Signature

Sign with a finger, and get the result back out as SVG or PNG.

A surface you sign with a finger, and a handle for getting the result back out as SVG or PNG.

The stroke being drawn is built on the UI thread and never reaches React, so the line keeps up with the fingertip. Finished strokes become static paths that never animate again.

Installation

Signature ships with the library — no separate install.

import { Signature, Button, Input, Frame, Portal, Scrim, Text, type SignatureHandle } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add signature

Usage

const pad = useRef<SignatureHandle>(null);

<Signature ref={pad} guideline guidelineLabel="Sign above the line" />
<Button onPress={() => pad.current?.save({ filename: 'agreement' })}>
  Finish signing
</Button>

Composition

<Signature ref={pad} />
<Signature.Toolbar>
  <Signature.Undo onPress={() => pad.current?.undo()} />
  <Signature.Redo onPress={() => pad.current?.redo()} />
  <Signature.Clear onPress={() => pad.current?.clear()} />
</Signature.Toolbar>
  • Signature.Toolbar — A row of controls under or over the pad. Purely layout.
  • Signature.Undo — Round button for removing the last stroke. Wire it to ref.current?.undo().
  • Signature.Redo — Round button for putting back the last undone stroke. Wire it to ref.current?.redo().
  • Signature.Clear — Round button for dropping every stroke. Wire it to ref.current?.clear().

The focused pad also exposes Undo, Redo and Clear as screen-reader actions when each action is available. Its value counts the strokes drawn so far, and the pad announces only the two moments it crosses between empty and not — a component cannot tell whether a signature is finished, and saying so after one mark would tell somebody who cannot see the pad that they are done.

Drawing a signature still requires tracing a path with direct touch. That interaction cannot be made non-path-dependent by adding a label to the canvas. If your signing policy permits another method, use onRequestAlternative to open a typed, uploaded or assisted flow, and offer the same choice as a visible control.

Examples

A pad with its controls

onChange reports the stroke count, which is the cheap way to keep a Save button disabled until there is something to save.

const pad = useRef<SignatureHandle>(null);
const [count, setCount] = useState(0);

<Signature ref={pad} onChange={setCount} />
<Signature.Toolbar>
  <View className="flex-row gap-2">
    <Signature.Undo disabled={count === 0} onPress={() => pad.current?.undo()} />
    <Signature.Clear disabled={count === 0} onPress={() => pad.current?.clear()} />
  </View>
  <Button disabled={count === 0} onPress={submit}>Done</Button>
</Signature.Toolbar>

A non-drawing alternative

This example offers a typed legal-name flow. Whether a typed or uploaded signature is acceptable is a product and legal-policy decision; the component does not silently treat it as equivalent to ink.

const [method, setMethod] = useState<'draw' | 'type'>('draw');
const useTypedSignature = () => setMethod('type');

{method === 'draw' ? (
  <>
    <Signature ref={pad} onRequestAlternative={useTypedSignature} />
    <Button variant="ghost" onPress={useTypedSignature}>
      Type my legal name instead
    </Button>
  </>
) : (
  <>
    <Input label="Legal name" value={legalName} onChangeText={setLegalName} />
    <Button variant="ghost" onPress={() => setMethod('draw')}>
      Draw instead
    </Button>
  </>
)}

Signing in a frame

Where a signature is usually asked for — over the thing being signed rather than on a screen of its own. Portal puts it above everything and Scrim frosts the screen behind it, so the frame is composed rather than borrowed from an overlay that has its own ideas about layout.

<Portal>
  <View className="absolute inset-0 items-center justify-center px-6">
    <Scrim blur />
    <Pressable className="absolute inset-0" onPress={close} />

    <Animated.View style={rise} className="w-full">
      <Frame className="rounded-[28px] border-2 border-dashed">
        <Frame.Header>
          <Signature.Clear disabled={count === 0} onPress={() => pad.current?.clear()} />
          <Frame.Title weight="semibold" className="flex-1 text-center text-foreground">
            Sign
          </Frame.Title>
          <CloseButton onPress={close} />
        </Frame.Header>
        {/* The shell's radius less its 2px border, so the panel's opaque
            corners stop short of the dashed edge instead of covering it. */}
        <Frame.Panel className="rounded-b-[26px]">
          <Signature ref={pad} size="lg" onChange={setCount} className="rounded-none border-0 bg-background" />
        </Frame.Panel>
      </Frame>
    </Animated.View>

    {/* Outside the frame, and absent until there is something to confirm. */}
    {count > 0 ? (
      <Animated.View
        entering={FadeInDown.springify()}
        exiting={FadeOutDown.duration(150)}
        className="absolute inset-x-6 bottom-16 items-center"
      >
        <Button size="lg" className="rounded-full px-8" onPress={finish}>
          Finish Signature
        </Button>
      </Animated.View>
    ) : null}
  </View>
</Portal>

Saving to a file

save() writes the signature and resolves where it went. SVG needs no extra packages; PNG rasterises the pad and needs the optional raster package.

const file = await pad.current?.save({
  directory: FileSystem.documentDirectory,
  filename: `agreement-${contractId}`,
  format: 'svg',
});

// file.uri  → 'file:///…/agreement-4821.svg'
// file.width, file.height → the pad's size in points

Sending it somewhere instead

toSVG() is pure string building — no packages, no async, nothing written to disk. For posting the signature to an API or storing it in a record.

await fetch('/api/agreements/4821/sign', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ signature: pad.current?.toSVG() }),
});

A baseline to sign above

The ✕ and the rule a paper form has. guidelineLabel captions it — a printed name under the line, usually.

<Signature guideline guidelineLabel="Khalid Abdi" size="lg" />

Versions

Signing frame

A framed pad over a frosted screen, with start-over on one side of the header and close on the other. The confirm button is not in the frame: inside, it is a third control competing for a strip of chrome and disabled for as long as the pad is empty. Outside and absent until the first stroke, it arrives exactly when it means something — and the frame lifts to make room for it.

const lift = useSharedValue(0);

useEffect(() => {
  lift.value = withSpring(count > 0 ? -28 : 0, { damping: 22, stiffness: 240, mass: 0.8 });
}, [count, lift]);

const rise = useAnimatedStyle(() => ({ transform: [{ translateY: lift.value }] }));

Signing a document

An agreement you scroll, with a Frame row that opens the same signing frame and the captured SVG landing back in the document.

<Frame.Row onPress={() => setOpen(true)} chevron>
  <Frame.Media><PencilIcon size={18} /></Frame.Media>
  <Frame.Content>
    <Frame.Title>{signature ? 'Signed' : 'Tap to sign'}</Frame.Title>
  </Frame.Content>
</Frame.Row>

Saving to a file

A format toggle and a Save button, printing the resulting URI — or the name of the package that is missing.

try {
  const file = await pad.current?.save({ format });
  setResult(file?.uri ?? null);
} catch (error) {
  setResult(error instanceof Error ? error.message : String(error));
}

Full screen

size="full" fills whatever it is given, with the actions on a bar below it.

<Signature size="full" guideline guidelineLabel="Khalid Abdi" />

Proof of delivery

Recipient, timestamp and signature on one screen, the shape a courier app asks for.

<Signature ref={pad} guideline onChange={setCount} />
<Button disabled={count === 0} onPress={confirm}>Confirm delivery</Button>

Variants

size

  • sm
  • md (default)
  • lg
  • full
<Signature size="sm">…</Signature>
<Signature size="md">…</Signature>
<Signature size="lg">…</Signature>
<Signature size="full">…</Signature>

API Reference

Signature

PropTypeDefaultDescription
classNamestring
size'sm' | 'md' | 'lg' | 'full'mdHow tall the pad is. full fills its parent instead.
strokeColorstringInk colour. Defaults to the theme's foreground.
strokeWidthnumber2.5Ink width in points.
minDistancenumber1.5Points closer together than this are dropped as they arrive, so a finger resting still does not add hundreds of points describing one spot.
guidelinebooleanfalseDraw the baseline and its ✕ mark, the way a paper form does.
guidelineLabelstringCaption beside the baseline. Only shown with guideline.
placeholderReactNodePrompt shown over an empty pad. Pass null for none.
disabledbooleanfalseTake no input. The strokes already there stay visible.
onRequestAlternative() => voidOpens a product-provided non-drawing method, such as typing a legal name, uploading an image, or asking for assisted signing. Exposed as a screen reader action; provide the same choice as a visible control too.
onBegin() => voidA stroke has started.
onEnd() => voidA stroke has finished.
onChange(strokeCount: number) => voidThe number of committed strokes changed — by drawing, undoing, redoing or clearing. The cheap way to enable a Save button only once something is there to save.
padClassNamestringClass on the drawing surface inside the border.
placeholderClassNamestringClass on the empty-pad prompt.
guideClassNamestringClass on the baseline.

Signature.Toolbar

PropTypeDefaultDescription
classNamestring

Signature.Button

PropTypeDefaultDescription
classNamestring
disabledbooleanfalseTake no input, and dim to say so.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

Why the stroke never reaches React

A finger produces touch events far faster than a component tree can usefully re-render, and a signature is exactly where the lag shows: the line trails the fingertip and drawing feels like moving through syrup.

So the stroke being drawn lives in a shared value and is turned into an SVG path by a worklet on the UI thread — React is not involved in a single frame of it. When the finger lifts, that one finished string crosses to JavaScript once and becomes a static path. Committed strokes never animate again, so the hundredth stroke costs what the first one did.

Points closer together than minDistance are dropped as they arrive. A finger resting still otherwise emits a point per frame in the same spot, which is a longer path describing the same shape.

Smoothing

Raw touch points joined with straight lines look like a seismograph. Each segment is drawn as a quadratic curve through the midpoint between two points instead: the point itself is the control handle, the midpoints are the anchors, and consecutive curves meet with a shared tangent. It needs no lookahead, so a point can be appended to a stroke already on screen without redrawing what came before it differently.

Getting the signature out

The ref exposes clear, undo, redo, isEmpty, strokeCount, toSVG, toDataURL and save.

toSVG() is pure string building — no packages, no async, nothing written to disk — and returns a standalone document sized to the pad. It is the one to reach for when the signature is going into an API call or a database record.

save() writes a file and resolves { uri, format, width, height }. It needs the optional expo-file-system. Passing format: 'png' rasterises the pad and also needs the optional react-native-view-shot. Neither is installed on your behalf, and asking for something a missing package provides throws with that package's name in the message rather than failing somewhere further down.

const file = await pad.current?.save({ filename: 'agreement', format: 'png', scale: 3 });

directory defaults to the app's own document directory, which survives restarts and is not visible to the user. Pass one explicitly to write anywhere else.

Public exports

Values: Signature, hasSignatureFileSystem, hasSignatureRaster

Types: SignatureProps, SignatureHandle, SignatureFile, SignatureSaveOptions, SignatureToolbarProps, SignatureButtonProps

On this page