Input
Draw Signature
A signing pill opens into a drawing pad, with saved strokes you can edit
Expo code
Original web component
Watermelon UIOur Expo component
React Native WebComponent source
Make it yours
One file to copy into your app.
draw-signature.tsx
import React, { useEffect, useLayoutEffect, useRef, useState } from "react"
import {
Platform,
Pressable,
StyleSheet,
Text,
View,
useColorScheme,
useWindowDimensions,
type StyleProp,
type ViewStyle,
} from "react-native"
import Animated, {
Easing,
ReduceMotion,
interpolateColor,
useAnimatedProps,
useAnimatedStyle,
useSharedValue,
withTiming,
type SharedValue,
} from "react-native-reanimated"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import { runOnUI, scheduleOnRN } from "react-native-worklets"
import Svg, { Path } from "react-native-svg"
const ICONS = {
draw: {
viewBox: "0 0 24 24",
paths: [
"m18.85 10.39 1.06-1.06c.78-.78.78-2.05 0-2.83L18.5 5.09c-.78-.78-2.05-.78-2.83 0l-1.06 1.06zm-5.66-2.83L4 16.76V21h4.24l9.19-9.19zM19 17.5c0 2.19-2.54 3.5-5 3.5-.55 0-1-.45-1-1s.45-1 1-1c1.54 0 3-.73 3-1.5 0-.47-.48-.87-1.23-1.2l1.48-1.48c1.07.63 1.75 1.47 1.75 2.68M4.58 13.35C3.61 12.79 3 12.06 3 11c0-1.8 1.89-2.63 3.56-3.36C7.59 7.18 9 6.56 9 6c0-.41-.78-1-2-1-1.26 0-1.8.61-1.83.64-.35.41-.98.46-1.4.12a.99.99 0 0 1-.15-1.38C3.73 4.24 4.76 3 7 3s4 1.32 4 3c0 1.87-1.93 2.72-3.64 3.47C6.42 9.88 5 10.5 5 11c0 .31.43.6 1.07.86z",
],
},
check: {
viewBox: "0 0 512 512",
paths: [
"M504 256c0 136.967-111.033 248-248 248S8 392.967 8 256 119.033 8 256 8s248 111.033 248 248zM227.314 387.314l184-184c6.248-6.248 6.248-16.379 0-22.627l-22.627-22.627c-6.248-6.249-16.379-6.249-22.628 0L216 308.118l-70.059-70.059c-6.248-6.248-16.379-6.248-22.628 0l-22.627 22.627c-6.248 6.248-6.248 16.379 0 22.627l104 104c6.249 6.249 16.379 6.249 22.628.001z",
],
},
redo: {
viewBox: "0 0 512 512",
paths: [
"M500.33 0h-47.41a12 12 0 0 0-12 12.57l4 82.76A247.42 247.42 0 0 0 256 8C119.34 8 7.9 119.53 8 256.19 8.1 393.07 119.1 504 256 504a247.1 247.1 0 0 0 166.18-63.91 12 12 0 0 0 .48-17.43l-34-34a12 12 0 0 0-16.38-.55A176 176 0 1 1 402.1 157.8l-101.53-4.87a12 12 0 0 0-12.57 12v47.41a12 12 0 0 0 12 12h200.33a12 12 0 0 0 12-12V12a12 12 0 0 0-12-12z",
],
},
pen: {
viewBox: "0 0 512 512",
paths: [
"M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z",
],
},
} as const
export type SignatureStep = "idle" | "drawing" | "done"
export type SignatureData = {
path: string
width: number
height: number
svg: string
}
export type DrawSignatureColors = {
pad: string
button: string
buttonHover: string
label: string
done: string
doneLabel: string
ink: string
border: string
header: string
clear: string
clearHover: string
close: string
closeHover: string
}
export type DrawSignatureComponentProps = {
startLabel?: string
finishLabel?: string
doneLabel?: string
defaultStep?: SignatureStep
onFinish?: (signature: SignatureData | null) => void
onClear?: () => void
onStepChange?: (step: SignatureStep) => void
theme?: "light" | "dark" | "system"
colors?: Partial<DrawSignatureColors>
width?: number
canvasWidth?: number
canvasHeight?: number
radius?: number
fontSize?: number
strokeWidth?: number
style?: StyleProp<ViewStyle>
testID?: string
}
const WEB = Platform.OS === "web",
FONT = WEB ? "Arial" : Platform.OS === "ios" ? "Helvetica" : "sans-serif"
const LIGHT: DrawSignatureColors = {
pad: "#fff",
button: "#f5f5f5",
buttonHover: "#e5e5e5",
label: "#171717",
done: "#171717",
doneLabel: "#fff",
ink: "#000",
border: "#d4d4d4",
header: "#737373",
clear: "#a3a3a3",
clearHover: "#525252",
close: "#a3a3a3",
closeHover: "#737373",
}
const DARK: DrawSignatureColors = {
pad: "#171717",
button: "#262626",
buttonHover: "#404040",
label: "#f5f5f5",
done: "#f5f5f5",
doneLabel: "#171717",
ink: "#fff",
border: "#404040",
header: "#a3a3a3",
clear: "#737373",
clearHover: "#d4d4d4",
close: "#404040",
closeHover: "#525252",
}
function spring(t: number) {
"worklet"
const seconds = t * 0.7,
decay = 21.103438635377675 / 2,
angular = Math.sqrt(154.1021184211596 - decay * decay)
return (
1 -
Math.exp(-decay * seconds) *
(Math.cos(angular * seconds) +
(decay / angular) * Math.sin(angular * seconds))
)
}
const MORPH = {
duration: 700,
easing: spring,
reduceMotion: ReduceMotion.System,
} as const
const BORDER = {
duration: 400,
easing: Easing.bezier(0, 0, 0.2, 1),
reduceMotion: ReduceMotion.System,
} as const
const limit = (x: number) => {
"worklet"
return Math.min(1, Math.max(0, x))
}
function entering(x: number) {
"worklet"
const t = limit(x * 2)
return Math.sqrt(1 - (t - 1) * (t - 1))
}
function leaving(x: number) {
"worklet"
return 1 - limit((x - 0.5) / 0.45)
}
const AnimatedPath = Animated.createAnimatedComponent(Path)
function Icon({
name,
size,
color,
}: {
name: keyof typeof ICONS | "close"
size: number
color: string
}) {
if (name === "close")
return (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M18 6 6 18M6 6l12 12"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
/>
</Svg>
)
const data = ICONS[name]
return (
<Svg width={size} height={size} viewBox={data.viewBox}>
{data.paths.map((d, i) => (
<Path key={i} d={d} fill={color} />
))}
</Svg>
)
}
function Drawing({
path,
width,
height,
canvasWidth,
strokeWidth,
ink,
testID,
}: {
path: SharedValue<string>
width: number
height: number
canvasWidth: number
strokeWidth: number
ink: string
testID?: string
}) {
const hasPoint = useSharedValue(false)
const downX = useSharedValue(0),
downY = useSharedValue(0)
const gesture = Gesture.Pan()
.minDistance(0)
.maxPointers(1)
.shouldCancelWhenOutside(true)
.onBegin(({ x, y }) => {
hasPoint.value = false
downX.value = Math.round(x * 100) / 100
downY.value = Math.round(y * 100) / 100
})
.onUpdate(({ x, y }) => {
const px = Math.round(x * 100) / 100,
py = Math.round(y * 100) / 100
if (!hasPoint.value && px === downX.value && py === downY.value) return
path.value += `${hasPoint.value ? "L" : "M"}${px} ${py} `
hasPoint.value = true
})
.onFinalize(() => {
hasPoint.value = false
})
const props = useAnimatedProps(() => ({ d: path.value }))
return (
<GestureDetector gesture={gesture}>
<View
testID={testID}
accessibilityLabel="Signature drawing area"
style={
{
width,
height,
overflow: "hidden",
...(WEB
? {
touchAction: "none",
cursor: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 24 24" fill="${ink === "#fff" ? "white" : "black"}" stroke="${ink === "#fff" ? "black" : "white"}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>') 0 22, auto`,
}
: {}),
} as ViewStyle
}
>
<Svg
width={width}
height={height}
viewBox={`0 0 ${canvasWidth} ${height}`}
preserveAspectRatio="none"
pointerEvents="none"
>
<AnimatedPath
animatedProps={props}
fill="none"
stroke={ink}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
</View>
</GestureDetector>
)
}
function SmallButton({
name,
size,
background,
hoverBackground,
color,
hoverColor,
onPress,
label,
}: {
name: "redo" | "close" | "pen"
size: number
background?: string
hoverBackground?: string
color: string
hoverColor?: string
onPress: () => void
label: string
}) {
const [hover, setHover] = useState(false)
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
hitSlop={name === "redo" ? 13 : name === "close" ? 10 : 0}
onPress={onPress}
onHoverIn={() => setHover(true)}
onHoverOut={() => setHover(false)}
style={{
width: size,
height: size,
borderRadius: size / 2,
alignItems: "center",
justifyContent: "center",
backgroundColor: hover ? (hoverBackground ?? background) : background,
}}
>
<Icon
name={name}
size={name === "close" ? 20 : 22}
color={hover ? (hoverColor ?? color) : color}
/>
</Pressable>
)
}
export function DrawSignatureComponent({
startLabel = "Start Signing",
finishLabel = "Finish Signing",
doneLabel = "Signing Done",
defaultStep = "idle",
onFinish,
onClear,
onStepChange,
theme = "system",
colors: overrides,
width = 320,
canvasWidth = 290,
canvasHeight = 200,
radius = 32,
fontSize = 18,
strokeWidth = 3,
style,
testID,
}: DrawSignatureComponentProps) {
const system = useColorScheme(),
{ fontScale } = useWindowDimensions()
const colors = {
...(theme === "dark" || (theme === "system" && system === "dark")
? DARK
: LIGHT),
...overrides,
}
const [step, setStep] = useState<SignatureStep>(defaultStep),
[previous, setPrevious] = useState<SignatureStep | null>(null)
const [padMounted, setPadMounted] = useState(defaultStep === "drawing"),
[textWidths, setTextWidths] = useState({
idle: 112,
drawing: 124,
done: 116,
})
const saved = useRef(""),
finishPending = useRef(false),
exitTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const path = useSharedValue(""),
transition = useSharedValue(1),
padExit = useSharedValue(0),
border = useSharedValue(defaultStep === "drawing" ? 1 : 0),
press = useSharedValue(1),
hover = useSharedValue(0)
const lineHeight = (28 / 18) * fontSize * fontScale,
padHeight =
24 +
Math.max(28, lineHeight) +
24 +
canvasHeight +
16 +
Math.max(28, lineHeight) +
32 +
16
const idleWidth = textWidths.idle + 24 + 8 + 64,
donePillWidth = textWidths.done + 24 + 8 + 48,
doneWidth = donePillWidth + 12 + 54
const sizes = {
idle: { w: idleWidth + 8, h: lineHeight + 48 },
drawing: { w: width + 8, h: padHeight + 8 },
done: { w: doneWidth + 8, h: Math.max(lineHeight + 32, 54) + 8 },
}
const initial = sizes[defaultStep]
const rootW = useSharedValue(initial.w),
rootH = useSharedValue(initial.h)
const rectangles = {
idle: {
x: -idleWidth / 2,
y: -(lineHeight + 40) / 2,
w: idleWidth,
h: lineHeight + 40,
ix: -idleWidth / 2 + 32 + 12,
iy: 0,
is: 24,
tx: -idleWidth / 2 + 64,
ty: 0,
tw: textWidths.idle,
},
drawing: {
x: -(width - 48) / 2,
y: padHeight / 2 - 16 - (lineHeight + 32),
w: width - 48,
h: lineHeight + 32,
ix: -(textWidths.drawing + 36) / 2 + 14,
iy: padHeight / 2 - 16 - (lineHeight + 32) / 2,
is: 28,
tx: -(textWidths.drawing + 36) / 2 + 36,
ty: padHeight / 2 - 16 - (lineHeight + 32) / 2,
tw: textWidths.drawing,
},
done: {
x: -doneWidth / 2,
y: -(lineHeight + 32) / 2,
w: doneWidth,
h: lineHeight + 32,
ix: -doneWidth / 2 + 24 + 12,
iy: 0,
is: 24,
tx: -doneWidth / 2 + 56,
ty: 0,
tw: textWidths.done,
},
}
const r = rectangles[defaultStep]
const bx = useSharedValue(r.x),
by = useSharedValue(r.y),
bw = useSharedValue(r.w),
bh = useSharedValue(r.h)
const ix = useSharedValue(r.ix),
iy = useSharedValue(r.iy),
iconSize = useSharedValue(r.is),
tx = useSharedValue(r.tx),
ty = useSharedValue(r.ty),
tw = useSharedValue(r.tw)
useEffect(() => {
onStepChange?.(step)
}, [step, onStepChange])
useEffect(
() => () => {
if (exitTimer.current) clearTimeout(exitTimer.current)
},
[]
)
useLayoutEffect(() => {
const next = rectangles[step]
bx.value = withTiming(next.x, MORPH)
by.value = withTiming(next.y, MORPH)
bw.value = withTiming(next.w, MORPH)
bh.value = withTiming(next.h, MORPH)
ix.value = withTiming(next.ix, MORPH)
iy.value = withTiming(next.iy, MORPH)
iconSize.value = withTiming(next.is, MORPH)
tx.value = withTiming(next.tx, MORPH)
ty.value = withTiming(next.ty, MORPH)
tw.value = withTiming(next.tw, MORPH)
border.value = withTiming(step === "drawing" ? 1 : 0, BORDER)
}, [step, textWidths, fontScale, width, fontSize, padHeight])
const moveTo = (next: SignatureStep) => {
if (exitTimer.current) clearTimeout(exitTimer.current)
setPrevious(step)
transition.value = 0
transition.value = withTiming(1, MORPH)
if (next === "drawing") {
path.value = saved.current
setPadMounted(true)
padExit.value = 0
} else if (step === "drawing") {
padExit.value = withTiming(1, MORPH)
exitTimer.current = setTimeout(() => {
setPadMounted(false)
setPrevious(null)
}, 700)
} else {
exitTimer.current = setTimeout(() => setPrevious(null), 700)
}
press.value = 1
hover.value = 0
setStep(next)
}
const clear = () => {
path.value = ""
saved.current = ""
onClear?.()
}
const complete = (value: string) => {
saved.current = value
finishPending.current = false
moveTo("done")
const safeInk = colors.ink
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/</g, "<")
onFinish?.({
path: value,
width: canvasWidth,
height: canvasHeight,
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${canvasWidth}" height="${canvasHeight}" viewBox="0 0 ${canvasWidth} ${canvasHeight}"><path d="${value}" fill="none" stroke="${safeInk}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
})
}
const finish = () => {
if (finishPending.current) return
finishPending.current = true
runOnUI(() => {
"worklet"
scheduleOnRN(complete, path.value)
})()
}
const rootStyle = useAnimatedStyle(() => ({
width: rootW.value,
height: rootH.value,
borderColor: interpolateColor(
border.value,
[0, 1],
["transparent", colors.border]
),
}))
const padStyle = useAnimatedStyle(() => ({
left: step === "drawing" ? (rootW.value - width) / 2 - 4 : 0,
top: step === "drawing" ? (rootH.value - padHeight) / 2 - 4 : 0,
opacity: 1 - limit(padExit.value),
transform: [
{ translateX: -width * 0.1 * padExit.value },
{ translateY: -padHeight * 0.3 * padExit.value },
],
}))
const buttonStyle = useAnimatedStyle(() => ({
left: rootW.value / 2 + bx.value - 4,
top: rootH.value / 2 + by.value - 4,
width: bw.value,
height: bh.value,
opacity: 1,
transform: [{ scale: press.value }],
}))
const pillStyle = useAnimatedStyle(() => ({
width: step === "done" ? (bw.value * donePillWidth) / doneWidth : bw.value,
height: bh.value,
borderRadius: bh.value / 2,
backgroundColor:
step === "done"
? colors.done
: interpolateColor(
hover.value,
[0, 1],
[colors.button, colors.buttonHover]
),
}))
const editStyle = useAnimatedStyle(() => ({
left: (bw.value * (donePillWidth + 12)) / doneWidth,
top: (bh.value - 54) / 2,
width: 54,
height: 54,
transform: [{ scaleX: bw.value / doneWidth }],
}))
const iconStyle = useAnimatedStyle(() => ({
left: rootW.value / 2 + ix.value - 18 - 4,
top: rootH.value / 2 + iy.value - 18 - 4,
transform: [
{
translateX: (ix.value - (bx.value + bw.value / 2)) * (press.value - 1),
},
{
translateY: (iy.value - (by.value + bh.value / 2)) * (press.value - 1),
},
{ scale: (iconSize.value / 36) * press.value },
],
}))
const textStyle = useAnimatedStyle(() => ({
left: rootW.value / 2 + tx.value - 4,
top: rootH.value / 2 + ty.value - lineHeight / 2 - 4,
width: tw.value,
height: lineHeight,
transform: [
{
translateX: (tx.value - (bx.value + bw.value / 2)) * (press.value - 1),
},
{
translateY: (ty.value - (by.value + bh.value / 2)) * (press.value - 1),
},
{ scale: press.value },
],
}))
const enterStyle = useAnimatedStyle(() => ({
opacity: previous ? entering(transition.value) : 1,
}))
const exitStyle = useAnimatedStyle(() => ({
opacity: previous === "idle" ? leaving(transition.value) : 0,
}))
const labels = { idle: startLabel, drawing: finishLabel, done: doneLabel }
const incomingText = useAnimatedStyle(() => ({
transform: [{ scaleX: tw.value / textWidths[step] }],
}))
const outgoingText = useAnimatedStyle(() => ({
transform: [
{ scaleX: tw.value / (previous ? textWidths[previous] : tw.value) },
],
}))
const textBase = {
fontFamily: FONT,
fontWeight: "700" as const,
fontSize,
lineHeight: (28 / 18) * fontSize,
includeFontPadding: false,
}
return (
<Animated.View
testID={testID}
style={[
styles.root,
{ borderRadius: radius, alignSelf: "center" },
style,
rootStyle,
]}
>
<View
pointerEvents="none"
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={{
position: "absolute",
width: sizes[step].w,
height: sizes[step].h,
opacity: 0,
}}
onLayout={() => {
rootW.value = withTiming(sizes[step].w, MORPH)
rootH.value = withTiming(sizes[step].h, MORPH)
}}
/>
<View
pointerEvents="none"
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={{
position: "absolute",
width: 0,
height: 0,
overflow: "hidden",
}}
>
<View style={{ width: 10000, alignItems: "flex-start" }}>
{(["idle", "drawing", "done"] as const).map((k) => (
<Text
key={k}
style={textBase}
onLayout={(e) => {
const measured = Math.round(e.nativeEvent.layout.width)
if (measured > 0)
setTextWidths((old) =>
old[k] === measured ? old : { ...old, [k]: measured }
)
}}
>
{labels[k]}
</Text>
))}
</View>
</View>
{padMounted && (
<Animated.View
pointerEvents={step === "drawing" ? "auto" : "none"}
style={[
{
position: "absolute",
width,
height: padHeight,
backgroundColor: colors.pad,
borderRadius: 34,
padding: 24,
paddingBottom: 16,
},
padStyle,
]}
>
<View
style={{
height: Math.max(28, lineHeight),
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 24,
}}
>
<SmallButton
name="redo"
size={22}
color={colors.clear}
hoverColor={colors.clearHover}
onPress={clear}
label="Clear signature"
/>
<Text style={[textBase, { color: colors.header }]}>Sign</Text>
<SmallButton
name="close"
size={28}
background={colors.close}
hoverBackground={colors.closeHover}
color="#fff"
onPress={() => moveTo("idle")}
label="Cancel signing"
/>
</View>
<Drawing
path={path}
width={width - 48}
height={canvasHeight}
canvasWidth={canvasWidth}
strokeWidth={strokeWidth}
ink={colors.ink}
testID={testID ? `${testID}-canvas` : undefined}
/>
</Animated.View>
)}
<Animated.View style={[{ position: "absolute" }, buttonStyle]}>
{previous === "idle" && (
<Animated.View
pointerEvents="none"
style={[
{ position: "absolute", backgroundColor: colors.button },
pillStyle,
exitStyle,
]}
/>
)}
<Animated.View
pointerEvents="none"
style={[{ position: "absolute" }, pillStyle, enterStyle]}
/>
{step === "done" ? (
<Animated.View
style={[{ position: "absolute" }, editStyle, enterStyle]}
>
<SmallButton
name="pen"
size={54}
background={colors.button}
hoverBackground={colors.buttonHover}
color={colors.label}
onPress={() => moveTo("drawing")}
label="Edit signature"
/>
</Animated.View>
) : (
<Pressable
style={{ width: "100%", height: "100%" }}
accessibilityRole="button"
accessibilityLabel={labels[step]}
onPress={step === "idle" ? () => moveTo("drawing") : finish}
onHoverIn={() => {
hover.value = 1
}}
onHoverOut={() => {
hover.value = 0
}}
onPressIn={() => {
if (step === "drawing") press.value = withTiming(0.97, MORPH)
}}
onPressOut={() => {
press.value = withTiming(1, MORPH)
}}
/>
)}
</Animated.View>
<Animated.View
pointerEvents="none"
style={[{ position: "absolute", width: 36, height: 36 }, iconStyle]}
>
{previous && (
<Animated.View style={[StyleSheet.absoluteFill, exitStyle]}>
<Icon
name={previous === "done" ? "check" : "draw"}
size={36}
color={previous === "done" ? colors.doneLabel : colors.label}
/>
</Animated.View>
)}
<Animated.View style={enterStyle}>
<Icon
name={step === "done" ? "check" : "draw"}
size={36}
color={step === "done" ? colors.doneLabel : colors.label}
/>
</Animated.View>
</Animated.View>
<Animated.View
pointerEvents="none"
style={[
{
position: "absolute",
overflow: "visible",
transformOrigin: "left center",
},
textStyle,
]}
>
{previous && (
<Animated.View
style={[
{
position: "absolute",
width: textWidths[previous],
height: lineHeight,
left: 0,
transformOrigin: "left center",
},
exitStyle,
outgoingText,
]}
>
<Text
style={[
textBase,
{
color: previous === "done" ? colors.doneLabel : colors.label,
},
]}
>
{labels[previous]}
</Text>
</Animated.View>
)}
<Animated.View
style={[
{
width: textWidths[step],
height: lineHeight,
transformOrigin: "left center",
},
enterStyle,
incomingText,
]}
>
<Text
style={[
textBase,
{ color: step === "done" ? colors.doneLabel : colors.label },
]}
>
{labels[step]}
</Text>
</Animated.View>
</Animated.View>
</Animated.View>
)
}
const styles = StyleSheet.create({
root: {
position: "relative",
overflow: "hidden",
borderWidth: 4,
borderStyle: "dashed",
},
})
Give the gestures and motion a run on a real Android phone.