컴포넌트
서명 패드
손이나 펜으로 서명을 그리고 SVG로 내보내는 패드예요.
- #arc
- #component
- #input
- #drawing
- #signature
- #canvas
- #form
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/signature-pad.json파일
components/arc/signature-pad/signature-pad.module.csscomponents/arc/signature-pad/signature-pad.tsx
npm 패키지
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10motion@^14.2.0
함께 오는 항목
signature-pad.module.csssignature-pad.module.csssignature-pad.module.csscss
.root {
--ink-black: var(--foreground);
--ink-blue: oklch(46% .17 262);
--ink-violet: oklch(49% .2 292);
display: grid; gap: 10px; width: 100%; min-width: 0; font-family: var(--font-body); letter-spacing: var(--tracking-body);
}
:global(:root[data-theme="dark"]) .root { --ink-blue: oklch(76% .12 258); --ink-violet: oklch(77% .13 295); }
/* The pad keeps the 600 by 260 ratio of its coordinate space, so strokes scale with it at every width. */
.pad {
position: relative; aspect-ratio: 600 / 260; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel);
background: var(--surface); cursor: crosshair; touch-action: none; user-select: none; -webkit-user-select: none; isolation: isolate;
transition: border-color var(--duration-fast) var(--ease-standard);
}
.pad[data-drawing] { border-color: var(--border-strong); }
.guide { position: absolute; inset: 0; pointer-events: none; }
.baseline { position: absolute; left: 7%; right: 7%; top: 72%; border-top: 1.5px dashed var(--border-strong); }
.mark { position: absolute; left: 7%; top: 72%; color: var(--text-muted); translate: 0 calc(-100% - 6px); }
.signer { position: absolute; left: 7%; top: calc(72% + 8px); color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
.hint {
position: absolute; left: 50%; top: 72%; translate: -50% calc(-100% - 8px); color: var(--text-muted);
font-size: var(--text-lg); line-height: var(--leading-body); white-space: nowrap;
}
.ink { position: absolute; inset: 0; pointer-events: none; }
.ink svg { display: block; width: 100%; height: 100%; overflow: visible; }
.stroke { fill: var(--ink-black); }
.stroke[data-color="blue"], .nib[data-color="blue"] { fill: var(--ink-blue); }
.stroke[data-color="violet"], .nib[data-color="violet"] { fill: var(--ink-violet); }
.nib { fill: var(--ink-black); opacity: .35; }
/* The squeegee edge that clears the ink as it crosses the pad. */
.sweep { position: absolute; inset: 0; pointer-events: none; }
.sweep::before {
content: ""; position: absolute; top: 8%; bottom: 8%; left: -1px; width: 2px; border-radius: 2px;
background: var(--foreground); opacity: .5;
}
.sweep::after {
content: ""; position: absolute; top: 0; bottom: 0; left: -28px; width: 28px;
background: linear-gradient(to right, transparent, color-mix(in oklab, var(--foreground) 5%, transparent));
}
.toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
.group { display: flex; align-items: center; gap: 2px; }
.divider { width: 1px; height: 20px; margin: 0 6px; background: var(--border); }
.choice { display: flex; align-items: center; gap: 2px; }
.option {
position: relative; display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 0; border-radius: var(--radius-pill);
background: transparent; color: var(--foreground); cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.selected { position: absolute; inset: 0; border-radius: inherit; background: var(--surface-muted); box-shadow: inset 0 0 0 1px var(--border); }
.swatch { position: relative; width: 16px; height: 16px; border-radius: 50%; background: var(--ink-black); }
.swatch[data-color="blue"] { background: var(--ink-blue); }
.swatch[data-color="violet"] { background: var(--ink-violet); }
.weight { position: relative; border-radius: 50%; background: var(--foreground); }
.iconButton {
display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 0; border-radius: var(--radius-pill);
background: transparent; color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent;
transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);
}
.iconButton:active:not(:disabled) { transform: scale(.94); }
.iconButton:disabled { color: var(--text-muted); opacity: .45; cursor: default; }
.iconButton[aria-pressed="true"] { background: var(--surface-muted); color: var(--foreground); }
.swap { display: grid; place-items: center; }
.exports { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
.exportLabel { margin-right: 4px; color: var(--text-muted); font-size: var(--text-xs); }
.export {
display: grid; min-width: 76px; height: var(--control-height-sm); padding: 0 12px; border: 1px solid var(--border); border-radius: var(--radius-control);
background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); cursor: pointer; -webkit-tap-highlight-color: transparent;
transition: background-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);
}
.export:active:not(:disabled) { transform: scale(.97); }
.export:disabled { color: var(--text-muted); cursor: default; }
.exportContent { display: inline-flex; grid-area: 1 / 1; align-items: center; justify-content: center; gap: 6px; }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (hover: hover) and (pointer: fine) {
.option:hover, .iconButton:hover:not(:disabled), .export:hover:not(:disabled) { background: var(--surface-muted); color: var(--foreground); }
}
signature-pad.tsxsignature-pad.tsxsignature-pad.tsxtsx
"use client";
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { AnimatePresence, animate, motion } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { Transition } from "motion/react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Download01Icon, EraserIcon, Redo02Icon, SquareIcon, Undo02Icon } from "@hugeicons/core-free-icons";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./signature-pad.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
/** A sampled point: position in pad units, pen pressure between 0 and 1 (or -1 when the device has none), and ms since the stroke began. */
export interface InkPoint { x: number; y: number; p: number; t: number }
export type InkColor = "black" | "blue" | "violet";
export type InkWidth = "fine" | "medium" | "bold";
export interface InkStroke {
id: string;
points: InkPoint[];
color: InkColor;
width: InkWidth;
/** When the stroke began, in ms. Replay keeps the real rhythm between strokes. */
at: number;
}
export interface OutlineOptions {
/** Diameter of the stroke at half pressure, in pad units. */
size: number;
/** How much pressure changes the width, from 0 to 1. */
thinning?: number;
/** How far each point trails the pointer, from 0 to 1. Higher is smoother but lags more. */
streamline?: number;
/** Derive pressure from speed when the device reports none: fast is thin, slow is full. */
simulatePressure?: boolean;
/** Length over which the start and the end taper, in pad units. */
taperStart?: number;
taperEnd?: number;
/** False while the stroke is still being drawn, so the end does not taper yet. */
last?: boolean;
}
/** Pad coordinates. Strokes are stored in this space and scale with the pad. */
export const PAD_WIDTH = 600, PAD_HEIGHT = 260;
const INK: Record<InkColor, { label: string; export: string }> = {
black: { label: "검정 잉크", export: "#17171a" },
blue: { label: "파랑 잉크", export: "#1d3fae" },
violet: { label: "보라 잉크", export: "#5b35c8" },
};
const WIDTHS: Record<InkWidth, { label: string; size: number }> = {
fine: { label: "가늘게", size: 3.4 },
medium: { label: "보통", size: 5.6 },
bold: { label: "굵게", size: 8.4 },
};
const COLORS = Object.keys(INK) as InkColor[];
const SIZES = Object.keys(WIDTHS) as InkWidth[];
type Vec = [number, number];
const add = (a: Vec, b: Vec): Vec => [a[0] + b[0], a[1] + b[1]];
const sub = (a: Vec, b: Vec): Vec => [a[0] - b[0], a[1] - b[1]];
const mul = (a: Vec, n: number): Vec => [a[0] * n, a[1] * n];
const lerp = (a: Vec, b: Vec, t: number): Vec => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
const dist = (a: Vec, b: Vec) => Math.hypot(a[0] - b[0], a[1] - b[1]);
const norm = (a: Vec): Vec => { const l = Math.hypot(a[0], a[1]); return l ? [a[0] / l, a[1] / l] : [0, 0]; };
const perp = (a: Vec): Vec => [a[1], -a[0]];
const dot = (a: Vec, b: Vec) => a[0] * b[0] + a[1] * b[1];
const rotAround = (p: Vec, c: Vec, r: number): Vec => {
const s = Math.sin(r), co = Math.cos(r), px = p[0] - c[0], py = p[1] - c[1];
return [px * co - py * s + c[0], px * s + py * co + c[1]];
};
const easeOut = (t: number) => t * (2 - t);
/**
* Turns sampled points into the outline of a variable width stroke, in the spirit of perfect-freehand. Points are streamlined
* toward the pointer, width follows pen pressure or, for a mouse or finger, speed, the ends taper, sharp turns get round joins,
* and both ends get round caps. Returns the outline polygon; pass it to `outlineToPath` for SVG.
*/
export function getStrokeOutline(input: InkPoint[], options: OutlineOptions): Vec[] {
const { size, thinning = .62, streamline = .42, simulatePressure = true, taperStart = 0, taperEnd = 0, last = true } = options;
const [head] = input;
if (!head) return [];
const usePen = !simulatePressure && input.some(point => point.p > 0);
// Streamline: each point eases toward the raw input, which removes jitter without a smoothing pass.
const pts: { at: Vec; pressure: number; length: number; vector: Vec }[] = [];
let prev: Vec = [head.x, head.y], length = 0;
const start = { at: prev, pressure: usePen ? head.p : .5, length: 0, vector: [1, 0] as Vec };
pts.push(start);
for (let i = 1; i < input.length; i++) {
const sample = input[i]!;
const raw: Vec = [sample.x, sample.y];
const at = i === input.length - 1 && last ? raw : lerp(prev, raw, 1 - streamline);
const d = dist(at, prev);
if (d < .4) continue;
length += d;
pts.push({ at, pressure: usePen ? sample.p : .5, length, vector: norm(sub(prev, at)) });
prev = at;
}
const second = pts[1];
if (second) start.vector = second.vector;
const total = length;
// A dot: a round mark sized by the first pressure.
if (pts.length === 1) {
const r = Math.max(.6, size * (.5 - thinning * (.5 - start.pressure)) * .9);
return Array.from({ length: 18 }, (_, i) => add(start.at, mul([Math.cos(i / 18 * Math.PI * 2), Math.sin(i / 18 * Math.PI * 2)], r)));
}
const left: Vec[] = [], right: Vec[] = [];
let pressure = start.pressure, prevVector = start.vector;
const radii: number[] = [];
for (let i = 0; i < pts.length; i++) {
const point = pts[i]!;
if (!usePen) {
// Speed thins the line: a long step between samples reads as a fast stroke.
const step = i === 0 ? 0 : dist(point.at, pts[i - 1]!.at);
const speed = Math.min(1, step / size);
const target = Math.min(1, 1 - speed);
pressure = Math.min(1, pressure + (target - pressure) * speed * .3);
} else {
pressure = point.pressure;
}
let radius = Math.max(.25, size * (.5 - thinning * (.5 - pressure)));
const ts = taperStart ? easeOut(Math.min(1, point.length / taperStart)) : 1;
const te = taperEnd && last ? easeOut(Math.min(1, (total - point.length) / taperEnd)) : 1;
radius = Math.max(.12 * size, radius * Math.min(ts, te));
radii.push(radius);
const next = pts[i + 1]?.vector ?? point.vector;
const offset = mul(perp(lerp(next, point.vector, .5)), radius);
// A sharp turn: sweep round the corner so the outline does not pinch.
if (i > 0 && i < pts.length - 1 && dot(point.vector, next) < -.2) {
const corner = mul(perp(prevVector), radius);
for (let t = 0; t <= 1; t += 1 / 12) {
left.push(rotAround(sub(point.at, corner), point.at, Math.PI * t));
right.push(rotAround(add(point.at, corner), point.at, -Math.PI * t));
}
} else {
left.push(sub(point.at, offset));
right.push(add(point.at, offset));
}
prevVector = point.vector;
}
// Round caps: half circles swept around the first and last points, on the side facing away from the stroke.
const cap = (center: Vec, from: Vec, to: Vec, away: Vec) => {
const out: Vec[] = [];
const r = dist(center, from), start = Math.atan2(from[1] - center[1], from[0] - center[0]);
const mid = start + Math.PI / 2;
const sign = dot([Math.cos(mid), Math.sin(mid)], away) >= 0 ? 1 : -1;
for (let k = 1; k < 12; k++) {
const a = start + sign * Math.PI * k / 12;
out.push([center[0] + Math.cos(a) * r, center[1] + Math.sin(a) * r]);
}
out.push(to);
return out;
};
const end = pts[pts.length - 1]!;
const endCap = cap(end.at, left[left.length - 1]!, right[right.length - 1]!, mul(end.vector, -1));
const startCap = cap(start.at, right[0]!, left[0]!, start.vector);
return [...left, ...endCap, ...right.reverse(), ...startCap];
}
const round = (value: number) => Math.round(value * 100) / 100;
/** Draws an outline as one smooth closed path, with quadratic curves through the midpoints of its edges. */
export function outlineToPath(points: Vec[]) {
if (points.length < 3) return "";
let d = `M${round(points[0]![0])} ${round(points[0]![1])} Q`;
for (let i = 0; i < points.length; i++) {
const [x0, y0] = points[i]!, [x1, y1] = points[(i + 1) % points.length]!;
d += `${round(x0)} ${round(y0)} ${round((x0 + x1) / 2)} ${round((y0 + y1) / 2)} `;
}
return `${d}Z`;
}
function strokeOptions(stroke: Pick<InkStroke, "width" | "points">, last: boolean): OutlineOptions {
const size = WIDTHS[stroke.width].size;
const pen = stroke.points.some(point => point.p > 0);
return { size, simulatePressure: !pen, taperStart: size * 1.5, taperEnd: pen ? 0 : size * 5, last };
}
/** The SVG path of a stroke, or of its first `upTo` ms while it is replayed. */
export function strokePath(stroke: Pick<InkStroke, "width" | "points">, upTo = Infinity) {
const all = stroke.points;
const done = upTo >= all[all.length - 1]!.t;
const points = done ? all : all.filter(point => point.t <= upTo);
return outlineToPath(getStrokeOutline(points, strokeOptions(stroke, done)));
}
/** Tight bounds of the ink, padded, for export. */
function inkBounds(strokes: InkStroke[]) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const stroke of strokes) for (const point of stroke.points) {
minX = Math.min(minX, point.x); minY = Math.min(minY, point.y); maxX = Math.max(maxX, point.x); maxY = Math.max(maxY, point.y);
}
const pad = 16;
return { x: Math.floor(minX - pad), y: Math.floor(minY - pad), width: Math.ceil(maxX - minX + pad * 2), height: Math.ceil(maxY - minY + pad * 2) };
}
/** A standalone SVG of the signature, cropped to the ink, with print ink colors. */
export function signatureToSvg(strokes: InkStroke[]) {
if (!strokes.length) return "";
const box = inkBounds(strokes);
const paths = strokes.map(stroke => `<path d="${strokePath(stroke)}" fill="${INK[stroke.color].export}"/>`).join("");
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${box.x} ${box.y} ${box.width} ${box.height}" width="${box.width}" height="${box.height}">${paths}</svg>`;
}
/** A transparent PNG of the signature, cropped to the ink, at `scale` times pad resolution. */
export function signatureToPng(strokes: InkStroke[], scale = 3): Promise<Blob> {
return new Promise((resolve, reject) => {
if (!strokes.length) return reject(new Error("저장할 서명이 없어요"));
const box = inkBounds(strokes);
const canvas = document.createElement("canvas");
canvas.width = box.width * scale; canvas.height = box.height * scale;
const ctx = canvas.getContext("2d");
if (!ctx) return reject(new Error("이 브라우저에서는 이미지로 저장할 수 없어요"));
ctx.scale(scale, scale);
ctx.translate(-box.x, -box.y);
for (const stroke of strokes) { ctx.fillStyle = INK[stroke.color].export; ctx.fill(new Path2D(strokePath(stroke))); }
canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error("저장하지 못했어요")), "image/png");
});
}
function download(blob: Blob, name: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url; link.download = name;
document.body.appendChild(link); link.click(); link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/**
* A signature field with real ink. Width follows pen pressure, or speed for a mouse or finger, and the ends taper like a nib
* lifting off. It keeps undo and redo, clears with a wipe across the pad, replays the signature in its original rhythm,
* and exports a cropped PNG or SVG. A "여기에 서명해 주세요" hint rests on the baseline until the first stroke.
* Keyboard: Mod+Z undoes, Shift+Mod+Z or Mod+Y redoes, and Delete clears.
*/
export interface SignaturePadProps {
/** Printed under the baseline, such as the signer's name. */
signer?: string;
/** Resting hint on the baseline before the first stroke. */
hint?: string;
defaultColor?: InkColor;
defaultWidth?: InkWidth;
/** Receives the strokes after every change. */
onChange?: (strokes: InkStroke[]) => void;
/** File name for exports, without extension. */
fileName?: string;
/** Accessible name of the drawing surface. */
label?: string;
className?: string;
}
/** `fresh` is the stroke just drawn: it is already on screen from the live path, so it appears without a fade. */
interface History { past: InkStroke[][]; present: InkStroke[]; future: InkStroke[][]; fresh: string | null }
type Saved = null | "png" | "svg" | "failed";
const { spring, duration, ease, blur } = motionTokens;
const subscribe = () => () => {};
function useReducedFlag() {
const hydrated = useSyncExternalStore(subscribe, () => true, () => false);
return !!useReducedMotion() && hydrated;
}
export function SignaturePad({ signer, hint = "여기에 서명해 주세요", defaultColor = "black", defaultWidth = "medium", onChange, fileName = "signature", label = "서명", className }: SignaturePadProps) {
const uid = useId().replace(/[^a-zA-Z0-9-]/g, "");
const reduced = useReducedFlag();
const [history, setHistory] = useState<History>({ past: [], present: [], future: [], fresh: null });
const [color, setColor] = useState<InkColor>(defaultColor);
const [width, setWidth] = useState<InkWidth>(defaultWidth);
const [drawing, setDrawing] = useState(false);
const [wiping, setWiping] = useState(false);
const [replay, setReplay] = useState<{ at: number; offsets: number[] } | null>(null);
const [saved, setSaved] = useState<Saved>(null);
const [status, setStatus] = useState("");
const padRef = useRef<HTMLDivElement>(null);
const inkRef = useRef<HTMLDivElement>(null);
const liveRef = useRef<SVGPathElement>(null);
const live = useRef<{ id: number; stroke: InkStroke; stamp: number } | null>(null);
const replayFrame = useRef(0);
const savedTimer = useRef(0);
const wipeAnimation = useRef<{ stop: () => void; complete: () => void } | null>(null);
const counter = useRef(0);
const onChangeRef = useRef(onChange);
useEffect(() => { onChangeRef.current = onChange; });
const strokes = history.present;
const empty = strokes.length === 0;
const replaying = replay !== null;
useEffect(() => { onChangeRef.current?.(strokes); }, [strokes]);
useEffect(() => () => { cancelAnimationFrame(replayFrame.current); window.clearTimeout(savedTimer.current); }, []);
const commit = useCallback((next: InkStroke[], fresh: string | null = null) => {
setHistory(current => ({ past: [...current.past, current.present].slice(-60), present: next, future: [], fresh }));
}, []);
const stopReplay = useCallback(() => { cancelAnimationFrame(replayFrame.current); setReplay(null); }, []);
/** A wipe in flight completes at once, so whatever comes next starts from a clear pad. */
const settleWipe = useCallback(() => { wipeAnimation.current?.complete(); }, []);
function toPad(event: { clientX: number; clientY: number }) {
const box = padRef.current!.getBoundingClientRect();
return { x: (event.clientX - box.left) / box.width * PAD_WIDTH, y: (event.clientY - box.top) / box.height * PAD_HEIGHT };
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
if (live.current || (event.pointerType === "mouse" && event.button !== 0)) return;
event.preventDefault();
settleWipe();
if (replaying) stopReplay();
padRef.current?.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId);
const { x, y } = toPad(event);
const now = event.nativeEvent.timeStamp;
counter.current += 1;
const pen = event.pointerType === "pen" && event.pressure > 0;
live.current = { id: event.pointerId, stamp: event.nativeEvent.timeStamp, stroke: { id: `${uid}-${counter.current}`, color, width, at: now, points: [{ x, y, p: pen ? event.pressure : -1, t: 0 }] } };
setDrawing(true);
paintLive();
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
const current = live.current;
if (!current || current.id !== event.pointerId) return;
const samples = typeof event.nativeEvent.getCoalescedEvents === "function" ? event.nativeEvent.getCoalescedEvents() : [];
const list = samples.length ? samples : [event.nativeEvent];
for (const sample of list) {
const { x, y } = toPad(sample);
const pen = sample.pointerType === "pen" && sample.pressure > 0;
current.stroke.points.push({ x, y, p: pen ? sample.pressure : -1, t: Math.max(current.stroke.points[current.stroke.points.length - 1]!.t, sample.timeStamp - current.stamp) });
}
paintLive();
}
function onPointerUp(event: ReactPointerEvent<HTMLDivElement>) {
const current = live.current;
if (!current || current.id !== event.pointerId) return;
live.current = null;
commit([...strokes, current.stroke], current.stroke.id);
liveRef.current?.setAttribute("d", "");
setDrawing(false);
setStatus(`선을 추가했어요. 서명에 ${strokes.length + 1}개가 있어요.`);
}
function paintLive() {
const current = live.current;
if (!current || !liveRef.current) return;
liveRef.current.setAttribute("d", outlineToPath(getStrokeOutline(current.stroke.points, strokeOptions(current.stroke, false))));
}
function undo() {
if (!history.past.length || live.current) return;
playUiSound("tick");
settleWipe(); stopReplay();
setHistory(current => current.past.length ? { past: current.past.slice(0, -1), present: current.past[current.past.length - 1]!, future: [current.present, ...current.future], fresh: null } : current);
setStatus("되돌렸어요");
}
function redo() {
if (!history.future.length || live.current) return;
playUiSound("tick");
settleWipe(); stopReplay();
setHistory(current => current.future.length ? { past: [...current.past, current.present], present: current.future[0]!, future: current.future.slice(1), fresh: null } : current);
setStatus("다시 실행했어요");
}
function clear() {
if (empty || wiping || live.current) return;
playUiSound("click");
stopReplay();
const node = inkRef.current;
const finish = () => {
wipeAnimation.current = null;
commit([]); setWiping(false); setStatus("서명을 지웠어요. 되돌리기로 다시 가져올 수 있어요.");
// Lift the clip only once the wiped strokes have left, so they never flash back for a frame.
if (node) requestAnimationFrame(() => requestAnimationFrame(() => { node.style.clipPath = ""; }));
};
if (!node || reduced) { finish(); return; }
setWiping(true);
// The ink is cut away behind a squeegee edge that sweeps across the pad; the edge itself is drawn by the sweep element.
const controls = animate(node, { clipPath: ["inset(0% 0% 0% 0%)", "inset(0% 0% 0% 100%)"] }, { duration: .56, ease: [...ease.inOut] });
wipeAnimation.current = { stop: () => controls.stop(), complete: () => { controls.complete(); } };
controls.then(finish);
}
function toggleReplay() {
if (replaying) { playUiSound("click"); stopReplay(); setStatus("다시 보기를 멈췄어요"); return; }
if (empty || live.current) return;
playUiSound("click");
settleWipe();
// Build a timeline: each stroke keeps its own rhythm, and pauses between strokes are capped so the replay never stalls.
const offsets: number[] = [];
let cursor = 0;
strokes.forEach((stroke, index) => {
if (index > 0) {
const previous = strokes[index - 1]!;
const gap = stroke.at - (previous.at + previous.points[previous.points.length - 1]!.t);
cursor += Math.min(260, Math.max(90, gap));
}
offsets.push(cursor);
cursor += stroke.points[stroke.points.length - 1]!.t;
});
const speed = Math.max(1, cursor / 3200);
const total = cursor + 120;
const begin = performance.now();
const tick = (now: number) => {
const at = (now - begin) * speed;
if (at >= total) { setReplay(null); setStatus("다시 보기가 끝났어요"); return; }
setReplay({ at, offsets });
replayFrame.current = requestAnimationFrame(tick);
};
setReplay({ at: 0, offsets });
setStatus("서명을 다시 보여 주고 있어요");
replayFrame.current = requestAnimationFrame(tick);
}
async function save(kind: "png" | "svg") {
if (empty) return;
window.clearTimeout(savedTimer.current);
try {
const blob = kind === "svg" ? new Blob([signatureToSvg(strokes)], { type: "image/svg+xml" }) : await signatureToPng(strokes);
download(blob, `${fileName}.${kind}`);
playUiSound("success");
setSaved(kind);
setStatus(`${fileName}.${kind}로 저장했어요`);
} catch {
playUiSound("error");
setSaved("failed");
setStatus("저장하지 못했어요. 다시 해 볼까요?");
}
savedTimer.current = window.setTimeout(() => setSaved(null), 1800);
}
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
const mod = event.metaKey || event.ctrlKey;
const key = event.key.toLowerCase();
if (mod && key === "z") { event.preventDefault(); if (event.shiftKey) redo(); else undo(); }
else if (mod && key === "y") { event.preventDefault(); redo(); }
else if (!mod && (event.key === "Delete" || event.key === "Backspace") && event.currentTarget === event.target) { event.preventDefault(); clear(); }
else if (event.key === "Escape" && replaying) { stopReplay(); }
}
// During replay each stroke shows only the part drawn so far, and the nib rides its newest point.
let nib: InkPoint | null = null, nibColor: InkColor = color;
const visible: { stroke: InkStroke; d: string | null }[] = [];
for (let index = 0; index < strokes.length; index++) {
const stroke = strokes[index]!;
if (replay === null) { visible.push({ stroke, d: null }); continue; }
const local = replay.at - (replay.offsets[index] ?? 0);
const lastT = stroke.points[stroke.points.length - 1]!.t;
if (local < 0) { visible.push({ stroke, d: "" }); continue; }
if (local >= lastT) { visible.push({ stroke, d: null }); continue; }
nib = stroke.points.findLast(point => point.t <= local) ?? stroke.points[0]!;
nibColor = stroke.color;
visible.push({ stroke, d: strokePath(stroke, local) });
}
const showHint = empty && !drawing && !wiping;
const hintHidden = reduced ? { opacity: 0 } : { opacity: 0, y: 6, scale: .97, filter: `blur(${blur.soft}px)` };
const t: Transition = reduced ? { duration: duration.instant } : spring.snappy;
return <div className={[styles.root, className].filter(Boolean).join(" ")} data-ink={color}>
<div ref={padRef} className={styles.pad} tabIndex={0} role="img" aria-roledescription="서명 입력란"
aria-label={`${label}. ${empty ? "비어 있어요." : `${strokes.length}획.`} 마우스, 펜, 손가락으로 그려요.`}
aria-describedby={`${uid}-keys`} data-drawing={drawing ? "" : undefined}
onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onKeyDown={onKeyDown}>
<div className={styles.guide} aria-hidden="true">
<UiIcon iconRole="close" className={styles.mark} size={14} strokeWidth={1.75} />
<span className={styles.baseline} />
{signer && <span className={styles.signer}>{signer}</span>}
<AnimatePresence initial={false}>
{showHint && <motion.span key="hint" className={styles.hint} initial={hintHidden} animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
exit={{ ...hintHidden, transition: { duration: duration.fast, ease: [...ease.standard] } }}
transition={reduced ? { duration: duration.instant } : { duration: duration.standard, ease: [...ease.enter] }}>{hint}</motion.span>}
</AnimatePresence>
</div>
<div ref={inkRef} className={styles.ink}>
<svg viewBox={`0 0 ${PAD_WIDTH} ${PAD_HEIGHT}`} preserveAspectRatio="none" aria-hidden="true">
<AnimatePresence initial={false}>
{visible.map(({ stroke, d }) => <motion.path key={stroke.id} className={styles.stroke} data-color={stroke.color}
d={d ?? strokePath(stroke)} initial={history.fresh === stroke.id ? false : { opacity: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: wiping ? 0 : duration.standard, ease: [...ease.standard] } }}
transition={{ duration: duration.standard, ease: [...ease.standard] }} />)}
</AnimatePresence>
<path ref={liveRef} className={styles.stroke} data-color={color} />
{nib && <circle className={styles.nib} data-color={nibColor} cx={nib.x} cy={nib.y} r={WIDTHS[width].size * .9} />}
</svg>
</div>
{wiping && <motion.span className={styles.sweep} aria-hidden="true" initial={{ x: "0%", opacity: 0 }} animate={{ x: "100%", opacity: [0, 1, 1, 0] }}
transition={{ duration: .56, ease: [...ease.inOut], opacity: { duration: .56, times: [0, .12, .85, 1] } }} />}
</div>
<div className={styles.toolbar}>
<div className={styles.group}>
<Choice label="잉크 색" options={COLORS} value={color} onChange={next => { playUiSound("select"); setColor(next); setStatus(`${INK[next].label}을 골랐어요`); }} layoutId={`${uid}-color`} reduced={reduced}
render={option => <span className={styles.swatch} data-color={option} />} name={option => INK[option].label} />
<span className={styles.divider} aria-hidden="true" />
<Choice label="선 굵기" options={SIZES} value={width} onChange={next => { playUiSound("select"); setWidth(next); setStatus(`선 굵기를 ${WIDTHS[next].label} 골랐어요`); }} layoutId={`${uid}-width`} reduced={reduced}
render={option => <span className={styles.weight} style={{ width: WIDTHS[option].size + 2, height: WIDTHS[option].size + 2 }} />} name={option => WIDTHS[option].label} />
</div>
<div className={styles.group}>
<IconButton label="되돌리기" onClick={undo} disabled={!history.past.length || wiping}><HugeiconsIcon icon={Undo02Icon} size={18} strokeWidth={1.75} aria-hidden="true" /></IconButton>
<IconButton label="다시 실행하기" onClick={redo} disabled={!history.future.length || wiping}><HugeiconsIcon icon={Redo02Icon} size={18} strokeWidth={1.75} aria-hidden="true" /></IconButton>
<IconButton label={replaying ? "다시 보기 멈추기" : "서명 다시 보기"} onClick={toggleReplay} disabled={empty || wiping} pressed={replaying}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={replaying ? "stop" : "play"} className={styles.swap} initial={{ opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: .6 }} transition={t}>
{replaying ? <HugeiconsIcon icon={SquareIcon} size={15} strokeWidth={1.75} aria-hidden="true" /> : <UiIcon iconRole="play" size={17} strokeWidth={1.75} aria-hidden="true" />}
</motion.span>
</AnimatePresence>
</IconButton>
<IconButton label="서명 지우기" onClick={clear} disabled={empty || wiping}><HugeiconsIcon icon={EraserIcon} size={18} strokeWidth={1.75} aria-hidden="true" /></IconButton>
</div>
</div>
<div className={styles.exports}>
<span className={styles.exportLabel}>저장 형식</span>
{(["png", "svg"] as const).map(kind => <button key={kind} type="button" className={styles.export} onClick={() => save(kind)} disabled={empty}
aria-label={`${kind.toUpperCase()} 다운로드하기`}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={saved === kind ? "done" : saved === "failed" ? "failed" : "idle"} className={styles.exportContent}
initial={{ opacity: 0, y: reduced ? 0 : 4, filter: reduced ? "blur(0px)" : `blur(${blur.subtle}px)` }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: reduced ? 0 : -4, transition: { duration: duration.instant } }} transition={reduced ? { duration: duration.instant } : { duration: duration.fast, ease: [...ease.enter] }}>
{saved === kind ? <><UiIcon iconRole="check" size={15} strokeWidth={1.75} aria-hidden="true" />저장했어요</> : <><HugeiconsIcon icon={Download01Icon} size={15} strokeWidth={1.75} aria-hidden="true" />{kind.toUpperCase()}</>}
</motion.span>
</AnimatePresence>
</button>)}
</div>
<p id={`${uid}-keys`} className={styles.srOnly}>Ctrl 또는 ⌘와 Z로 되돌리고, Shift를 함께 누르면 다시 실행해요. Delete로 서명을 지워요.</p>
<p className={styles.srOnly} role="status" aria-live="polite">{status}</p>
</div>;
}
function IconButton({ label, onClick, disabled, pressed, children }: { label: string; onClick: () => void; disabled?: boolean; pressed?: boolean; children: ReactNode }) {
return <button type="button" className={styles.iconButton} aria-label={label} title={label} onClick={onClick} disabled={disabled} aria-pressed={pressed}>{children}</button>;
}
/** A compact radio group with a highlight that glides to the chosen option. Arrow keys move the choice. */
function Choice<T extends string>({ label, options, value, onChange, render, name, layoutId, reduced }: {
label: string; options: T[]; value: T; onChange: (value: T) => void; render: (option: T) => ReactNode; name: (option: T) => string; layoutId: string; reduced: boolean;
}) {
const refs = useRef<(HTMLButtonElement | null)[]>([]);
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
if (!step) return;
event.preventDefault();
const index = (options.indexOf(value) + step + options.length) % options.length;
onChange(options[index]!);
refs.current[index]?.focus();
}
return <div className={styles.choice} role="radiogroup" aria-label={label} onKeyDown={onKeyDown}>
{options.map((option, index) => <button key={option} ref={node => { refs.current[index] = node; }} type="button" role="radio" aria-checked={option === value}
aria-label={name(option)} title={name(option)} tabIndex={option === value ? 0 : -1} className={styles.option} onClick={() => onChange(option)}>
{option === value && <motion.span layoutId={layoutId} className={styles.selected} transition={reduced ? { duration: 0 } : spring.morph} />}
{render(option)}
</button>)}
</div>;
}
export default SignaturePad;