# Signing workspace

> Ink colors, line weights, undo, redo, replay and PNG or SVG download in one signing card.

A shadcn/ui block from the [@reui](https://reui.io) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/reui/c-signature-pad-4).

- Page, with a live preview: https://shadcn.exchange/r/reui/c-signature-pad-4
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/reui/c-signature-pad-4-light-292a5667f2c17243.png
- Registry item: https://reui.io/r/new-york-v4/c-signature-pad-4.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://reui.io/r/new-york-v4/c-signature-pad-4.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `cn`
- Registry items: `@reui/badge`, `button`, `card`, `dropdown-menu`, `separator`, `@reui/signature-pad`

## Files

### `c-signature-pad-4.tsx`

```tsx
"use client"

import { useEffect, useRef, useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
  SignaturePad,
  SignaturePadArea,
  SignaturePadClear,
  SignaturePadGuide,
  SignaturePadPlaceholder,
  SignaturePadRedo,
  SignaturePadUndo,
  type SignaturePadApi,
  type SignaturePadStroke,
} from "@/components/reui/signature-pad"
import { cn } from "cn"

import { Button } from "@/components/ui/button"
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Separator } from "@/components/ui/separator"
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"

/* `undefined` follows the theme's text color, so it stays legible in dark mode. */
const INKS = [
  { label: "Default", color: undefined, swatch: "bg-foreground" },
  { label: "Blue", color: "#2563eb", swatch: "bg-blue-600" },
  { label: "Green", color: "#059669", swatch: "bg-emerald-600" },
]

const WEIGHTS = [
  { label: "Fine", min: 0.5, max: 1.8, sample: 1 },
  { label: "Medium", min: 0.8, max: 3.2, sample: 1.75 },
  { label: "Bold", min: 1.6, max: 5.5, sample: 2.75 },
]

function Swatch({ className }: { className: string }) {
  return (
    <span
      aria-hidden="true"
      className={cn(
        "ring-foreground/15 size-3.5 shrink-0 rounded-full ring-1 ring-inset",
        className
      )}
    />
  )
}

function Stroke({ width }: { width: number }) {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 24 12"
      fill="none"
      stroke="currentColor"
      strokeLinecap="round"
      strokeWidth={width}
      className="w-5 shrink-0"
    >
      <path d="M2 8c3-5 6-5 9-1s7 4 11-3" />
    </svg>
  )
}

function download(href: string, name: string) {
  const link = document.createElement("a")
  link.href = href
  link.download = name
  link.click()
}

function DownloadMenuIcon({ format }: { format: "png" | "svg" }) {
  return format === "png" ? (
    <IconPlaceholder
      lucide="ImageIcon"
      tabler="IconPhoto"
      hugeicons="Image01Icon"
      phosphor="ImageIcon"
      remixicon="RiImageLine"
      aria-hidden="true"
    />
  ) : (
    <IconPlaceholder
      lucide="PenToolIcon"
      tabler="IconVectorBezier"
      hugeicons="PenTool03Icon"
      phosphor="PenNibIcon"
      remixicon="RiPenNibLine"
      aria-hidden="true"
    />
  )
}

const DOWNLOADS = [
  { format: "png", label: "PNG image" },
  { format: "svg", label: "SVG vector" },
] as const

export function Pattern() {
  const [ink, setInk] = useState(INKS[0])
  const [weight, setWeight] = useState(WEIGHTS[1])
  const [strokes, setStrokes] = useState<SignaturePadStroke[]>([])
  /* While set, the pad shows this partial copy instead of the real value. */
  const [replay, setReplay] = useState<SignaturePadStroke[] | null>(null)
  const [signedAt, setSignedAt] = useState<string | null>(null)
  const frame = useRef(0)
  const padRef = useRef<SignaturePadApi>(null)

  useEffect(() => () => cancelAnimationFrame(frame.current), [])

  const playBack = () => {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
    const total = strokes.reduce((sum, stroke) => sum + stroke.points.length, 0)
    const perFrame = Math.max(2, Math.ceil(total / 80))
    let shown = 0
    const step = () => {
      shown += perFrame
      let left = shown
      const partial: SignaturePadStroke[] = []
      for (const stroke of strokes) {
        if (left <= 0) break
        partial.push(
          left >= stroke.points.length
            ? stroke
            : { ...stroke, points: stroke.points.slice(0, left) }
        )
        left -= stroke.points.length
      }
      if (shown < total) {
        setReplay(partial)
        frame.current = requestAnimationFrame(step)
      } else {
        setReplay(null)
      }
    }
    frame.current = requestAnimationFrame(step)
  }

  const locked = signedAt !== null || replay !== null

  /* Through apiRef, so a menu item outside the pad's parts can export it.
     Paper-white PNG, so the file reads the same on any page. */
  const save = (format: "png" | "svg") => {
    const value = padRef.current?.serialize(
      format,
      format === "png" ? { background: "#ffffff" } : undefined
    )
    if (value) download(value, `signature.${format}`)
  }

  return (
    <Card className="mx-auto w-full max-w-lg">
      <CardHeader>
        <CardTitle>Statement of work</CardTitle>
        <CardDescription>
          <span className="flex items-center gap-2">
            Harbor & Pine Design
            <span
              aria-hidden="true"
              className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
            />
            SOW-2041
          </span>
        </CardDescription>
        <CardAction>
          {/* Up here, away from Sign statement, so a double click on it
              cannot land on Edit and reopen what was just signed. */}
          <div className="flex items-center gap-2">
            {signedAt && (
              <Button
                variant="ghost"
                size="xs"
                onClick={() => {
                  setSignedAt(null)
                  requestAnimationFrame(() => padRef.current?.focus())
                }}
              >
                Edit
              </Button>
            )}
            <Badge variant={signedAt ? "success-light" : "secondary"}>
              {signedAt ? "Signed" : "Draft"}
            </Badge>
          </div>
        </CardAction>
      </CardHeader>
      <CardContent>
        <SignaturePad
          apiRef={padRef}
          /* Replay swaps in a partial copy; readOnly keeps a stray stroke
             from landing in it and being committed as the real value. */
          value={replay ?? strokes}
          onValueChange={setStrokes}
          color={ink.color}
          minWidth={weight.min}
          maxWidth={weight.max}
          readOnly={locked}
        >
          <SignaturePadArea aria-label="Client signature" className="h-44">
            {/* Raised off the edge so the name below the line has room. */}
            <SignaturePadGuide className="bottom-11" />
            <SignaturePadPlaceholder />
            <p
              aria-hidden="true"
              className="text-muted-foreground pointer-events-none absolute start-6 bottom-4 text-xs"
            >
              Daniel Reyes, Client
            </p>
          </SignaturePadArea>
          {/* One ghost row: each trigger shows its current choice and names
              it in the label, so the menus need no visible caption. */}
          <div className="flex items-center gap-1 pt-1">
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <Button
                    variant="ghost"
                    size="icon-sm"
                    aria-label={`Ink color, ${ink.label}`}
                    disabled={locked}
                  />
                }
              >
                <Swatch className={ink.swatch} />
              </DropdownMenuTrigger>
              <DropdownMenuContent align="start">
                <DropdownMenuGroup>
                  <DropdownMenuLabel>Ink color</DropdownMenuLabel>
                  <DropdownMenuRadioGroup
                    value={ink.label}
                    onValueChange={(value) => {
                      const option = INKS.find((item) => item.label === value)
                      if (option) setInk(option)
                    }}
                  >
                    {INKS.map((option) => (
                      /* Base UI keeps a radio menu open by default. */
                      <DropdownMenuRadioItem
                        key={option.label}
                        value={option.label}
                        closeOnClick
                      >
                        <Swatch className={option.swatch} />
                        {option.label}
                      </DropdownMenuRadioItem>
                    ))}
                  </DropdownMenuRadioGroup>
                </DropdownMenuGroup>
              </DropdownMenuContent>
            </DropdownMenu>
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <Button
                    variant="ghost"
                    size="icon-sm"
                    aria-label={`Line weight, ${weight.label}`}
                    disabled={locked}
                  />
                }
              >
                <Stroke width={weight.sample} />
              </DropdownMenuTrigger>
              <DropdownMenuContent align="start">
                <DropdownMenuGroup>
                  <DropdownMenuLabel>Line weight</DropdownMenuLabel>
                  <DropdownMenuRadioGroup
                    value={weight.label}
                    onValueChange={(value) => {
                      const option = WEIGHTS.find(
                        (item) => item.label === value
                      )
                      if (option) setWeight(option)
                    }}
                  >
                    {WEIGHTS.map((option) => (
                      <DropdownMenuRadioItem
                        key={option.label}
                        value={option.label}
                        closeOnClick
                      >
                        <Stroke width={option.sample} />
                        {option.label}
                      </DropdownMenuRadioItem>
                    ))}
                  </DropdownMenuRadioGroup>
                </DropdownMenuGroup>
              </DropdownMenuContent>
            </DropdownMenu>
            <Separator orientation="vertical" className="mx-1 my-auto h-4" />
            <SignaturePadUndo variant="ghost" />
            <SignaturePadRedo variant="ghost" />
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label="Replay signature"
              disabled={strokes.length === 0 || locked}
              onClick={playBack}
            >
              <IconPlaceholder
                lucide="PlayIcon"
                tabler="IconPlayerPlay"
                hugeicons="PlayIcon"
                phosphor="PlayIcon"
                remixicon="RiPlayLine"
                aria-hidden="true"
              />
            </Button>
            <SignaturePadClear variant="ghost" />
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <Button
                    variant="ghost"
                    size="icon-sm"
                    aria-label="Download signature"
                    className="ms-auto"
                    disabled={strokes.length === 0 || replay !== null}
                  />
                }
              >
                <IconPlaceholder
                  lucide="DownloadIcon"
                  tabler="IconDownload"
                  hugeicons="Download04Icon"
                  phosphor="DownloadSimpleIcon"
                  remixicon="RiDownloadLine"
                  aria-hidden="true"
                />
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                <DropdownMenuGroup>
                  <DropdownMenuLabel>Download as</DropdownMenuLabel>
                  {DOWNLOADS.map((option) => (
                    <DropdownMenuItem
                      key={option.format}
                      onClick={() => save(option.format)}
                    >
                      <DownloadMenuIcon format={option.format} />
                      {option.label}
                    </DropdownMenuItem>
                  ))}
                </DropdownMenuGroup>
              </DropdownMenuContent>
            </DropdownMenu>
          </div>
        </SignaturePad>
      </CardContent>
      <CardFooter>
        {signedAt ? (
          <p
            ref={(node) => node?.focus()}
            tabIndex={-1}
            role="status"
            className="text-muted-foreground ms-auto flex items-center gap-1.5 text-sm outline-none"
          >
            <IconPlaceholder
              lucide="CircleCheckIcon"
              tabler="IconCircleCheck"
              hugeicons="CheckmarkCircle02Icon"
              phosphor="CheckCircleIcon"
              remixicon="RiCheckboxCircleLine"
              aria-hidden="true"
              className="text-success size-4"
            />
            Signed {signedAt}
          </p>
        ) : (
          <Button
            className="ms-auto"
            disabled={strokes.length === 0 || replay !== null}
            onClick={() =>
              setSignedAt(
                new Intl.DateTimeFormat("en-US", {
                  dateStyle: "medium",
                  timeStyle: "short",
                }).format(new Date())
              )
            }
          >
            Sign statement
          </Button>
        )}
      </CardFooter>
    </Card>
  )
}
```
