# Signature Pad

A drawing area that captures a handwritten signature as strokes and can export it as an SVG string.

Signature Pad is an open-source component from suiss/ui for React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/signature-pad

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-signature-pad--default.light.webp)

```tsx
import { SignaturePad } from "@/components/ui/signature-pad"

export function SignaturePadExample() {
  return (
    <SignaturePad />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SignaturePadExample: View {
  var body: some View {
    SignaturePadDemo(initial: [])
  }
}

struct SignaturePadDemo: View {
  var strokeWidth: CGFloat = 2.5

  @State private var strokes: [SignatureStroke]

  init(initial: [SignatureStroke], strokeWidth: CGFloat = 2.5) {
    self.strokeWidth = strokeWidth
    _strokes = State(initialValue: initial)
  }

  var body: some View {
    SignaturePad(value: $strokes, strokeWidth: strokeWidth)
      .frame(height: 192)
      .frame(maxWidth: .infinity)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.esuiss.ui.components.SignaturePad

@Composable
fun SignaturePadExample() {
    SignaturePad(Modifier.fillMaxWidth())
}
```

`SignaturePad` is a `View` with a single-finger pan gesture from `react-native-gesture-handler`. Each touch records a stroke of points, and the strokes are drawn as smoothed paths with `react-native-svg`. A signature line with an `×` mark and a placeholder sits near the bottom, and a ghost Clear button appears in the top corner once something is drawn.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/signature-pad
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add signature-pad
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add signature-pad --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add signature-pad --platform kotlin
```

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text).

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-gesture-handler react-native-svg
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/ui/signature-pad.tsx`:

```tsx title="uniwind/components/ui/signature-pad.tsx"
import { cn } from "cn"
import type { IconProps as PhosphorIconProps } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import Svg, { Path } from "react-native-svg"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text, TextClassContext } from "@/components/ui/text"

type SignaturePadPoint = { x: number; y: number }

type SignaturePadStroke = SignaturePadPoint[]

type SignaturePadHandle = {
  toSVG: (options?: { color?: string }) => string
  clear: () => void
  isEmpty: () => boolean
}

type SignaturePadContextValue = {
  strokes: SignaturePadStroke[]
  strokeWidth: number
}

const SignaturePadContext = React.createContext<SignaturePadContextValue>({
  strokes: [],
  strokeWidth: 2.5,
})

function round(value: number) {
  return Math.round(value * 10) / 10
}

function strokePath(stroke: SignaturePadStroke) {
  const [first, ...rest] = stroke
  if (!first) return ""
  if (rest.length === 0) {
    return `M${round(first.x)} ${round(first.y)}L${round(first.x)} ${round(first.y)}`
  }
  let path = `M${round(first.x)} ${round(first.y)}`
  for (let index = 1; index < stroke.length - 1; index++) {
    const point = stroke[index]
    const next = stroke[index + 1]
    if (!point || !next) continue
    path += `Q${round(point.x)} ${round(point.y)} ${round((point.x + next.x) / 2)} ${round((point.y + next.y) / 2)}`
  }
  const last = stroke.at(-1)
  if (last) path += `L${round(last.x)} ${round(last.y)}`
  return path
}

function signatureToSVG(
  strokes: SignaturePadStroke[],
  width: number,
  height: number,
  strokeWidth: number,
  color: string
) {
  const paths = strokes
    .filter((stroke) => stroke.length > 0)
    .map(
      (stroke) =>
        `<path d="${strokePath(stroke)}" fill="none" stroke="${color}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"/>`
    )
    .join("")
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${round(width)}" height="${round(height)}" viewBox="0 0 ${round(width)} ${round(height)}">${paths}</svg>`
}

function SignaturePadStrokes({ color }: PhosphorIconProps) {
  const { strokes, strokeWidth } = React.useContext(SignaturePadContext)
  return (
    <Svg width="100%" height="100%">
      <Path
        d={strokes.map(strokePath).join("")}
        fill="none"
        stroke={color}
        strokeWidth={strokeWidth}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </Svg>
  )
}

function SignaturePad({
  value: valueProp,
  defaultValue,
  onChange,
  strokeWidth = 2.5,
  disabled = false,
  placeholder = "Sign here",
  clearLabel = "Clear",
  accessibilityLabel = "Signature",
  className,
  ref,
  accessibilityActions,
  onAccessibilityAction,
  onLayout,
  ...props
}: Omit<React.ComponentProps<typeof View>, "children"> & {
  value?: SignaturePadStroke[]
  defaultValue?: SignaturePadStroke[]
  onChange?: (value: SignaturePadStroke[]) => void
  strokeWidth?: number
  disabled?: boolean
  placeholder?: React.ReactNode
  clearLabel?: React.ReactNode
  ref?: React.Ref<SignaturePadHandle>
}) {
  const [uncontrolled, setUncontrolled] = React.useState<SignaturePadStroke[]>(
    defaultValue ?? []
  )
  const isControlled = valueProp !== undefined
  const value = isControlled ? valueProp : uncontrolled
  const valueRef = React.useRef(value)
  valueRef.current = value
  const [active, setActive] = React.useState<SignaturePadStroke | null>(null)
  const activeRef = React.useRef<SignaturePadStroke | null>(null)
  const sizeRef = React.useRef({ width: 0, height: 0 })

  const commit = React.useCallback(
    (next: SignaturePadStroke[]) => {
      valueRef.current = next
      if (!isControlled) setUncontrolled(next)
      onChange?.(next)
    },
    [isControlled, onChange]
  )

  const clear = React.useCallback(() => {
    activeRef.current = null
    setActive(null)
    if (valueRef.current.length === 0) return
    commit([])
  }, [commit])

  React.useImperativeHandle(
    ref,
    () => ({
      toSVG: (options) =>
        signatureToSVG(
          valueRef.current,
          sizeRef.current.width,
          sizeRef.current.height,
          strokeWidth,
          options?.color ?? "currentColor"
        ),
      clear,
      isEmpty: () => valueRef.current.length === 0,
    }),
    [clear, strokeWidth]
  )

  const gesture = React.useMemo(
    () =>
      Gesture.Pan()
        .runOnJS(true)
        .enabled(!disabled)
        .minDistance(0)
        .maxPointers(1)
        .onBegin((event) => {
          const stroke = [{ x: event.x, y: event.y }]
          activeRef.current = stroke
          setActive(stroke)
        })
        .onUpdate((event) => {
          const current = activeRef.current
          if (!current) return
          const last = current.at(-1)
          if (
            last &&
            Math.abs(last.x - event.x) < 1 &&
            Math.abs(last.y - event.y) < 1
          ) {
            return
          }
          const stroke = [...current, { x: event.x, y: event.y }]
          activeRef.current = stroke
          setActive(stroke)
        })
        .onFinalize(() => {
          const stroke = activeRef.current
          activeRef.current = null
          setActive(null)
          if (stroke && stroke.length > 0) commit([...valueRef.current, stroke])
        }),
    [disabled, commit]
  )

  const strokes = active ? [...value, active] : value
  const empty = strokes.length === 0
  const context = React.useMemo(
    () => ({ strokes, strokeWidth }),
    [strokes, strokeWidth]
  )

  return (
    <View
      {...props}
      accessible
      accessibilityLabel={accessibilityLabel}
      accessibilityValue={{ text: empty ? "Empty" : "Signed" }}
      accessibilityState={{ disabled }}
      accessibilityActions={
        empty || disabled
          ? accessibilityActions
          : [{ name: "clear", label: "Clear" }, ...(accessibilityActions ?? [])]
      }
      onAccessibilityAction={(event) => {
        if (event.nativeEvent.actionName === "clear" && !empty && !disabled) {
          clear()
          return
        }
        onAccessibilityAction?.(event)
      }}
      onLayout={(event) => {
        sizeRef.current = {
          width: event.nativeEvent.layout.width,
          height: event.nativeEvent.layout.height,
        }
        onLayout?.(event)
      }}
      className={cn(
        "relative h-48 w-full overflow-hidden rounded-2xl border border-border bg-card dark:bg-muted",
        disabled && "opacity-50",
        className
      )}
    >
      <View
        pointerEvents="none"
        className="absolute inset-x-6 bottom-12 flex-row items-end gap-2 border-b border-input pb-1.5"
      >
        <Text className="text-lg text-muted-foreground">×</Text>
        {empty &&
          (typeof placeholder === "string" ||
          typeof placeholder === "number" ? (
            <Text className="text-sm text-muted-foreground">{placeholder}</Text>
          ) : (
            placeholder
          ))}
      </View>
      <GestureDetector gesture={gesture}>
        <View className="absolute inset-0">
          <SignaturePadContext.Provider value={context}>
            <TextClassContext.Provider value={undefined}>
              <View pointerEvents="none" className="absolute inset-0">
                <Icon as={SignaturePadStrokes} className="text-foreground" />
              </View>
            </TextClassContext.Provider>
          </SignaturePadContext.Provider>
        </View>
      </GestureDetector>
      {!empty && !disabled ? (
        <View className="absolute end-2 top-2">
          <Button variant="ghost" size="sm" onPress={clear}>
            {typeof clearLabel === "string" ||
            typeof clearLabel === "number" ? (
              <Text>{clearLabel}</Text>
            ) : (
              clearLabel
            )}
          </Button>
        </View>
      ) : null}
    </View>
  )
}

export type { SignaturePadHandle, SignaturePadPoint, SignaturePadStroke }
export { SignaturePad }
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-gesture-handler react-native-svg
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/ui/signature-pad.tsx`:

```tsx title="nativewind/components/ui/signature-pad.tsx"
import { cn } from "cn"
import type { IconProps as PhosphorIconProps } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import Svg, { Path } from "react-native-svg"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text, TextClassContext } from "@/components/ui/text"

type SignaturePadPoint = { x: number; y: number }

type SignaturePadStroke = SignaturePadPoint[]

type SignaturePadHandle = {
  toSVG: (options?: { color?: string }) => string
  clear: () => void
  isEmpty: () => boolean
}

type SignaturePadContextValue = {
  strokes: SignaturePadStroke[]
  strokeWidth: number
}

const SignaturePadContext = React.createContext<SignaturePadContextValue>({
  strokes: [],
  strokeWidth: 2.5,
})

function round(value: number) {
  return Math.round(value * 10) / 10
}

function strokePath(stroke: SignaturePadStroke) {
  const [first, ...rest] = stroke
  if (!first) return ""
  if (rest.length === 0) {
    return `M${round(first.x)} ${round(first.y)}L${round(first.x)} ${round(first.y)}`
  }
  let path = `M${round(first.x)} ${round(first.y)}`
  for (let index = 1; index < stroke.length - 1; index++) {
    const point = stroke[index]
    const next = stroke[index + 1]
    if (!point || !next) continue
    path += `Q${round(point.x)} ${round(point.y)} ${round((point.x + next.x) / 2)} ${round((point.y + next.y) / 2)}`
  }
  const last = stroke.at(-1)
  if (last) path += `L${round(last.x)} ${round(last.y)}`
  return path
}

function signatureToSVG(
  strokes: SignaturePadStroke[],
  width: number,
  height: number,
  strokeWidth: number,
  color: string
) {
  const paths = strokes
    .filter((stroke) => stroke.length > 0)
    .map(
      (stroke) =>
        `<path d="${strokePath(stroke)}" fill="none" stroke="${color}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"/>`
    )
    .join("")
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${round(width)}" height="${round(height)}" viewBox="0 0 ${round(width)} ${round(height)}">${paths}</svg>`
}

function SignaturePadStrokes({ color }: PhosphorIconProps) {
  const { strokes, strokeWidth } = React.useContext(SignaturePadContext)
  return (
    <Svg width="100%" height="100%">
      <Path
        d={strokes.map(strokePath).join("")}
        fill="none"
        stroke={color}
        strokeWidth={strokeWidth}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </Svg>
  )
}

function SignaturePad({
  value: valueProp,
  defaultValue,
  onChange,
  strokeWidth = 2.5,
  disabled = false,
  placeholder = "Sign here",
  clearLabel = "Clear",
  accessibilityLabel = "Signature",
  className,
  ref,
  accessibilityActions,
  onAccessibilityAction,
  onLayout,
  ...props
}: Omit<React.ComponentProps<typeof View>, "children"> & {
  value?: SignaturePadStroke[]
  defaultValue?: SignaturePadStroke[]
  onChange?: (value: SignaturePadStroke[]) => void
  strokeWidth?: number
  disabled?: boolean
  placeholder?: React.ReactNode
  clearLabel?: React.ReactNode
  ref?: React.Ref<SignaturePadHandle>
}) {
  const [uncontrolled, setUncontrolled] = React.useState<SignaturePadStroke[]>(
    defaultValue ?? []
  )
  const isControlled = valueProp !== undefined
  const value = isControlled ? valueProp : uncontrolled
  const valueRef = React.useRef(value)
  valueRef.current = value
  const [active, setActive] = React.useState<SignaturePadStroke | null>(null)
  const activeRef = React.useRef<SignaturePadStroke | null>(null)
  const sizeRef = React.useRef({ width: 0, height: 0 })

  const commit = React.useCallback(
    (next: SignaturePadStroke[]) => {
      valueRef.current = next
      if (!isControlled) setUncontrolled(next)
      onChange?.(next)
    },
    [isControlled, onChange]
  )

  const clear = React.useCallback(() => {
    activeRef.current = null
    setActive(null)
    if (valueRef.current.length === 0) return
    commit([])
  }, [commit])

  React.useImperativeHandle(
    ref,
    () => ({
      toSVG: (options) =>
        signatureToSVG(
          valueRef.current,
          sizeRef.current.width,
          sizeRef.current.height,
          strokeWidth,
          options?.color ?? "currentColor"
        ),
      clear,
      isEmpty: () => valueRef.current.length === 0,
    }),
    [clear, strokeWidth]
  )

  const gesture = React.useMemo(
    () =>
      Gesture.Pan()
        .runOnJS(true)
        .enabled(!disabled)
        .minDistance(0)
        .maxPointers(1)
        .onBegin((event) => {
          const stroke = [{ x: event.x, y: event.y }]
          activeRef.current = stroke
          setActive(stroke)
        })
        .onUpdate((event) => {
          const current = activeRef.current
          if (!current) return
          const last = current.at(-1)
          if (
            last &&
            Math.abs(last.x - event.x) < 1 &&
            Math.abs(last.y - event.y) < 1
          ) {
            return
          }
          const stroke = [...current, { x: event.x, y: event.y }]
          activeRef.current = stroke
          setActive(stroke)
        })
        .onFinalize(() => {
          const stroke = activeRef.current
          activeRef.current = null
          setActive(null)
          if (stroke && stroke.length > 0) commit([...valueRef.current, stroke])
        }),
    [disabled, commit]
  )

  const strokes = active ? [...value, active] : value
  const empty = strokes.length === 0
  const context = React.useMemo(
    () => ({ strokes, strokeWidth }),
    [strokes, strokeWidth]
  )

  return (
    <View
      {...props}
      accessible
      accessibilityLabel={accessibilityLabel}
      accessibilityValue={{ text: empty ? "Empty" : "Signed" }}
      accessibilityState={{ disabled }}
      accessibilityActions={
        empty || disabled
          ? accessibilityActions
          : [{ name: "clear", label: "Clear" }, ...(accessibilityActions ?? [])]
      }
      onAccessibilityAction={(event) => {
        if (event.nativeEvent.actionName === "clear" && !empty && !disabled) {
          clear()
          return
        }
        onAccessibilityAction?.(event)
      }}
      onLayout={(event) => {
        sizeRef.current = {
          width: event.nativeEvent.layout.width,
          height: event.nativeEvent.layout.height,
        }
        onLayout?.(event)
      }}
      className={cn(
        "relative h-48 w-full overflow-hidden rounded-2xl border border-border bg-card dark:bg-muted",
        disabled && "opacity-50",
        className
      )}
    >
      <View
        pointerEvents="none"
        className="absolute inset-x-6 bottom-12 flex-row items-end gap-2 border-b border-input pb-1.5"
      >
        <Text className="text-lg text-muted-foreground">×</Text>
        {empty &&
          (typeof placeholder === "string" ||
          typeof placeholder === "number" ? (
            <Text className="text-sm text-muted-foreground">{placeholder}</Text>
          ) : (
            placeholder
          ))}
      </View>
      <GestureDetector gesture={gesture}>
        <View className="absolute inset-0">
          <SignaturePadContext.Provider value={context}>
            <TextClassContext.Provider value={undefined}>
              <View pointerEvents="none" className="absolute inset-0">
                <Icon as={SignaturePadStrokes} className="text-foreground" />
              </View>
            </TextClassContext.Provider>
          </SignaturePadContext.Provider>
        </View>
      </GestureDetector>
      {!empty && !disabled ? (
        <View className="absolute end-2 top-2">
          <Button variant="ghost" size="sm" onPress={clear}>
            {typeof clearLabel === "string" ||
            typeof clearLabel === "number" ? (
              <Text>{clearLabel}</Text>
            ) : (
              clearLabel
            )}
          </Button>
        </View>
      ) : null}
    </View>
  )
}

export type { SignaturePadHandle, SignaturePadPoint, SignaturePadStroke }
export { SignaturePad }
```

4. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install react-native-gesture-handler react-native-svg
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/ui/signature-pad.tsx`:

```tsx title="stylesheet/components/ui/signature-pad.tsx"
import * as React from "react"
import { type StyleProp, StyleSheet, View, type ViewStyle } from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import Svg, { Path } from "react-native-svg"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"
import { fontSize, radius, useTheme } from "@/components/ui/theme"

type SignaturePadPoint = { x: number; y: number }

type SignaturePadStroke = SignaturePadPoint[]

type SignaturePadHandle = {
  toSVG: (options?: { color?: string }) => string
  clear: () => void
  isEmpty: () => boolean
}

type SignaturePadContextValue = {
  strokes: SignaturePadStroke[]
  strokeWidth: number
}

const SignaturePadContext = React.createContext<SignaturePadContextValue>({
  strokes: [],
  strokeWidth: 2.5,
})

function round(value: number) {
  return Math.round(value * 10) / 10
}

function strokePath(stroke: SignaturePadStroke) {
  const [first, ...rest] = stroke
  if (!first) return ""
  if (rest.length === 0) {
    return `M${round(first.x)} ${round(first.y)}L${round(first.x)} ${round(first.y)}`
  }
  let path = `M${round(first.x)} ${round(first.y)}`
  for (let index = 1; index < stroke.length - 1; index++) {
    const point = stroke[index]
    const next = stroke[index + 1]
    if (!point || !next) continue
    path += `Q${round(point.x)} ${round(point.y)} ${round((point.x + next.x) / 2)} ${round((point.y + next.y) / 2)}`
  }
  const last = stroke.at(-1)
  if (last) path += `L${round(last.x)} ${round(last.y)}`
  return path
}

function signatureToSVG(
  strokes: SignaturePadStroke[],
  width: number,
  height: number,
  strokeWidth: number,
  color: string
) {
  const paths = strokes
    .filter((stroke) => stroke.length > 0)
    .map(
      (stroke) =>
        `<path d="${strokePath(stroke)}" fill="none" stroke="${color}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"/>`
    )
    .join("")
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${round(width)}" height="${round(height)}" viewBox="0 0 ${round(width)} ${round(height)}">${paths}</svg>`
}

const styles = StyleSheet.create({
  root: {
    position: "relative",
    height: 192,
    width: "100%",
    overflow: "hidden",
    borderRadius: radius["2xl"],
    borderWidth: 1,
  },
  baseline: {
    position: "absolute",
    left: 24,
    right: 24,
    bottom: 48,
    flexDirection: "row",
    alignItems: "flex-end",
    gap: 8,
    borderBottomWidth: 1,
    paddingBottom: 6,
  },
  mark: { ...fontSize.lg },
  placeholder: { ...fontSize.sm },
  clear: { position: "absolute", end: 8, top: 8 },
})

function SignaturePadStrokes({ color }: { color: string }) {
  const { strokes, strokeWidth } = React.useContext(SignaturePadContext)
  return (
    <Svg width="100%" height="100%">
      <Path
        d={strokes.map(strokePath).join("")}
        fill="none"
        stroke={color}
        strokeWidth={strokeWidth}
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </Svg>
  )
}

function SignaturePad({
  value: valueProp,
  defaultValue,
  onChange,
  strokeWidth = 2.5,
  disabled = false,
  placeholder = "Sign here",
  clearLabel = "Clear",
  accessibilityLabel = "Signature",
  style,
  ref,
  accessibilityActions,
  onAccessibilityAction,
  onLayout,
  ...props
}: Omit<React.ComponentProps<typeof View>, "children" | "style"> & {
  style?: StyleProp<ViewStyle>
  value?: SignaturePadStroke[]
  defaultValue?: SignaturePadStroke[]
  onChange?: (value: SignaturePadStroke[]) => void
  strokeWidth?: number
  disabled?: boolean
  placeholder?: React.ReactNode
  clearLabel?: React.ReactNode
  ref?: React.Ref<SignaturePadHandle>
}) {
  const [uncontrolled, setUncontrolled] = React.useState<SignaturePadStroke[]>(
    defaultValue ?? []
  )
  const isControlled = valueProp !== undefined
  const value = isControlled ? valueProp : uncontrolled
  const valueRef = React.useRef(value)
  valueRef.current = value
  const [active, setActive] = React.useState<SignaturePadStroke | null>(null)
  const activeRef = React.useRef<SignaturePadStroke | null>(null)
  const sizeRef = React.useRef({ width: 0, height: 0 })

  const commit = React.useCallback(
    (next: SignaturePadStroke[]) => {
      valueRef.current = next
      if (!isControlled) setUncontrolled(next)
      onChange?.(next)
    },
    [isControlled, onChange]
  )

  const clear = React.useCallback(() => {
    activeRef.current = null
    setActive(null)
    if (valueRef.current.length === 0) return
    commit([])
  }, [commit])

  React.useImperativeHandle(
    ref,
    () => ({
      toSVG: (options) =>
        signatureToSVG(
          valueRef.current,
          sizeRef.current.width,
          sizeRef.current.height,
          strokeWidth,
          options?.color ?? "currentColor"
        ),
      clear,
      isEmpty: () => valueRef.current.length === 0,
    }),
    [clear, strokeWidth]
  )

  const gesture = React.useMemo(
    () =>
      Gesture.Pan()
        .runOnJS(true)
        .enabled(!disabled)
        .minDistance(0)
        .maxPointers(1)
        .onBegin((event) => {
          const stroke = [{ x: event.x, y: event.y }]
          activeRef.current = stroke
          setActive(stroke)
        })
        .onUpdate((event) => {
          const current = activeRef.current
          if (!current) return
          const last = current.at(-1)
          if (
            last &&
            Math.abs(last.x - event.x) < 1 &&
            Math.abs(last.y - event.y) < 1
          ) {
            return
          }
          const stroke = [...current, { x: event.x, y: event.y }]
          activeRef.current = stroke
          setActive(stroke)
        })
        .onFinalize(() => {
          const stroke = activeRef.current
          activeRef.current = null
          setActive(null)
          if (stroke && stroke.length > 0) commit([...valueRef.current, stroke])
        }),
    [disabled, commit]
  )

  const { colors, isDark } = useTheme()
  const strokes = active ? [...value, active] : value
  const empty = strokes.length === 0
  const context = React.useMemo(
    () => ({ strokes, strokeWidth }),
    [strokes, strokeWidth]
  )

  const placeholderNode =
    typeof placeholder === "string" || typeof placeholder === "number" ? (
      <Text style={[styles.placeholder, { color: colors.mutedForeground }]}>
        {placeholder}
      </Text>
    ) : (
      placeholder
    )
  return (
    <View
      {...props}
      accessible
      accessibilityLabel={accessibilityLabel}
      accessibilityValue={{ text: empty ? "Empty" : "Signed" }}
      accessibilityState={{ disabled }}
      accessibilityActions={
        empty || disabled
          ? accessibilityActions
          : [{ name: "clear", label: "Clear" }, ...(accessibilityActions ?? [])]
      }
      onAccessibilityAction={(event) => {
        if (event.nativeEvent.actionName === "clear" && !empty && !disabled) {
          clear()
          return
        }
        onAccessibilityAction?.(event)
      }}
      onLayout={(event) => {
        sizeRef.current = {
          width: event.nativeEvent.layout.width,
          height: event.nativeEvent.layout.height,
        }
        onLayout?.(event)
      }}
      style={[
        styles.root,
        {
          borderColor: colors.border,
          backgroundColor: isDark ? colors.muted : colors.card,
        },
        disabled && { opacity: 0.5 },
        style,
      ]}
    >
      <View
        pointerEvents="none"
        style={[styles.baseline, { borderBottomColor: colors.input }]}
      >
        <Text style={[styles.mark, { color: colors.mutedForeground }]}>×</Text>
        {empty ? placeholderNode : null}
      </View>
      <GestureDetector gesture={gesture}>
        <View style={StyleSheet.absoluteFill}>
          <SignaturePadContext.Provider value={context}>
            <View pointerEvents="none" style={StyleSheet.absoluteFill}>
              <SignaturePadStrokes color={colors.foreground} />
            </View>
          </SignaturePadContext.Provider>
        </View>
      </GestureDetector>
      {!empty && !disabled ? (
        <View style={styles.clear}>
          <Button variant="ghost" size="sm" onPress={clear}>
            {typeof clearLabel === "string" ||
            typeof clearLabel === "number" ? (
              <Text>{clearLabel}</Text>
            ) : (
              clearLabel
            )}
          </Button>
        </View>
      ) : null}
    </View>
  )
}

export type { SignaturePadHandle, SignaturePadPoint, SignaturePadStroke }
export { SignaturePad }
```

4. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Components/SignaturePad.swift"
import PhosphorSwift
import SwiftUI
import UIKit

public struct SignatureStroke: Hashable, Sendable {
  public var points: [CGPoint]

  public init(_ points: [CGPoint]) {
    self.points = points
  }
}

enum SignatureMath {
  static func path(for stroke: [CGPoint], strokeWidth: CGFloat) -> Path {
    var path = Path()
    guard let first = stroke.first else { return path }
    if stroke.count == 1 {
      let radius = strokeWidth / 2
      path.addEllipse(in: CGRect(x: first.x - radius, y: first.y - radius, width: radius * 2, height: radius * 2))
      return path
    }
    path.move(to: first)
    if stroke.count == 2 {
      path.addLine(to: stroke[1])
      return path
    }
    for index in 1..<(stroke.count - 1) {
      let point = stroke[index]
      let next = stroke[index + 1]
      let middle = CGPoint(x: (point.x + next.x) / 2, y: (point.y + next.y) / 2)
      path.addQuadCurve(to: middle, control: point)
    }
    if let last = stroke.last {
      path.addLine(to: last)
    }
    return path
  }

  static func path(for strokes: [SignatureStroke], strokeWidth: CGFloat) -> Path {
    var path = Path()
    for stroke in strokes {
      path.addPath(self.path(for: stroke.points, strokeWidth: strokeWidth))
    }
    return path
  }

  static func appending(_ point: CGPoint, to stroke: [CGPoint], minDistance: CGFloat = 1.5) -> [CGPoint] {
    guard let last = stroke.last else { return [point] }
    let distance = hypot(point.x - last.x, point.y - last.y)
    return distance < minDistance ? stroke : stroke + [point]
  }

  static func clamp(_ point: CGPoint, to size: CGSize) -> CGPoint {
    CGPoint(x: min(max(point.x, 0), size.width), y: min(max(point.y, 0), size.height))
  }
}

public struct SignaturePad: View {
  @Binding var value: [SignatureStroke]
  let strokeWidth: CGFloat
  let placeholder: Text
  let clearable: Bool

  @State private var current: [CGPoint] = []
  @Environment(\.isEnabled) private var isEnabled
  @Environment(\.isInvalid) private var invalid

  public init(
    value: Binding<[SignatureStroke]>,
    strokeWidth: CGFloat = 2.5,
    placeholder: LocalizedStringKey = "Sign here",
    clearable: Bool = true
  ) {
    _value = value
    self.strokeWidth = strokeWidth
    self.placeholder = Text(placeholder)
    self.clearable = clearable
  }

  public static func path(_ strokes: [SignatureStroke], strokeWidth: CGFloat = 2.5) -> Path {
    SignatureMath.path(for: strokes, strokeWidth: strokeWidth)
  }

  @MainActor
  public static func image(
    _ strokes: [SignatureStroke],
    size: CGSize,
    strokeWidth: CGFloat = 2.5,
    color: Color = EsuissColor.foreground,
    colorScheme: ColorScheme = .light,
    scale: CGFloat = 3
  ) -> UIImage? {
    let shape = path(strokes, strokeWidth: strokeWidth)
    let dots = strokes.filter { $0.points.count == 1 }
    let renderer = ImageRenderer(
      content: ZStack {
        shape.stroke(color, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round, lineJoin: .round))
        SignatureMath.path(for: dots, strokeWidth: strokeWidth).fill(color)
      }
      .frame(width: size.width, height: size.height)
      .environment(\.colorScheme, colorScheme)
    )
    renderer.scale = scale
    renderer.isOpaque = false
    return renderer.uiImage
  }

  public var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
    let empty = value.isEmpty && current.isEmpty
    GeometryReader { proxy in
      let size = proxy.size
      ZStack(alignment: .bottomLeading) {
        VStack(alignment: .leading, spacing: 6) {
          HStack(alignment: .lastTextBaseline, spacing: 8) {
            Ph.x.bold
              .frame(width: 14, height: 14)
              .foregroundStyle(EsuissColor.mutedForeground)
            if empty {
              placeholder
                .scaledFont(.sm)
                .foregroundStyle(EsuissColor.mutedForeground)
                .transition(.opacity)
            }
          }
          Rectangle()
            .fill(EsuissColor.input)
            .frame(height: 1)
        }
        .padding(.horizontal, 20)
        .padding(.bottom, 28)
        .accessibilityHidden(true)
        strokes
          .frame(width: size.width, height: size.height)
          .allowsHitTesting(false)
      }
      .frame(width: size.width, height: size.height)
      .contentShape(shape)
      .gesture(drawGesture(size: size), including: isEnabled ? .all : .subviews)
    }
    .frame(minHeight: 160)
    .background(EsuissColor.muted, in: shape)
    .overlay {
      shape.strokeBorder(invalid ? EsuissColor.destructive : EsuissColor.border, lineWidth: 1)
    }
    .overlay(alignment: .topTrailing) {
      if clearable && !value.isEmpty && isEnabled {
        Button {
          withAnimation(.easeOut(duration: 0.2)) { value = [] }
        } label: {
          Text("Clear")
        }
        .buttonStyle(.variant(.ghost, size: .sm))
        .padding(8)
        .transition(.opacity)
      }
    }
    .opacity(isEnabled ? 1 : 0.5)
    .animation(.easeOut(duration: 0.15), value: empty)
    .accessibilityElement(children: .contain)
    .accessibilityLabel(Text("Signature"))
    .accessibilityValue(Text(value.isEmpty ? "Empty" : "Signed"))
  }

  private var strokes: some View {
    let width = strokeWidth
    let committed = value
    let active = current
    return Canvas { context, _ in
      let style = StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round)
      for stroke in committed + [SignatureStroke(active)] where !stroke.points.isEmpty {
        let path = SignatureMath.path(for: stroke.points, strokeWidth: width)
        if stroke.points.count == 1 {
          context.fill(path, with: .color(EsuissColor.foreground))
        } else {
          context.stroke(path, with: .color(EsuissColor.foreground), style: style)
        }
      }
    }
  }

  private func drawGesture(size: CGSize) -> some Gesture {
    DragGesture(minimumDistance: 0, coordinateSpace: .local)
      .onChanged { gesture in
        current = SignatureMath.appending(SignatureMath.clamp(gesture.location, to: size), to: current)
      }
      .onEnded { _ in
        guard !current.isEmpty else { return }
        value.append(SignatureStroke(current))
        current = []
      }
  }
}
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/components/SignaturePad.kt"
package com.esuiss.ui.components

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.Canvas as GraphicsCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import java.util.Locale

@Immutable
class SignatureStroke(val points: List<Offset>)

fun List<SignatureStroke>.toPath(scale: Float = 1f): Path {
    val path = Path()
    forEach { stroke ->
        val points = stroke.points
        if (points.isEmpty()) return@forEach
        val first = points.first() * scale
        path.moveTo(first.x, first.y)
        if (points.size == 1) {
            path.lineTo(first.x + 0.01f, first.y)
            return@forEach
        }
        for (i in 1 until points.size) {
            val previous = points[i - 1] * scale
            val current = points[i] * scale
            val middle = (previous + current) / 2f
            if (i == 1) path.lineTo(middle.x, middle.y) else path.quadraticTo(previous.x, previous.y, middle.x, middle.y)
        }
        val last = points.last() * scale
        path.lineTo(last.x, last.y)
    }
    return path
}

fun List<SignatureStroke>.toSvg(width: Float, height: Float, strokeWidth: Float = 2.5f, color: String = "#000000"): String {
    fun format(value: Float) = String.format(Locale.US, "%.1f", value)
    val paths = joinToString("") { stroke ->
        val points = stroke.points
        if (points.isEmpty()) return@joinToString ""
        val data = StringBuilder("M${format(points[0].x)} ${format(points[0].y)}")
        if (points.size == 1) data.append(" l0.01 0")
        for (i in 1 until points.size) {
            val previous = points[i - 1]
            val middle = (previous + points[i]) / 2f
            if (i == 1) data.append(" L${format(middle.x)} ${format(middle.y)}")
            else data.append(" Q${format(previous.x)} ${format(previous.y)} ${format(middle.x)} ${format(middle.y)}")
        }
        if (points.size > 1) data.append(" L${format(points.last().x)} ${format(points.last().y)}")
        "<path d=\"$data\"/>"
    }
    return "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${format(width)} ${format(height)}\" width=\"${format(width)}\" height=\"${format(height)}\">" +
        "<g fill=\"none\" stroke=\"$color\" stroke-width=\"${format(strokeWidth)}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">$paths</g></svg>"
}

fun List<SignatureStroke>.toImageBitmap(
    width: Dp,
    height: Dp,
    density: Density,
    color: Color = Color.Black,
    strokeWidth: Dp = 2.5.dp,
    background: Color = Color.Transparent,
): ImageBitmap {
    val bitmap = with(density) { ImageBitmap(width.roundToPx().coerceAtLeast(1), height.roundToPx().coerceAtLeast(1)) }
    val scope = CanvasDrawScope()
    val size = Size(bitmap.width.toFloat(), bitmap.height.toFloat())
    scope.draw(density, LayoutDirection.Ltr, GraphicsCanvas(bitmap), size) {
        if (background != Color.Transparent) drawRect(background)
        drawSignature(this@toImageBitmap, density.density, color, strokeWidth.toPx())
    }
    return bitmap
}

private fun DrawScope.drawSignature(strokes: List<SignatureStroke>, scale: Float, color: Color, width: Float) {
    drawPath(strokes.toPath(scale), color, style = Stroke(width, cap = StrokeCap.Round, join = StrokeJoin.Round))
}

@Composable
fun SignaturePad(
    modifier: Modifier = Modifier,
    value: List<SignatureStroke>? = null,
    onValueChange: ((List<SignatureStroke>) -> Unit)? = null,
    defaultValue: List<SignatureStroke> = emptyList(),
    strokeWidth: Dp = 2.5.dp,
    enabled: Boolean = true,
    placeholder: String = "Sign here",
    clearLabel: String = "Clear",
    showClearButton: Boolean = true,
) {
    val colors = EsuissTheme.colors
    var uncontrolled by remember { mutableStateOf(defaultValue) }
    val strokes = value ?: uncontrolled
    val latest by rememberUpdatedState(strokes)
    val change by rememberUpdatedState<(List<SignatureStroke>) -> Unit>({ next ->
        if (value == null) uncontrolled = next
        onValueChange?.invoke(next)
    })
    val live = remember { mutableStateListOf<Offset>() }
    val empty = strokes.isEmpty() && live.isEmpty()
    val shape = RoundedCornerShape(Radius.xl)
    val ink = colors.foreground
    val guide = colors.input
    Box(
        modifier = modifier
            .fillMaxWidth()
            .height(200.dp)
            .alpha(if (enabled) 1f else 0.5f)
            .clip(shape)
            .background(colors.background, shape)
            .border(1.dp, colors.border, shape)
            .semantics {
                contentDescription = "Signature pad"
                stateDescription = if (strokes.isEmpty()) "Empty" else "Signed"
                if (enabled && strokes.isNotEmpty()) {
                    customActions = listOf(
                        CustomAccessibilityAction(clearLabel) {
                            change(emptyList())
                            true
                        },
                    )
                }
            },
    ) {
        Canvas(
            modifier = Modifier
                .matchParentSize()
                .then(
                    if (!enabled) {
                        Modifier
                    } else {
                        Modifier.pointerInput(Unit) {
                            awaitEachGesture {
                                val down = awaitFirstDown()
                                down.consume()
                                val scale = density
                                live.clear()
                                live.add(down.position / scale)
                                while (true) {
                                    val event = awaitPointerEvent()
                                    val pointer = event.changes.firstOrNull { it.id == down.id } ?: break
                                    if (!pointer.pressed) break
                                    if (pointer.positionChange() != Offset.Zero) {
                                        live.add(pointer.position / scale)
                                        pointer.consume()
                                    }
                                }
                                val stroke = SignatureStroke(live.toList())
                                live.clear()
                                change(latest + stroke)
                            }
                        }
                    },
                ),
        ) {
            val inset = 24.dp.toPx()
            val baseline = size.height * 0.72f
            drawLine(
                guide,
                Offset(inset, baseline),
                Offset(size.width - inset, baseline),
                strokeWidth = 1.dp.toPx(),
                pathEffect = PathEffect.dashPathEffect(floatArrayOf(4.dp.toPx(), 4.dp.toPx())),
            )
            val width = strokeWidth.toPx()
            drawSignature(strokes, density, ink, width)
            if (live.isNotEmpty()) drawSignature(listOf(SignatureStroke(live.toList())), density, ink, width)
        }
        if (empty) {
            BasicText(
                placeholder,
                modifier = Modifier.layout { measurable, constraints ->
                    val placeable = measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
                    layout(constraints.maxWidth, constraints.maxHeight) {
                        placeable.place(24.dp.roundToPx(), (constraints.maxHeight * 0.72f).toInt() + 8.dp.roundToPx())
                    }
                },
                style = Typography.sm.copy(color = colors.mutedForeground),
            )
        }
        if (showClearButton && enabled && strokes.isNotEmpty()) {
            Button(
                text = clearLabel,
                onClick = { change(emptyList()) },
                modifier = Modifier
                    .align(Alignment.TopEnd)
                    .padding(8.dp),
                variant = ButtonVariant.Ghost,
                size = ButtonSize.Sm,
            )
        }
    }
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { SignaturePad } from "@/components/ui/signature-pad"
```

```tsx
<SignaturePad />
```

Use it uncontrolled with `defaultValue`, or controlled with `value` and `onChange`. The value is an array of strokes, where each stroke is an array of `{ x, y }` points.

Pass a `ref` to get a `SignaturePadHandle` with `toSVG()`, `clear()` and `isEmpty()`. `toSVG` returns an SVG string sized to the pad and accepts an optional `color` (defaults to `currentColor`).

The `SignaturePadHandle`, `SignaturePadPoint` and `SignaturePadStroke` types are also exported.

## Examples

### Signed

A pad that starts with a saved signature.

![Preview](https://ui.suiss.app/previews/components-signature-pad--signed.light.webp)

```tsx
import { SignaturePad, type SignaturePadStroke } from "@/components/ui/signature-pad"

function wave(
  startX: number,
  baseY: number,
  length: number,
  amplitude: number,
  turns: number
): SignaturePadStroke {
  return Array.from({ length: 48 }, (_, index) => {
    const progress = index / 47
    return {
      x: startX + progress * length,
      y:
        baseY -
        Math.sin(progress * Math.PI * turns) * amplitude * (1 - progress * 0.4),
    }
  })
}

const SIGNATURE: SignaturePadStroke[] = [
  wave(40, 120, 70, 40, 3),
  wave(110, 115, 110, 22, 5),
  [
    { x: 60, y: 140 },
    { x: 140, y: 136 },
    { x: 230, y: 134 },
  ],
]

export function SignaturePadSignedExample() {
  return (
    <SignaturePad defaultValue={SIGNATURE} />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SignaturePadSignedExample: View {
  var body: some View {
    SignaturePadDemo(initial: signaturePadSample)
  }
}

func signaturePadWave(startX: CGFloat, baseY: CGFloat, length: CGFloat, amplitude: CGFloat, turns: CGFloat) -> SignatureStroke {
  SignatureStroke((0..<48).map { index in
    let progress = CGFloat(index) / 47
    return CGPoint(
      x: startX + progress * length,
      y: baseY - sin(progress * .pi * turns) * amplitude * (1 - progress * 0.4)
    )
  })
}

let signaturePadSample: [SignatureStroke] = [
  signaturePadWave(startX: 40, baseY: 120, length: 70, amplitude: 40, turns: 3),
  signaturePadWave(startX: 110, baseY: 115, length: 110, amplitude: 22, turns: 5),
  SignatureStroke([CGPoint(x: 60, y: 140), CGPoint(x: 140, y: 136), CGPoint(x: 230, y: 134)]),
]

struct SignaturePadDemo: View {
  var strokeWidth: CGFloat = 2.5

  @State private var strokes: [SignatureStroke]

  init(initial: [SignatureStroke], strokeWidth: CGFloat = 2.5) {
    self.strokeWidth = strokeWidth
    _strokes = State(initialValue: initial)
  }

  var body: some View {
    SignaturePad(value: $strokes, strokeWidth: strokeWidth)
      .frame(height: 192)
      .frame(maxWidth: .infinity)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import com.esuiss.ui.components.SignaturePad
import com.esuiss.ui.components.SignatureStroke
import kotlin.math.PI
import kotlin.math.sin

@Composable
fun SignaturePadSignedExample() {
    SignaturePad(Modifier.fillMaxWidth(), defaultValue = signaturePadSample)
}

fun signaturePadWave(startX: Float, baseY: Float, length: Float, amplitude: Float, turns: Float): SignatureStroke =
    SignatureStroke(
        List(48) { index ->
            val progress = index / 47f
            Offset(
                startX + progress * length,
                baseY - sin(progress * PI.toFloat() * turns) * amplitude * (1 - progress * 0.4f),
            )
        },
    )

val signaturePadSample = listOf(
    signaturePadWave(40f, 120f, 70f, 40f, 3f),
    signaturePadWave(110f, 115f, 110f, 22f, 5f),
    SignatureStroke(listOf(Offset(60f, 140f), Offset(140f, 136f), Offset(230f, 134f))),
)
```

### Thick stroke

A signature drawn with `strokeWidth={5}`.

![Preview](https://ui.suiss.app/previews/components-signature-pad--thick-stroke.light.webp)

```tsx
import { SignaturePad, type SignaturePadStroke } from "@/components/ui/signature-pad"

function wave(
  startX: number,
  baseY: number,
  length: number,
  amplitude: number,
  turns: number
): SignaturePadStroke {
  return Array.from({ length: 48 }, (_, index) => {
    const progress = index / 47
    return {
      x: startX + progress * length,
      y:
        baseY -
        Math.sin(progress * Math.PI * turns) * amplitude * (1 - progress * 0.4),
    }
  })
}

const SIGNATURE: SignaturePadStroke[] = [
  wave(40, 120, 70, 40, 3),
  wave(110, 115, 110, 22, 5),
  [
    { x: 60, y: 140 },
    { x: 140, y: 136 },
    { x: 230, y: 134 },
  ],
]

export function SignaturePadThickStrokeExample() {
  return (
    <SignaturePad defaultValue={SIGNATURE} strokeWidth={5} />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SignaturePadThickStrokeExample: View {
  var body: some View {
    SignaturePadDemo(initial: signaturePadSample, strokeWidth: 5)
  }
}

func signaturePadWave(startX: CGFloat, baseY: CGFloat, length: CGFloat, amplitude: CGFloat, turns: CGFloat) -> SignatureStroke {
  SignatureStroke((0..<48).map { index in
    let progress = CGFloat(index) / 47
    return CGPoint(
      x: startX + progress * length,
      y: baseY - sin(progress * .pi * turns) * amplitude * (1 - progress * 0.4)
    )
  })
}

let signaturePadSample: [SignatureStroke] = [
  signaturePadWave(startX: 40, baseY: 120, length: 70, amplitude: 40, turns: 3),
  signaturePadWave(startX: 110, baseY: 115, length: 110, amplitude: 22, turns: 5),
  SignatureStroke([CGPoint(x: 60, y: 140), CGPoint(x: 140, y: 136), CGPoint(x: 230, y: 134)]),
]

struct SignaturePadDemo: View {
  var strokeWidth: CGFloat = 2.5

  @State private var strokes: [SignatureStroke]

  init(initial: [SignatureStroke], strokeWidth: CGFloat = 2.5) {
    self.strokeWidth = strokeWidth
    _strokes = State(initialValue: initial)
  }

  var body: some View {
    SignaturePad(value: $strokes, strokeWidth: strokeWidth)
      .frame(height: 192)
      .frame(maxWidth: .infinity)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.SignaturePad
import com.esuiss.ui.components.SignatureStroke
import kotlin.math.PI
import kotlin.math.sin

@Composable
fun SignaturePadThickStrokeExample() {
    SignaturePad(Modifier.fillMaxWidth(), defaultValue = signaturePadSample, strokeWidth = 5.dp)
}

fun signaturePadWave(startX: Float, baseY: Float, length: Float, amplitude: Float, turns: Float): SignatureStroke =
    SignatureStroke(
        List(48) { index ->
            val progress = index / 47f
            Offset(
                startX + progress * length,
                baseY - sin(progress * PI.toFloat() * turns) * amplitude * (1 - progress * 0.4f),
            )
        },
    )

val signaturePadSample = listOf(
    signaturePadWave(40f, 120f, 70f, 40f, 3f),
    signaturePadWave(110f, 115f, 110f, 22f, 5f),
    SignatureStroke(listOf(Offset(60f, 140f), Offset(140f, 136f), Offset(230f, 134f))),
)
```

### Disabled

A disabled pad that ignores touches and hides the Clear button.

![Preview](https://ui.suiss.app/previews/components-signature-pad--disabled.light.webp)

```tsx
import { SignaturePad, type SignaturePadStroke } from "@/components/ui/signature-pad"

function wave(
  startX: number,
  baseY: number,
  length: number,
  amplitude: number,
  turns: number
): SignaturePadStroke {
  return Array.from({ length: 48 }, (_, index) => {
    const progress = index / 47
    return {
      x: startX + progress * length,
      y:
        baseY -
        Math.sin(progress * Math.PI * turns) * amplitude * (1 - progress * 0.4),
    }
  })
}

const SIGNATURE: SignaturePadStroke[] = [
  wave(40, 120, 70, 40, 3),
  wave(110, 115, 110, 22, 5),
  [
    { x: 60, y: 140 },
    { x: 140, y: 136 },
    { x: 230, y: 134 },
  ],
]

export function SignaturePadDisabledExample() {
  return (
    <SignaturePad defaultValue={SIGNATURE} disabled />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SignaturePadDisabledExample: View {
  var body: some View {
    SignaturePadDemo(initial: signaturePadSample)
      .disabled(true)
  }
}

func signaturePadWave(startX: CGFloat, baseY: CGFloat, length: CGFloat, amplitude: CGFloat, turns: CGFloat) -> SignatureStroke {
  SignatureStroke((0..<48).map { index in
    let progress = CGFloat(index) / 47
    return CGPoint(
      x: startX + progress * length,
      y: baseY - sin(progress * .pi * turns) * amplitude * (1 - progress * 0.4)
    )
  })
}

let signaturePadSample: [SignatureStroke] = [
  signaturePadWave(startX: 40, baseY: 120, length: 70, amplitude: 40, turns: 3),
  signaturePadWave(startX: 110, baseY: 115, length: 110, amplitude: 22, turns: 5),
  SignatureStroke([CGPoint(x: 60, y: 140), CGPoint(x: 140, y: 136), CGPoint(x: 230, y: 134)]),
]

struct SignaturePadDemo: View {
  var strokeWidth: CGFloat = 2.5

  @State private var strokes: [SignatureStroke]

  init(initial: [SignatureStroke], strokeWidth: CGFloat = 2.5) {
    self.strokeWidth = strokeWidth
    _strokes = State(initialValue: initial)
  }

  var body: some View {
    SignaturePad(value: $strokes, strokeWidth: strokeWidth)
      .frame(height: 192)
      .frame(maxWidth: .infinity)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import com.esuiss.ui.components.SignaturePad
import com.esuiss.ui.components.SignatureStroke
import kotlin.math.PI
import kotlin.math.sin

@Composable
fun SignaturePadDisabledExample() {
    SignaturePad(Modifier.fillMaxWidth(), defaultValue = signaturePadSample, enabled = false)
}

fun signaturePadWave(startX: Float, baseY: Float, length: Float, amplitude: Float, turns: Float): SignatureStroke =
    SignatureStroke(
        List(48) { index ->
            val progress = index / 47f
            Offset(
                startX + progress * length,
                baseY - sin(progress * PI.toFloat() * turns) * amplitude * (1 - progress * 0.4f),
            )
        },
    )

val signaturePadSample = listOf(
    signaturePadWave(40f, 120f, 70f, 40f, 3f),
    signaturePadWave(110f, 115f, 110f, 22f, 5f),
    SignatureStroke(listOf(Offset(60f, 140f), Offset(140f, 136f), Offset(230f, 134f))),
)
```

### Controlled

A controlled pad that reads the signature through the ref and exports it with `toSVG`.

![Preview](https://ui.suiss.app/previews/components-signature-pad--controlled.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { SignaturePad, type SignaturePadHandle, type SignaturePadStroke } from "@/components/ui/signature-pad"
import { Text } from "@/components/ui/text"

function wave(
  startX: number,
  baseY: number,
  length: number,
  amplitude: number,
  turns: number
): SignaturePadStroke {
  return Array.from({ length: 48 }, (_, index) => {
    const progress = index / 47
    return {
      x: startX + progress * length,
      y:
        baseY -
        Math.sin(progress * Math.PI * turns) * amplitude * (1 - progress * 0.4),
    }
  })
}

const SIGNATURE: SignaturePadStroke[] = [
  wave(40, 120, 70, 40, 3),
  wave(110, 115, 110, 22, 5),
  [
    { x: 60, y: 140 },
    { x: 140, y: 136 },
    { x: 230, y: 134 },
  ],
]

function ExportDemo() {
  const ref = React.useRef<SignaturePadHandle>(null)
  const [strokes, setStrokes] = React.useState<SignaturePadStroke[]>(SIGNATURE)
  const [size, setSize] = React.useState("")
  return (
    <View className="w-full gap-4">
      <SignaturePad ref={ref} value={strokes} onChange={setStrokes} />
      <Button
        variant="outline"
        onPress={() =>
          setSize(`SVG: ${ref.current?.toSVG().length ?? 0} characters`)
        }
      >
        <Text>Export SVG</Text>
      </Button>
      <Text role="status" className="text-sm text-muted-foreground">
        {`${strokes.length} strokes${size ? ` · ${size}` : ""}`}
      </Text>
    </View>
  )
}

export function SignaturePadControlledExample() {
  return (
    <ExportDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

func signaturePadWave(startX: CGFloat, baseY: CGFloat, length: CGFloat, amplitude: CGFloat, turns: CGFloat) -> SignatureStroke {
  SignatureStroke((0..<48).map { index in
    let progress = CGFloat(index) / 47
    return CGPoint(
      x: startX + progress * length,
      y: baseY - sin(progress * .pi * turns) * amplitude * (1 - progress * 0.4)
    )
  })
}

let signaturePadSample: [SignatureStroke] = [
  signaturePadWave(startX: 40, baseY: 120, length: 70, amplitude: 40, turns: 3),
  signaturePadWave(startX: 110, baseY: 115, length: 110, amplitude: 22, turns: 5),
  SignatureStroke([CGPoint(x: 60, y: 140), CGPoint(x: 140, y: 136), CGPoint(x: 230, y: 134)]),
]

struct SignaturePadExportDemo: View {
  @State private var strokes = signaturePadSample
  @State private var exported = ""
  @State private var width: CGFloat = 0
  @Environment(\.colorScheme) private var colorScheme

  var body: some View {
    VStack(alignment: .leading, spacing: 16) {
      SignaturePad(value: $strokes)
        .frame(height: 192)
        .frame(maxWidth: .infinity)
        .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 }
        .onChange(of: strokes) { _, _ in exported = "" }
      Button {
        let image = SignaturePad.image(strokes, size: CGSize(width: width, height: 192), colorScheme: colorScheme)
        if let image {
          exported = "Image: \(Int(image.size.width * image.scale))×\(Int(image.size.height * image.scale)) px"
        }
      } label: {
        Text("Export image").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Text(verbatim: exported.isEmpty ? "\(strokes.count) strokes" : "\(strokes.count) strokes · \(exported)")
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.SignaturePad
import com.esuiss.ui.components.SignatureStroke
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.toSvg
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
import kotlin.math.PI
import kotlin.math.sin

fun signaturePadWave(startX: Float, baseY: Float, length: Float, amplitude: Float, turns: Float): SignatureStroke =
    SignatureStroke(
        List(48) { index ->
            val progress = index / 47f
            Offset(
                startX + progress * length,
                baseY - sin(progress * PI.toFloat() * turns) * amplitude * (1 - progress * 0.4f),
            )
        },
    )

val signaturePadSample = listOf(
    signaturePadWave(40f, 120f, 70f, 40f, 3f),
    signaturePadWave(110f, 115f, 110f, 22f, 5f),
    SignatureStroke(listOf(Offset(60f, 140f), Offset(140f, 136f), Offset(230f, 134f))),
)

@Composable
fun SignaturePadControlled() {
    var strokes by remember { mutableStateOf(signaturePadSample) }
    var exported by rememberSaveable { mutableStateOf("") }
    var size by remember { mutableStateOf(IntSize.Zero) }
    val density = LocalDensity.current
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
        SignaturePad(
            Modifier.fillMaxWidth().onSizeChanged { size = it },
            value = strokes,
            onValueChange = { strokes = it },
        )
        Button(
            "Export SVG",
            onClick = {
                val svg = with(density) { strokes.toSvg(size.width.toDp().value, size.height.toDp().value) }
                exported = "SVG: ${svg.length} characters"
            },
            modifier = Modifier.fillMaxWidth(),
            variant = ButtonVariant.Outline,
        )
        Text(
            "${strokes.size} strokes" + if (exported.isNotEmpty()) " · $exported" else "",
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm,
            color = EsuissTheme.colors.mutedForeground,
        )
    }
}
```

## API Reference

### SignaturePad

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `SignaturePadStroke[]` | - | Strokes for controlled use. |
| `defaultValue` | `SignaturePadStroke[]` | - | Initial strokes for uncontrolled use. |
| `onChange` | `(value: SignaturePadStroke[]) => void` | - | Called with the full list of strokes after each stroke ends or the pad is cleared. |
| `strokeWidth` | `number` | `2.5` | Line width of the strokes. Defaults to `2.5`. |
| `disabled` | `boolean` | `false` | Turns off drawing, hides the Clear button and dims the pad. |
| `placeholder` | `React.ReactNode` | `"Sign here"` | Content shown on the signature line while the pad is empty. Defaults to `"Sign here"`. |
| `clearLabel` | `React.ReactNode` | `"Clear"` | Label of the Clear button. Defaults to `"Clear"`. |
| `ref` | `React.Ref<SignaturePadHandle>` | - | Receives a `SignaturePadHandle` with `toSVG`, `clear` and `isEmpty`. |

Also accepts the props of `React.ComponentProps<typeof View>`.

## Accessibility

- The pad is a single accessible element labeled by `accessibilityLabel`.
- Its value is announced as "Empty" or "Signed", and `disabled` is reported in its state.
- When it has a signature and is not disabled, it offers a `clear` accessibility action, so VoiceOver and TalkBack users can clear it without finding the button.

## Notes

- Uses `bg-card` in light mode and `bg-muted` in dark mode, with `border-border` and `text-foreground` for the strokes.
- Points closer than one point to the previous point are skipped while drawing.

