diff --git a/.changeset/healthy-readers-clean.md b/.changeset/healthy-readers-clean.md
new file mode 100644
index 0000000000..67c7a31f16
--- /dev/null
+++ b/.changeset/healthy-readers-clean.md
@@ -0,0 +1,5 @@
+---
+"@zag-js/signature-pad": minor
+---
+
+[NEW] Signature Pad machine to allow capturing user signatures
diff --git a/.xstate/signature-pad.js b/.xstate/signature-pad.js
new file mode 100644
index 0000000000..c0c073b1c1
--- /dev/null
+++ b/.xstate/signature-pad.js
@@ -0,0 +1,57 @@
+"use strict";
+
+var _xstate = require("xstate");
+const {
+ actions,
+ createMachine,
+ assign
+} = _xstate;
+const {
+ choose
+} = actions;
+const fetchMachine = createMachine({
+ id: "signature-pad",
+ initial: "idle",
+ context: {},
+ on: {
+ CLEAR: {
+ actions: ["clearPoints", "invokeOnDrawEnd", "focusCanvasEl"]
+ }
+ },
+ on: {
+ UPDATE_CONTEXT: {
+ actions: "updateContext"
+ }
+ },
+ states: {
+ idle: {
+ on: {
+ POINTER_DOWN: {
+ target: "drawing",
+ actions: ["addPoint"]
+ }
+ }
+ },
+ drawing: {
+ activities: ["trackPointerMove"],
+ on: {
+ POINTER_MOVE: {
+ actions: ["addPoint", "invokeOnDraw"]
+ },
+ POINTER_UP: {
+ target: "idle",
+ actions: ["endStroke", "invokeOnDrawEnd"]
+ }
+ }
+ }
+ }
+}, {
+ actions: {
+ updateContext: assign((context, event) => {
+ return {
+ [event.contextKey]: true
+ };
+ })
+ },
+ guards: {}
+});
\ No newline at end of file
diff --git a/examples/next-ts/package.json b/examples/next-ts/package.json
index 9c8cf031f2..d9510a82bb 100644
--- a/examples/next-ts/package.json
+++ b/examples/next-ts/package.json
@@ -64,6 +64,7 @@
"@zag-js/remove-scroll": "workspace:*",
"@zag-js/select": "workspace:*",
"@zag-js/shared": "workspace:*",
+ "@zag-js/signature-pad": "workspace:*",
"@zag-js/slider": "workspace:*",
"@zag-js/splitter": "workspace:*",
"@zag-js/store": "workspace:*",
diff --git a/examples/next-ts/pages/signature-pad.tsx b/examples/next-ts/pages/signature-pad.tsx
new file mode 100644
index 0000000000..227e504601
--- /dev/null
+++ b/examples/next-ts/pages/signature-pad.tsx
@@ -0,0 +1,69 @@
+import { normalizeProps, useMachine } from "@zag-js/react"
+import { signaturePadControls } from "@zag-js/shared"
+import * as signaturePad from "@zag-js/signature-pad"
+import { RotateCcw } from "lucide-react"
+import { useId, useState } from "react"
+import { StateVisualizer } from "../components/state-visualizer"
+import { Toolbar } from "../components/toolbar"
+import { useControls } from "../hooks/use-controls"
+
+export default function Page() {
+ const [url, setUrl] = useState("")
+
+ const controls = useControls(signaturePadControls)
+
+ const [state, send] = useMachine(
+ signaturePad.machine({
+ id: useId(),
+ onDrawEnd(details) {
+ details.getDataUrl("image/png").then(setUrl)
+ },
+ drawing: {
+ fill: "red",
+ size: 4,
+ simulatePressure: true,
+ },
+ }),
+ { context: controls.context },
+ )
+
+ const api = signaturePad.connect(state, send, normalizeProps)
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {url && }
+
+
+
+
+
+ >
+ )
+}
diff --git a/examples/nuxt-ts/package.json b/examples/nuxt-ts/package.json
index db1157058d..64fbdcca8d 100644
--- a/examples/nuxt-ts/package.json
+++ b/examples/nuxt-ts/package.json
@@ -62,6 +62,7 @@
"@zag-js/remove-scroll": "workspace:*",
"@zag-js/select": "workspace:*",
"@zag-js/shared": "workspace:*",
+ "@zag-js/signature-pad": "workspace:*",
"@zag-js/slider": "workspace:*",
"@zag-js/splitter": "workspace:*",
"@zag-js/store": "workspace:*",
diff --git a/examples/nuxt-ts/pages/signature-pad.vue b/examples/nuxt-ts/pages/signature-pad.vue
new file mode 100644
index 0000000000..4b6531229a
--- /dev/null
+++ b/examples/nuxt-ts/pages/signature-pad.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/preact-ts/package.json b/examples/preact-ts/package.json
index 012378d08d..7109476e12 100644
--- a/examples/preact-ts/package.json
+++ b/examples/preact-ts/package.json
@@ -62,6 +62,7 @@
"@zag-js/remove-scroll": "workspace:*",
"@zag-js/select": "workspace:*",
"@zag-js/shared": "workspace:*",
+ "@zag-js/signature-pad": "workspace:*",
"@zag-js/slider": "workspace:*",
"@zag-js/splitter": "workspace:*",
"@zag-js/store": "workspace:*",
diff --git a/examples/solid-ts/package.json b/examples/solid-ts/package.json
index 9e4dcb7fdb..dba1b9c3b8 100644
--- a/examples/solid-ts/package.json
+++ b/examples/solid-ts/package.json
@@ -71,6 +71,7 @@
"@zag-js/remove-scroll": "workspace:*",
"@zag-js/select": "workspace:*",
"@zag-js/shared": "workspace:*",
+ "@zag-js/signature-pad": "workspace:*",
"@zag-js/slider": "workspace:*",
"@zag-js/solid": "workspace:*",
"@zag-js/splitter": "workspace:*",
diff --git a/examples/solid-ts/src/pages/signature-pad.tsx b/examples/solid-ts/src/pages/signature-pad.tsx
new file mode 100644
index 0000000000..652cb10b73
--- /dev/null
+++ b/examples/solid-ts/src/pages/signature-pad.tsx
@@ -0,0 +1,68 @@
+import { signaturePadControls } from "@zag-js/shared"
+import * as signaturePad from "@zag-js/signature-pad"
+import { normalizeProps, useMachine } from "@zag-js/solid"
+import { For, Show, createMemo, createSignal, createUniqueId } from "solid-js"
+import { StateVisualizer } from "../components/state-visualizer"
+import { Toolbar } from "../components/toolbar"
+import { useControls } from "../hooks/use-controls"
+import { RotateCcw } from "lucide-solid"
+
+export default function Page() {
+ const [url, setUrl] = createSignal("")
+
+ const controls = useControls(signaturePadControls)
+
+ const [state, send] = useMachine(
+ signaturePad.machine({
+ id: createUniqueId(),
+ onDrawEnd(details) {
+ details.getDataUrl("image/png").then(setUrl)
+ },
+ }),
+ {
+ context: controls.context,
+ },
+ )
+
+ const api = createMemo(() => signaturePad.connect(state, send, normalizeProps))
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/examples/solid-ts/src/routes.ts b/examples/solid-ts/src/routes.ts
index 9c8611c646..c7d434168b 100644
--- a/examples/solid-ts/src/routes.ts
+++ b/examples/solid-ts/src/routes.ts
@@ -4,6 +4,7 @@ import { lazy } from "solid-js"
import Home from "./pages/home"
export const routes: RouteDefinition[] = [
+ { path: "/signature-pad", component: lazy(() => import("./pages/signature-pad")) },
{ path: "/floating-panel", component: lazy(() => import("./pages/floating-panel")) },
{ path: "/tour", component: lazy(() => import("./pages/tour")) },
{ path: "/collapsible", component: lazy(() => import("./pages/collapsible")) },
diff --git a/examples/svelte-ts/package.json b/examples/svelte-ts/package.json
index 0665832867..348f1be2f6 100644
--- a/examples/svelte-ts/package.json
+++ b/examples/svelte-ts/package.json
@@ -63,6 +63,7 @@
"@zag-js/remove-scroll": "workspace:*",
"@zag-js/select": "workspace:*",
"@zag-js/shared": "workspace:*",
+ "@zag-js/signature-pad": "workspace:*",
"@zag-js/slider": "workspace:*",
"@zag-js/splitter": "workspace:*",
"@zag-js/store": "workspace:*",
@@ -95,4 +96,4 @@
"vite": "5.2.7",
"vite-tsconfig-paths": "4.3.2"
}
-}
+}
\ No newline at end of file
diff --git a/examples/svelte-ts/src/App.svelte b/examples/svelte-ts/src/App.svelte
index f0f01bca0d..cb0f33a1b8 100644
--- a/examples/svelte-ts/src/App.svelte
+++ b/examples/svelte-ts/src/App.svelte
@@ -17,6 +17,7 @@
import PinInput from "./routes/pin-input.svelte"
import Progress from "./routes/progress.svelte"
import Select from "./routes/select.svelte"
+ import SignaturePad from "./routes/signature-pad.svelte"
import Slider from "./routes/slider.svelte"
import Tabs from "./routes/tabs.svelte"
import TagsInput from "./routes/tags-input.svelte"
@@ -44,6 +45,7 @@
{ path: "/progress", component: Progress },
{ path: "/tabs", component: Tabs },
{ path: "/number-input", component: NumberInput },
+ { path: "/signature-pad", component: SignaturePad },
]
diff --git a/examples/svelte-ts/src/routes/signature-pad.svelte b/examples/svelte-ts/src/routes/signature-pad.svelte
new file mode 100644
index 0000000000..dc141ebfa8
--- /dev/null
+++ b/examples/svelte-ts/src/routes/signature-pad.svelte
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if url}
+
+ {/if}
+
+
+
diff --git a/examples/vue-ts/package.json b/examples/vue-ts/package.json
index c8a316d3b1..7ca6bfd58a 100644
--- a/examples/vue-ts/package.json
+++ b/examples/vue-ts/package.json
@@ -63,6 +63,7 @@
"@zag-js/remove-scroll": "workspace:*",
"@zag-js/select": "workspace:*",
"@zag-js/shared": "workspace:*",
+ "@zag-js/signature-pad": "workspace:*",
"@zag-js/slider": "workspace:*",
"@zag-js/splitter": "workspace:*",
"@zag-js/store": "workspace:*",
diff --git a/examples/vue-ts/src/pages/signature-pad.tsx b/examples/vue-ts/src/pages/signature-pad.tsx
new file mode 100644
index 0000000000..47cfc0af44
--- /dev/null
+++ b/examples/vue-ts/src/pages/signature-pad.tsx
@@ -0,0 +1,75 @@
+import * as signaturePad from "@zag-js/signature-pad"
+import { normalizeProps, useMachine } from "@zag-js/vue"
+import { computed, defineComponent, h, Fragment, ref } from "vue"
+import { signaturePadControls } from "@zag-js/shared"
+import { StateVisualizer } from "../components/state-visualizer"
+import { Toolbar } from "../components/toolbar"
+import { useControls } from "../hooks/use-controls"
+import { RotateCcw } from "lucide-vue-next"
+
+export default defineComponent({
+ name: "signature-pad",
+ setup() {
+ const controls = useControls(signaturePadControls)
+ const urlRef = ref("")
+ const setUrl = (v: string) => {
+ urlRef.value = v
+ }
+
+ const [state, send] = useMachine(
+ signaturePad.machine({
+ id: "1",
+ onDrawEnd(details) {
+ details.getDataUrl("image/png").then(setUrl)
+ },
+ }),
+ {
+ context: controls.context,
+ },
+ )
+
+ const apiRef = computed(() => signaturePad.connect(state.value, send, normalizeProps))
+
+ return () => {
+ const api = apiRef.value
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {urlRef.value && }
+
+
+
+
+
+ >
+ )
+ }
+ },
+})
diff --git a/examples/vue-ts/src/routes.ts b/examples/vue-ts/src/routes.ts
index 6f770b9625..d1e4ff0dc4 100644
--- a/examples/vue-ts/src/routes.ts
+++ b/examples/vue-ts/src/routes.ts
@@ -4,6 +4,7 @@ import Home from "./pages/index"
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
+ { path: "/signature-pad", component: () => import("./pages/signature-pad") },
{ path: "/floating-panel", component: () => import("./pages/floating-panel") },
{ path: "/tour", component: () => import("./pages/tour") },
{ path: "/collapsible", component: () => import("./pages/collapsible") },
diff --git a/packages/machines/signature-pad/README.md b/packages/machines/signature-pad/README.md
new file mode 100644
index 0000000000..c77aa7a7f8
--- /dev/null
+++ b/packages/machines/signature-pad/README.md
@@ -0,0 +1,19 @@
+# @zag-js/signature-pad
+
+Core logic for the signature-pad widget implemented as a state machine
+
+## Installation
+
+```sh
+yarn add @zag-js/signature-pad
+# or
+npm i @zag-js/signature-pad
+```
+
+## Contribution
+
+Yes please! See the [contributing guidelines](https://github.com/chakra-ui/zag/blob/main/CONTRIBUTING.md) for details.
+
+## Licence
+
+This project is licensed under the terms of the [MIT license](https://github.com/chakra-ui/zag/blob/main/LICENSE).
diff --git a/packages/machines/signature-pad/package.json b/packages/machines/signature-pad/package.json
new file mode 100644
index 0000000000..725c760c59
--- /dev/null
+++ b/packages/machines/signature-pad/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "@zag-js/signature-pad",
+ "version": "0.0.0",
+ "description": "Core logic for the signature-pad widget implemented as a state machine",
+ "keywords": [
+ "js",
+ "machine",
+ "xstate",
+ "statechart",
+ "component",
+ "chakra-ui",
+ "signature-pad"
+ ],
+ "author": "Segun Adebayo ",
+ "homepage": "https://github.com/chakra-ui/zag#readme",
+ "license": "MIT",
+ "main": "src/index.ts",
+ "repository": "https://github.com/chakra-ui/zag/tree/main/packages/signature-pad",
+ "sideEffects": false,
+ "files": [
+ "dist",
+ "src"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "lint": "eslint src --ext .ts,.tsx",
+ "typecheck": "tsc --noEmit",
+ "prepack": "clean-package",
+ "postpack": "clean-package restore"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "bugs": {
+ "url": "https://github.com/chakra-ui/zag/issues"
+ },
+ "dependencies": {
+ "@zag-js/anatomy": "workspace:*",
+ "@zag-js/core": "workspace:*",
+ "@zag-js/dom-query": "workspace:*",
+ "@zag-js/dom-event": "workspace:*",
+ "@zag-js/utils": "workspace:*",
+ "@zag-js/types": "workspace:*",
+ "perfect-freehand": "^1.2.2"
+ },
+ "devDependencies": {
+ "clean-package": "2.2.0"
+ },
+ "clean-package": "../../../clean-package.config.json"
+}
diff --git a/packages/machines/signature-pad/src/get-svg-path.ts b/packages/machines/signature-pad/src/get-svg-path.ts
new file mode 100644
index 0000000000..01fe848494
--- /dev/null
+++ b/packages/machines/signature-pad/src/get-svg-path.ts
@@ -0,0 +1,30 @@
+const average = (a: number, b: number) => (a + b) / 2
+
+export function getSvgPathFromStroke(points: number[][], closed = true): string {
+ const len = points.length
+
+ if (len < 4) {
+ return ""
+ }
+
+ let a = points[0]
+ let b = points[1]
+ const c = points[2]
+
+ let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(2)},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average(
+ b[1],
+ c[1],
+ ).toFixed(2)} T`
+
+ for (let i = 2, max = len - 1; i < max; i++) {
+ a = points[i]
+ b = points[i + 1]
+ result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(2)} `
+ }
+
+ if (closed) {
+ result += "Z"
+ }
+
+ return result
+}
diff --git a/packages/machines/signature-pad/src/index.ts b/packages/machines/signature-pad/src/index.ts
new file mode 100644
index 0000000000..c11c48357d
--- /dev/null
+++ b/packages/machines/signature-pad/src/index.ts
@@ -0,0 +1,11 @@
+export { anatomy } from "./signature-pad.anatomy"
+export { connect } from "./signature-pad.connect"
+export { machine } from "./signature-pad.machine"
+export * from "./signature-pad.props"
+export type {
+ UserDefinedContext as Context,
+ MachineApi as Api,
+ DrawDetails,
+ DrawEndDetails,
+ SegmentPathProps,
+} from "./signature-pad.types"
diff --git a/packages/machines/signature-pad/src/signature-pad.anatomy.ts b/packages/machines/signature-pad/src/signature-pad.anatomy.ts
new file mode 100644
index 0000000000..34b49ce201
--- /dev/null
+++ b/packages/machines/signature-pad/src/signature-pad.anatomy.ts
@@ -0,0 +1,13 @@
+import { createAnatomy } from "@zag-js/anatomy"
+
+export const anatomy = createAnatomy("signature-pad").parts(
+ "root",
+ "control",
+ "segment",
+ "segmentPath",
+ "separator",
+ "clearTrigger",
+ "label",
+)
+
+export const parts = anatomy.build()
diff --git a/packages/machines/signature-pad/src/signature-pad.connect.ts b/packages/machines/signature-pad/src/signature-pad.connect.ts
new file mode 100644
index 0000000000..36b97a2793
--- /dev/null
+++ b/packages/machines/signature-pad/src/signature-pad.connect.ts
@@ -0,0 +1,101 @@
+import { getRelativePoint, isLeftClick, isModifiedEvent } from "@zag-js/dom-event"
+import { dataAttr } from "@zag-js/dom-query"
+import type { NormalizeProps, PropTypes } from "@zag-js/types"
+import { parts } from "./signature-pad.anatomy"
+import { dom } from "./signature-pad.dom"
+import type { MachineApi, Send, State } from "./signature-pad.types"
+
+export function connect(state: State, send: Send, normalize: NormalizeProps): MachineApi {
+ const isDrawing = state.matches("drawing")
+ const isEmpty = state.context.isEmpty
+ const isInteractive = state.context.isInteractive
+ const isDisabled = !!state.context.disabled
+
+ return {
+ isEmpty,
+ isDrawing,
+ currentPath: state.context.currentPath,
+ paths: state.context.paths,
+ clear() {
+ send({ type: "CLEAR" })
+ },
+
+ getDataUrl(type, quality) {
+ return dom.getDataUrl(state.context, { type, quality })
+ },
+
+ labelProps: normalize.element({
+ ...parts.label.attrs,
+ "data-disabled": dataAttr(isDisabled),
+ htmlFor: dom.getControlId(state.context),
+ }),
+
+ rootProps: normalize.element({
+ ...parts.root.attrs,
+ "data-disabled": dataAttr(isDisabled),
+ id: dom.getRootId(state.context),
+ }),
+
+ controlProps: normalize.element({
+ ...parts.control.attrs,
+ tabIndex: isDisabled ? undefined : 0,
+ id: dom.getControlId(state.context),
+ "aria-label": "Signature Pad",
+ "aria-roledescription": "signature pad",
+ "aria-disabled": isDisabled,
+ "data-disabled": dataAttr(isDisabled),
+ onPointerDown(event) {
+ if (!isLeftClick(event) || isModifiedEvent(event) || !isInteractive) return
+ event.currentTarget.setPointerCapture(event.pointerId)
+ const point = { x: event.clientX, y: event.clientY }
+ const { offset } = getRelativePoint(point, dom.getControlEl(state.context)!)
+ send({ type: "POINTER_DOWN", point: offset, pressure: event.pressure })
+ },
+ onPointerUp(event) {
+ if (!isInteractive) return
+ event.currentTarget.releasePointerCapture(event.pointerId)
+ },
+ style: {
+ position: "relative",
+ touchAction: "none",
+ userSelect: "none",
+ },
+ }),
+
+ segmentProps: normalize.svg({
+ ...parts.segment.attrs,
+ style: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ width: "100%",
+ height: "100%",
+ pointerEvents: "none",
+ fill: state.context.drawing.fill,
+ },
+ }),
+
+ getSegmentPathProps(props) {
+ return normalize.path({
+ ...parts.segmentPath.attrs,
+ d: props.path,
+ })
+ },
+
+ separatorProps: normalize.element({
+ ...parts.separator.attrs,
+ "data-disabled": dataAttr(isDisabled),
+ }),
+
+ clearTriggerProps: normalize.button({
+ ...parts.clearTrigger.attrs,
+ type: "button",
+ "aria-label": "Clear Signature",
+ hidden: !state.context.paths.length || isDrawing,
+ disabled: isDisabled,
+ onClick() {
+ send({ type: "CLEAR" })
+ },
+ }),
+ }
+}
diff --git a/packages/machines/signature-pad/src/signature-pad.dom.ts b/packages/machines/signature-pad/src/signature-pad.dom.ts
new file mode 100644
index 0000000000..d09db1acd5
--- /dev/null
+++ b/packages/machines/signature-pad/src/signature-pad.dom.ts
@@ -0,0 +1,54 @@
+import { createScope, query } from "@zag-js/dom-query"
+import type { MachineContext as Ctx, DataUrlOptions } from "./signature-pad.types"
+
+export const dom = createScope({
+ getRootId: (ctx: Ctx) => `signature-${ctx.id}`,
+ getControlId: (ctx: Ctx) => `signature-control-${ctx.id}`,
+
+ getControlEl: (ctx: Ctx) => dom.getById(ctx, dom.getControlId(ctx)),
+ getSegmentEl: (ctx: Ctx) => query(dom.getControlEl(ctx), "[data-part=segment]"),
+
+ getDataUrl: (ctx: Ctx, options: DataUrlOptions): Promise => {
+ const { type, quality = 0.92 } = options
+
+ if (ctx.isEmpty) {
+ return Promise.resolve("")
+ }
+
+ const svg = dom.getSegmentEl(ctx) as SVGElement | null
+ if (!svg) {
+ throw new Error("Could not find the svg element.")
+ }
+
+ const win = dom.getWin(ctx)
+ const doc = win.document
+
+ const serializer = new win.XMLSerializer()
+ const source = '\r\n' + serializer.serializeToString(svg)
+ const svgString = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(source)
+
+ if (type === "image/svg+xml") {
+ return Promise.resolve(svgString)
+ }
+
+ const svgBounds = svg.getBoundingClientRect()
+ const dpr = win.devicePixelRatio || 1
+
+ const canvas = doc.createElement("canvas")
+ const image = new win.Image()
+ image.src = svgString
+
+ canvas.width = svgBounds.width * dpr
+ canvas.height = svgBounds.height * dpr
+
+ const context = canvas.getContext("2d")
+ context!.scale(dpr, dpr)
+
+ return new Promise((resolve) => {
+ image.onload = () => {
+ context!.drawImage(image, 0, 0)
+ resolve(canvas.toDataURL(type, quality))
+ }
+ })
+ },
+})
diff --git a/packages/machines/signature-pad/src/signature-pad.machine.ts b/packages/machines/signature-pad/src/signature-pad.machine.ts
new file mode 100644
index 0000000000..cbd715e737
--- /dev/null
+++ b/packages/machines/signature-pad/src/signature-pad.machine.ts
@@ -0,0 +1,115 @@
+import { createMachine } from "@zag-js/core"
+import { getRelativePoint, trackPointerMove } from "@zag-js/dom-event"
+import { compact } from "@zag-js/utils"
+import getStroke from "perfect-freehand"
+import { getSvgPathFromStroke } from "./get-svg-path"
+import { dom } from "./signature-pad.dom"
+import type { MachineContext, MachineState, UserDefinedContext } from "./signature-pad.types"
+
+export function machine(userContext: UserDefinedContext) {
+ const ctx = compact(userContext)
+ return createMachine(
+ {
+ id: "signature-pad",
+ initial: "idle",
+ context: {
+ ...ctx,
+ paths: [],
+ currentPoints: [],
+ currentPath: null,
+ drawing: {
+ size: 2,
+ simulatePressure: false,
+ thinning: 0.7,
+ smoothing: 0.4,
+ streamline: 0.6,
+ ...ctx.drawing,
+ },
+ },
+
+ computed: {
+ isInteractive: (ctx) => !(ctx.disabled || ctx.readOnly),
+ isEmpty: (ctx) => ctx.paths.length === 0,
+ },
+
+ on: {
+ CLEAR: {
+ actions: ["clearPoints", "invokeOnDrawEnd", "focusCanvasEl"],
+ },
+ },
+
+ states: {
+ idle: {
+ on: {
+ POINTER_DOWN: {
+ target: "drawing",
+ actions: ["addPoint"],
+ },
+ },
+ },
+ drawing: {
+ activities: ["trackPointerMove"],
+ on: {
+ POINTER_MOVE: {
+ actions: ["addPoint", "invokeOnDraw"],
+ },
+ POINTER_UP: {
+ target: "idle",
+ actions: ["endStroke", "invokeOnDrawEnd"],
+ },
+ },
+ },
+ },
+ },
+ {
+ activities: {
+ trackPointerMove(ctx, _evt, { send }) {
+ const doc = dom.getDoc(ctx)
+ return trackPointerMove(doc, {
+ onPointerMove({ event, point }) {
+ const { offset } = getRelativePoint(point, dom.getControlEl(ctx)!)
+ send({ type: "POINTER_MOVE", point: offset, pressure: event.pressure })
+ },
+ onPointerUp() {
+ send({ type: "POINTER_UP" })
+ },
+ })
+ },
+ },
+ actions: {
+ addPoint(ctx, evt) {
+ ctx.currentPoints.push(evt.point)
+ const stroke = getStroke(ctx.currentPoints, ctx.drawing)
+ ctx.currentPath = getSvgPathFromStroke(stroke)
+ },
+ endStroke(ctx) {
+ ctx.paths.push(ctx.currentPath!)
+ ctx.currentPoints = []
+ ctx.currentPath = null
+ },
+ clearPoints(ctx) {
+ ctx.currentPoints = []
+ ctx.paths = []
+ },
+ focusCanvasEl(ctx) {
+ queueMicrotask(() => {
+ dom.getControlEl(ctx)?.focus({ preventScroll: true })
+ })
+ },
+ invokeOnDraw(ctx) {
+ ctx.onDraw?.({
+ paths: [...ctx.paths, ctx.currentPath!],
+ })
+ },
+ invokeOnDrawEnd(ctx) {
+ ctx.onDrawEnd?.({
+ paths: [...ctx.paths],
+ getDataUrl(type, quality = 0.92) {
+ return dom.getDataUrl(ctx, { type, quality })
+ },
+ })
+ },
+ },
+ },
+ )
+}
diff --git a/packages/machines/signature-pad/src/signature-pad.props.ts b/packages/machines/signature-pad/src/signature-pad.props.ts
new file mode 100644
index 0000000000..6590aaa373
--- /dev/null
+++ b/packages/machines/signature-pad/src/signature-pad.props.ts
@@ -0,0 +1,16 @@
+import { createProps } from "@zag-js/types"
+import { createSplitProps } from "@zag-js/utils"
+import type { UserDefinedContext } from "./signature-pad.types"
+
+export const props = createProps()([
+ "dir",
+ "disabled",
+ "getRootNode",
+ "id",
+ "onDraw",
+ "onDrawEnd",
+ "readOnly",
+ "drawing",
+])
+
+export const splitProps = createSplitProps>(props)
diff --git a/packages/machines/signature-pad/src/signature-pad.types.ts b/packages/machines/signature-pad/src/signature-pad.types.ts
new file mode 100644
index 0000000000..d835316222
--- /dev/null
+++ b/packages/machines/signature-pad/src/signature-pad.types.ts
@@ -0,0 +1,131 @@
+import type { StateMachine as S } from "@zag-js/core"
+import type { CommonProperties, DirectionProperty, PropTypes, RequiredBy } from "@zag-js/types"
+import type { StrokeOptions } from "perfect-freehand"
+
+interface Point {
+ x: number
+ y: number
+ pressure: number
+}
+
+export interface DrawDetails {
+ paths: string[]
+}
+
+export interface DrawingOptions extends StrokeOptions {
+ /**
+ * The color of the stroke.
+ * Note: Must be a valid CSS color string, not a css variable.
+ */
+ fill?: string
+}
+
+export type DataUrlType = "image/png" | "image/jpeg" | "image/svg+xml"
+
+export interface DrawEndDetails {
+ paths: string[]
+ getDataUrl(type: DataUrlType, quality?: number): Promise
+}
+
+export interface DataUrlOptions {
+ type: DataUrlType
+ quality?: number
+}
+
+interface PublicContext extends DirectionProperty, CommonProperties {
+ /**
+ * Callback when the signature pad is drawing.
+ */
+ onDraw?(details: DrawDetails): void
+ /**
+ * Callback when the signature pad is done drawing.
+ */
+ onDrawEnd?(details: DrawEndDetails): void
+ /**
+ * The drawing options.
+ */
+ drawing: DrawingOptions
+ /**
+ * Whether the signature pad is disabled.
+ */
+ disabled?: boolean
+ /**
+ * Whether the signature pad is read-only.
+ */
+ readOnly?: boolean
+}
+
+interface PrivateContext {
+ /**
+ * The layers of the signature pad. A layer is a snapshot of a single stroke interaction.
+ */
+ paths: string[]
+ /**
+ * The current layer points.
+ */
+ currentPoints: Point[]
+ /**
+ * The current stroke path
+ */
+ currentPath: string | null
+}
+
+type ComputedContext = Readonly<{
+ isInteractive: boolean
+ isEmpty: boolean
+}>
+
+export type UserDefinedContext = RequiredBy
+
+export interface MachineContext extends PublicContext, PrivateContext, ComputedContext {}
+
+export interface MachineState {
+ value: "idle" | "drawing"
+}
+
+export type State = S.State
+
+export type Send = S.Send
+
+/* -----------------------------------------------------------------------------
+ * Component API
+ * -----------------------------------------------------------------------------*/
+
+export interface SegmentPathProps {
+ path: string
+}
+
+export interface MachineApi {
+ /**
+ * Whether the signature pad is empty.
+ */
+ isEmpty: boolean
+ /**
+ * Whether the user is currently drawing.
+ */
+ isDrawing: boolean
+ /**
+ * The current path being drawn.
+ */
+ currentPath: string | null
+ /**
+ * The paths of the signature pad.
+ */
+ paths: string[]
+ /**
+ * Returns the data URL of the signature pad.
+ */
+ getDataUrl(type: DataUrlType, quality?: number): Promise
+ /**
+ * Clears the signature pad.
+ */
+ clear(): void
+
+ labelProps: T["element"]
+ rootProps: T["element"]
+ controlProps: T["element"]
+ segmentProps: T["svg"]
+ getSegmentPathProps(props: SegmentPathProps): T["path"]
+ separatorProps: T["element"]
+ clearTriggerProps: T["element"]
+}
diff --git a/packages/machines/signature-pad/tsconfig.json b/packages/machines/signature-pad/tsconfig.json
new file mode 100644
index 0000000000..8e781cd154
--- /dev/null
+++ b/packages/machines/signature-pad/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../../tsconfig.json",
+ "include": ["src"],
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/.tsbuildinfo"
+ }
+}
diff --git a/packages/types/src/jsx.ts b/packages/types/src/jsx.ts
index 60111c626b..e4f6b2941b 100644
--- a/packages/types/src/jsx.ts
+++ b/packages/types/src/jsx.ts
@@ -1235,6 +1235,291 @@ export namespace JSX {
disableRemotePlayback?: boolean | undefined
}
+ export interface SVGAttributes extends AriaAttributes, DOMAttributes {
+ // React-specific Attributes
+ suppressHydrationWarning?: boolean | undefined
+
+ // Attributes which also defined in HTMLAttributes
+ // See comment in SVGDOMPropertyConfig.js
+ className?: string | undefined
+ color?: string | undefined
+ height?: number | string | undefined
+ id?: string | undefined
+ lang?: string | undefined
+ max?: number | string | undefined
+ media?: string | undefined
+ method?: string | undefined
+ min?: number | string | undefined
+ name?: string | undefined
+ style?: CSSProperties | undefined
+ target?: string | undefined
+ type?: string | undefined
+ width?: number | string | undefined
+
+ // Other HTML properties supported by SVG elements in browsers
+ role?: AriaRole | undefined
+ tabIndex?: number | undefined
+ crossOrigin?: any
+
+ // SVG Specific attributes
+ accentHeight?: number | string | undefined
+ accumulate?: "none" | "sum" | undefined
+ additive?: "replace" | "sum" | undefined
+ alignmentBaseline?:
+ | "auto"
+ | "baseline"
+ | "before-edge"
+ | "text-before-edge"
+ | "middle"
+ | "central"
+ | "after-edge"
+ | "text-after-edge"
+ | "ideographic"
+ | "alphabetic"
+ | "hanging"
+ | "mathematical"
+ | "inherit"
+ | undefined
+ allowReorder?: "no" | "yes" | undefined
+ alphabetic?: number | string | undefined
+ amplitude?: number | string | undefined
+ arabicForm?: "initial" | "medial" | "terminal" | "isolated" | undefined
+ ascent?: number | string | undefined
+ attributeName?: string | undefined
+ attributeType?: string | undefined
+ autoReverse?: Booleanish | undefined
+ azimuth?: number | string | undefined
+ baseFrequency?: number | string | undefined
+ baselineShift?: number | string | undefined
+ baseProfile?: number | string | undefined
+ bbox?: number | string | undefined
+ begin?: number | string | undefined
+ bias?: number | string | undefined
+ by?: number | string | undefined
+ calcMode?: number | string | undefined
+ capHeight?: number | string | undefined
+ clip?: number | string | undefined
+ clipPath?: string | undefined
+ clipPathUnits?: number | string | undefined
+ clipRule?: number | string | undefined
+ colorInterpolation?: number | string | undefined
+ colorInterpolationFilters?: "auto" | "sRGB" | "linearRGB" | "inherit" | undefined
+ colorProfile?: number | string | undefined
+ colorRendering?: number | string | undefined
+ contentScriptType?: number | string | undefined
+ contentStyleType?: number | string | undefined
+ cursor?: number | string | undefined
+ cx?: number | string | undefined
+ cy?: number | string | undefined
+ d?: string | undefined
+ decelerate?: number | string | undefined
+ descent?: number | string | undefined
+ diffuseConstant?: number | string | undefined
+ direction?: number | string | undefined
+ display?: number | string | undefined
+ divisor?: number | string | undefined
+ dominantBaseline?: number | string | undefined
+ dur?: number | string | undefined
+ dx?: number | string | undefined
+ dy?: number | string | undefined
+ edgeMode?: number | string | undefined
+ elevation?: number | string | undefined
+ enableBackground?: number | string | undefined
+ end?: number | string | undefined
+ exponent?: number | string | undefined
+ externalResourcesRequired?: Booleanish | undefined
+ fill?: string | undefined
+ fillOpacity?: number | string | undefined
+ fillRule?: "nonzero" | "evenodd" | "inherit" | undefined
+ filter?: string | undefined
+ filterRes?: number | string | undefined
+ filterUnits?: number | string | undefined
+ floodColor?: number | string | undefined
+ floodOpacity?: number | string | undefined
+ focusable?: Booleanish | "auto" | undefined
+ fontFamily?: string | undefined
+ fontSize?: number | string | undefined
+ fontSizeAdjust?: number | string | undefined
+ fontStretch?: number | string | undefined
+ fontStyle?: number | string | undefined
+ fontVariant?: number | string | undefined
+ fontWeight?: number | string | undefined
+ format?: number | string | undefined
+ fr?: number | string | undefined
+ from?: number | string | undefined
+ fx?: number | string | undefined
+ fy?: number | string | undefined
+ g1?: number | string | undefined
+ g2?: number | string | undefined
+ glyphName?: number | string | undefined
+ glyphOrientationHorizontal?: number | string | undefined
+ glyphOrientationVertical?: number | string | undefined
+ glyphRef?: number | string | undefined
+ gradientTransform?: string | undefined
+ gradientUnits?: string | undefined
+ hanging?: number | string | undefined
+ horizAdvX?: number | string | undefined
+ horizOriginX?: number | string | undefined
+ href?: string | undefined
+ ideographic?: number | string | undefined
+ imageRendering?: number | string | undefined
+ in2?: number | string | undefined
+ in?: string | undefined
+ intercept?: number | string | undefined
+ k1?: number | string | undefined
+ k2?: number | string | undefined
+ k3?: number | string | undefined
+ k4?: number | string | undefined
+ k?: number | string | undefined
+ kernelMatrix?: number | string | undefined
+ kernelUnitLength?: number | string | undefined
+ kerning?: number | string | undefined
+ keyPoints?: number | string | undefined
+ keySplines?: number | string | undefined
+ keyTimes?: number | string | undefined
+ lengthAdjust?: number | string | undefined
+ letterSpacing?: number | string | undefined
+ lightingColor?: number | string | undefined
+ limitingConeAngle?: number | string | undefined
+ local?: number | string | undefined
+ markerEnd?: string | undefined
+ markerHeight?: number | string | undefined
+ markerMid?: string | undefined
+ markerStart?: string | undefined
+ markerUnits?: number | string | undefined
+ markerWidth?: number | string | undefined
+ mask?: string | undefined
+ maskContentUnits?: number | string | undefined
+ maskUnits?: number | string | undefined
+ mathematical?: number | string | undefined
+ mode?: number | string | undefined
+ numOctaves?: number | string | undefined
+ offset?: number | string | undefined
+ opacity?: number | string | undefined
+ operator?: number | string | undefined
+ order?: number | string | undefined
+ orient?: number | string | undefined
+ orientation?: number | string | undefined
+ origin?: number | string | undefined
+ overflow?: number | string | undefined
+ overlinePosition?: number | string | undefined
+ overlineThickness?: number | string | undefined
+ paintOrder?: number | string | undefined
+ panose1?: number | string | undefined
+ path?: string | undefined
+ pathLength?: number | string | undefined
+ patternContentUnits?: string | undefined
+ patternTransform?: number | string | undefined
+ patternUnits?: string | undefined
+ pointerEvents?: number | string | undefined
+ points?: string | undefined
+ pointsAtX?: number | string | undefined
+ pointsAtY?: number | string | undefined
+ pointsAtZ?: number | string | undefined
+ preserveAlpha?: Booleanish | undefined
+ preserveAspectRatio?: string | undefined
+ primitiveUnits?: number | string | undefined
+ r?: number | string | undefined
+ radius?: number | string | undefined
+ refX?: number | string | undefined
+ refY?: number | string | undefined
+ renderingIntent?: number | string | undefined
+ repeatCount?: number | string | undefined
+ repeatDur?: number | string | undefined
+ requiredExtensions?: number | string | undefined
+ requiredFeatures?: number | string | undefined
+ restart?: number | string | undefined
+ result?: string | undefined
+ rotate?: number | string | undefined
+ rx?: number | string | undefined
+ ry?: number | string | undefined
+ scale?: number | string | undefined
+ seed?: number | string | undefined
+ shapeRendering?: number | string | undefined
+ slope?: number | string | undefined
+ spacing?: number | string | undefined
+ specularConstant?: number | string | undefined
+ specularExponent?: number | string | undefined
+ speed?: number | string | undefined
+ spreadMethod?: string | undefined
+ startOffset?: number | string | undefined
+ stdDeviation?: number | string | undefined
+ stemh?: number | string | undefined
+ stemv?: number | string | undefined
+ stitchTiles?: number | string | undefined
+ stopColor?: string | undefined
+ stopOpacity?: number | string | undefined
+ strikethroughPosition?: number | string | undefined
+ strikethroughThickness?: number | string | undefined
+ string?: number | string | undefined
+ stroke?: string | undefined
+ strokeDasharray?: string | number | undefined
+ strokeDashoffset?: string | number | undefined
+ strokeLinecap?: "butt" | "round" | "square" | "inherit" | undefined
+ strokeLinejoin?: "miter" | "round" | "bevel" | "inherit" | undefined
+ strokeMiterlimit?: number | string | undefined
+ strokeOpacity?: number | string | undefined
+ strokeWidth?: number | string | undefined
+ surfaceScale?: number | string | undefined
+ systemLanguage?: number | string | undefined
+ tableValues?: number | string | undefined
+ targetX?: number | string | undefined
+ targetY?: number | string | undefined
+ textAnchor?: string | undefined
+ textDecoration?: number | string | undefined
+ textLength?: number | string | undefined
+ textRendering?: number | string | undefined
+ to?: number | string | undefined
+ transform?: string | undefined
+ u1?: number | string | undefined
+ u2?: number | string | undefined
+ underlinePosition?: number | string | undefined
+ underlineThickness?: number | string | undefined
+ unicode?: number | string | undefined
+ unicodeBidi?: number | string | undefined
+ unicodeRange?: number | string | undefined
+ unitsPerEm?: number | string | undefined
+ vAlphabetic?: number | string | undefined
+ values?: string | undefined
+ vectorEffect?: number | string | undefined
+ version?: string | undefined
+ vertAdvY?: number | string | undefined
+ vertOriginX?: number | string | undefined
+ vertOriginY?: number | string | undefined
+ vHanging?: number | string | undefined
+ vIdeographic?: number | string | undefined
+ viewBox?: string | undefined
+ viewTarget?: number | string | undefined
+ visibility?: number | string | undefined
+ vMathematical?: number | string | undefined
+ widths?: number | string | undefined
+ wordSpacing?: number | string | undefined
+ writingMode?: number | string | undefined
+ x1?: number | string | undefined
+ x2?: number | string | undefined
+ x?: number | string | undefined
+ xChannelSelector?: string | undefined
+ xHeight?: number | string | undefined
+ xlinkActuate?: string | undefined
+ xlinkArcrole?: string | undefined
+ xlinkHref?: string | undefined
+ xlinkRole?: string | undefined
+ xlinkShow?: string | undefined
+ xlinkTitle?: string | undefined
+ xlinkType?: string | undefined
+ xmlBase?: string | undefined
+ xmlLang?: string | undefined
+ xmlns?: string | undefined
+ xmlnsXlink?: string | undefined
+ xmlSpace?: string | undefined
+ y1?: number | string | undefined
+ y2?: number | string | undefined
+ y?: number | string | undefined
+ yChannelSelector?: string | undefined
+ z?: number | string | undefined
+ zoomAndPan?: string | undefined
+ }
+
export interface IntrinsicElements {
// HTML
a: AnchorHTMLAttributes
@@ -1352,5 +1637,9 @@ export namespace JSX {
ul: HTMLAttributes
var: HTMLAttributes
video: VideoHTMLAttributes
+
+ svg: SVGAttributes
+ circle: SVGAttributes
+ path: SVGAttributes
}
}
diff --git a/packages/types/src/prop-types.ts b/packages/types/src/prop-types.ts
index 9fe0dbc029..8d56c4558c 100644
--- a/packages/types/src/prop-types.ts
+++ b/packages/types/src/prop-types.ts
@@ -45,7 +45,18 @@ type DataAttr = {
}
export type PropTypes = Record<
- "button" | "label" | "input" | "textarea" | "img" | "output" | "element" | "select" | "style" | "circle" | "svg",
+ | "button"
+ | "label"
+ | "input"
+ | "textarea"
+ | "img"
+ | "output"
+ | "element"
+ | "select"
+ | "style"
+ | "circle"
+ | "svg"
+ | "path",
T
>
diff --git a/packages/utilities/dom-query/src/query.ts b/packages/utilities/dom-query/src/query.ts
index f5bfe368e9..ac223f41b7 100644
--- a/packages/utilities/dom-query/src/query.ts
+++ b/packages/utilities/dom-query/src/query.ts
@@ -1,9 +1,9 @@
type Root = Document | Element | null | undefined
-export function queryAll(root: Root, selector: string) {
+export function queryAll(root: Root, selector: string) {
return Array.from(root?.querySelectorAll(selector) ?? [])
}
-export function query(root: Root, selector: string) {
+export function query(root: Root, selector: string) {
return root?.querySelector(selector) ?? null
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e4c1d7a86b..556d13584f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -303,6 +303,9 @@ importers:
'@zag-js/shared':
specifier: workspace:*
version: link:../../shared
+ '@zag-js/signature-pad':
+ specifier: workspace:*
+ version: link:../../packages/machines/signature-pad
'@zag-js/slider':
specifier: workspace:*
version: link:../../packages/machines/slider
@@ -553,6 +556,9 @@ importers:
'@zag-js/shared':
specifier: workspace:*
version: link:../../shared
+ '@zag-js/signature-pad':
+ specifier: workspace:*
+ version: link:../../packages/machines/signature-pad
'@zag-js/slider':
specifier: workspace:*
version: link:../../packages/machines/slider
@@ -797,6 +803,9 @@ importers:
'@zag-js/shared':
specifier: workspace:*
version: link:../../shared
+ '@zag-js/signature-pad':
+ specifier: workspace:*
+ version: link:../../packages/machines/signature-pad
'@zag-js/slider':
specifier: workspace:*
version: link:../../packages/machines/slider
@@ -1038,6 +1047,9 @@ importers:
'@zag-js/shared':
specifier: workspace:*
version: link:../../shared
+ '@zag-js/signature-pad':
+ specifier: workspace:*
+ version: link:../../packages/machines/signature-pad
'@zag-js/slider':
specifier: workspace:*
version: link:../../packages/machines/slider
@@ -1270,6 +1282,9 @@ importers:
'@zag-js/shared':
specifier: workspace:*
version: link:../../shared
+ '@zag-js/signature-pad':
+ specifier: workspace:*
+ version: link:../../packages/machines/signature-pad
'@zag-js/slider':
specifier: workspace:*
version: link:../../packages/machines/slider
@@ -1520,6 +1535,9 @@ importers:
'@zag-js/shared':
specifier: workspace:*
version: link:../../shared
+ '@zag-js/signature-pad':
+ specifier: workspace:*
+ version: link:../../packages/machines/signature-pad
'@zag-js/slider':
specifier: workspace:*
version: link:../../packages/machines/slider
@@ -2552,6 +2570,34 @@ importers:
specifier: 2.2.0
version: 2.2.0
+ packages/machines/signature-pad:
+ dependencies:
+ '@zag-js/anatomy':
+ specifier: workspace:*
+ version: link:../../anatomy
+ '@zag-js/core':
+ specifier: workspace:*
+ version: link:../../core
+ '@zag-js/dom-event':
+ specifier: workspace:*
+ version: link:../../utilities/dom-event
+ '@zag-js/dom-query':
+ specifier: workspace:*
+ version: link:../../utilities/dom-query
+ '@zag-js/types':
+ specifier: workspace:*
+ version: link:../../types
+ '@zag-js/utils':
+ specifier: workspace:*
+ version: link:../../utilities/core
+ perfect-freehand:
+ specifier: ^1.2.2
+ version: 1.2.2
+ devDependencies:
+ clean-package:
+ specifier: 2.2.0
+ version: 2.2.0
+
packages/machines/slider:
dependencies:
'@zag-js/anatomy':
@@ -15924,6 +15970,10 @@ packages:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
dev: true
+ /perfect-freehand@1.2.2:
+ resolution: {integrity: sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==}
+ dev: false
+
/periscopic@3.1.0:
resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==}
dependencies:
diff --git a/shared/src/controls.ts b/shared/src/controls.ts
index 5ff8d5f9a4..548663437b 100644
--- a/shared/src/controls.ts
+++ b/shared/src/controls.ts
@@ -254,3 +254,7 @@ export const floatingPanelControls = defineControls({
lockAspectRatio: { type: "boolean", defaultValue: false },
closeOnEscape: { type: "boolean", defaultValue: true },
})
+
+export const signaturePadControls = defineControls({
+ disabled: { type: "boolean", defaultValue: false },
+})
diff --git a/shared/src/css/signature-pad.css b/shared/src/css/signature-pad.css
new file mode 100644
index 0000000000..766fc7cafa
--- /dev/null
+++ b/shared/src/css/signature-pad.css
@@ -0,0 +1,49 @@
+[data-scope="signature-pad"][data-part="root"] {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ max-width: 400px;
+}
+
+[data-scope="signature-pad"][data-part="label"] {
+ display: inline-block;
+ margin-bottom: 8px;
+ font-weight: 500;
+}
+
+[data-scope="signature-pad"][data-part="control"] {
+ width: 100%;
+ height: 160px;
+ background-color: rgb(239, 239, 239);
+ border-radius: 8px;
+}
+
+[data-scope="signature-pad"][data-part="separator"] {
+ position: absolute;
+ bottom: 12%;
+ left: 8px;
+ right: 8px;
+ border-bottom: 2px dashed rgb(129, 133, 150);
+}
+
+[data-scope="signature-pad"][data-part="clear-trigger"] {
+ position: absolute;
+ top: 32px;
+ right: 8px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.2rem;
+
+ & svg {
+ width: 1em;
+ height: 1em;
+ }
+}
+
+.signature-pad [data-part="preview"] {
+ height: 160px;
+ object-fit: cover;
+ max-width: 100%;
+}
diff --git a/shared/src/routes.ts b/shared/src/routes.ts
index 3c8e722afa..a2718ce0c9 100644
--- a/shared/src/routes.ts
+++ b/shared/src/routes.ts
@@ -4,6 +4,7 @@ type RouteData = {
}
export const routesData: RouteData[] = [
+ { label: "Signature Pad", path: "/signature-pad" },
{ label: "Floating Panel", path: "/floating-panel" },
{ label: "Tour", path: "/tour" },
{ label: "Collapsible", path: "/collapsible" },
diff --git a/shared/src/style.css b/shared/src/style.css
index 12c21dcc91..f84bb37ef5 100644
--- a/shared/src/style.css
+++ b/shared/src/style.css
@@ -36,6 +36,7 @@
@import url("./css/segmented-control.css");
@import url("./css/select.css");
+@import url("./css/signature-pad.css");
@import url("./css/slider.css");
@import url("./css/splitter.css");
@import url("./css/switch.css");
diff --git a/website/data/components/signature-pad.mdx b/website/data/components/signature-pad.mdx
new file mode 100644
index 0000000000..ab0d9c0584
--- /dev/null
+++ b/website/data/components/signature-pad.mdx
@@ -0,0 +1,131 @@
+---
+title: Signature Pad
+description: Using the signature pad component in your application
+package: "@zag-js/signature-pad"
+---
+
+# Signature Pad
+
+The signature pad component allows users to draw handwritten signatures using
+touch or pointer devices. The signature can be saved as an image or cleared.
+
+## Listening to drawing events
+
+The signature pad component emits the following events:
+
+- `onDraw`: Emitted when the user is drawing the signature.
+- `onDrawEnd`: Emitted when the user stops drawing the signature.
+
+```jsx
+const [state, send] = useMachine(
+ signature.machine({
+ onDraw(details) {
+ // details => { path: string[] }
+ console.log("Drawing signature", details)
+ },
+ onDrawEnd(details) {
+ // details => { path: string[], toDataURL: () => string }
+ console.log("Signature drawn", details)
+ },
+ }),
+)
+```
+
+## Clearing the signature
+
+To clear the signature, use the `api.clear()`, or render the clear trigger
+button.
+
+```jsx
+
+```
+
+## Rendering an image preview
+
+Use the `api.getDataUrl()` method to get the signature as a data URL and render
+it as an image.
+
+> You can also leverage the `onDrawEnd` event to get the signature data URL.
+
+## Changing the stroke color
+
+To change the stroke color, set the `drawing.fill` option to a valid CSS color.
+
+> Note: You can't use a css variable as the stroke color.
+
+```jsx
+const [state, send] = useMachine(
+ signature.machine({
+ drawing: {
+ fill: "red",
+ },
+ }),
+)
+```
+
+## Changing the stroke width
+
+To change the stroke width, set the `drawing.size` option to a number.
+
+```jsx
+const [state, send] = useMachine(
+ signature.machine({
+ drawing: {
+ size: 5,
+ },
+ }),
+)
+```
+
+## Simulating pressure sensitivity
+
+Pressure sensitivity is disabled by default. To enable it, set the
+`drawing.simulatePressure` option to `true`.
+
+```jsx
+const [state, send] = useMachine(
+ signature.machine({
+ drawing: {
+ simulatePressure: true,
+ },
+ }),
+)
+```
+
+## Usage in forms
+
+To use the signature pad in a form, set the `name` context property and render
+the hidden input element using `api.hiddenInputProps`.
+
+```jsx
+const [state, send] = useMachine(
+ signature.machine({
+ name: "signature",
+ }),
+)
+```
+
+## Disabling the signature pad
+
+Set the `disabled` context property to `true` to disable the signature pad.
+
+```jsx
+const [state, send] = useMachine(
+ signature.machine({
+ disabled: true,
+ }),
+)
+```
+
+## Making the signature pad read-only
+
+Set the `readOnly` context property to `true` to make the signature pad
+read-only.
+
+```jsx
+const [state, send] = useMachine(
+ signature.machine({
+ readOnly: true,
+ }),
+)
+```