@miracles-cn/react-signature-card
v0.20.2
Published
A React digital signature card component (手写签名卡片) with fixed ink, grid background and device detection, styled with styled-components.
Downloads
961
Maintainers
Readme
@miracles-cn/react-signature-card
手写签名卡片 React 组件 / A React digital signature card component — a white card with a fixed-black-ink signing area, grid background, clear button and input device badge (鼠标 / 触屏 / 手写板), as used in commitment-signing flows.
Built for React 17 with styled-components v6, written in strict TypeScript and developed with the Deno toolchain.
Features
- Composable API: arrange the toolbar, clear button, device badge and signing canvas freely — nothing is hard-wired into a fixed layout. Headers and hint lines live outside the component, in your markup
- Signature canvas with grid background and centered placeholder text
- Fixed black ink strokes via Pointer Events (mouse / touch / stylus)
- Device detection badge, showing
未检测until the first stroke - Undo/redo per stroke, with
canUndo/canRedostate for custom controls - Optional brush dynamics: strokes get bolder when drawing slowly (or under real stylus pressure) — off by default for fixed ink
- Export as PNG, JPEG or SVG (
exportToDataURL()on the ref and hook) onChangedelivers every format at once: PNG/JPEG/SVG data URLs plus the raw stroke matrix (path)- Live designer component: drag-and-drop, show/hide every widget, pick the toolbar's position relative to the canvas — and get the layout back as config
- The designed config drives
SignatureCarddirectly via itslayoutprop - Layout config also covers the button size and every built-in text — button labels, device badge and the canvas placeholder
- DPI-aware crisp rendering and resize-safe redraw
useSignatureCard()hook for building fully custom controls- Publishes ESM + CJS + TypeScript types
Breaking changes: 0.2.0 removed the built-in hint line and made the toolbar composable; 0.4.0 changed
onChangeto receive aSignatureExportResult(every format at once) instead of a single data URL; 0.13.0 removed the<SignatureCard.Header>part entirely — render headers outside of the component.
Install
npm install @miracles-cn/react-signature-cardPeer dependencies: react@^17 and styled-components@^6.
Usage
The root <SignatureCard> owns the signing engine (state, imperative ref) and
renders the white card; you compose the inside from its subcomponents. Headers
and hint lines are rendered outside of the card, in your own markup:
import React, { useRef } from "react";
import { SignatureCard } from "@miracles-cn/react-signature-card";
import type { SignatureCardRef } from "@miracles-cn/react-signature-card";
export function CommitmentSign() {
const ref = useRef<SignatureCardRef>(null);
return (
<section>
{/* Hint line and header are intentionally not part of the component. */}
<p style={{ margin: "0 0 8px", fontSize: 14 }}>
✍️ 请手动填写,签订后随承诺书一并反馈组织方
</p>
<p style={{ margin: "0 0 12px", fontSize: 15, fontWeight: 600 }}>
✍️ 请直接在签名区书写(固定黑色笔迹)
</p>
<SignatureCard
ref={ref}
onChange={(result) => {
// result is null right after clearing
if (result) console.log("signature png:", result.png);
}}
>
<SignatureCard.Toolbar>
<SignatureCard.ClearButton />
<SignatureCard.DeviceStatus />
</SignatureCard.Toolbar>
<SignatureCard.Canvas height={480} />
</SignatureCard>
</section>
);
}Because the layout is composed, rearranging is trivial — e.g. toolbar below the canvas:
<SignatureCard>
<SignatureCard.Canvas height={360} />
<SignatureCard.Toolbar style={{ marginTop: 12 }}>
<SignatureCard.DeviceStatus />
<SignatureCard.ClearButton />
</SignatureCard.Toolbar>
</SignatureCard>;Parts
| Part | Purpose | Props |
| ------------------------------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <SignatureCard> | White card frame + signing engine (state, ref) | penColor (#000000), penWidth (3), disabled (false), brush (false), layout (see the live designer), className, style, children, onSignStart, onChange, onClear |
| <SignatureCard.Toolbar> | Flex row, space-between | children, className, style |
| <SignatureCard.ClearButton> | Button wired to clear() | children (default 🗑️ 清空重签), className, style |
| <SignatureCard.UndoButton> | Button wired to undo(); disabled when there is nothing to undo | children (default ↶ 撤销), className, style |
| <SignatureCard.RedoButton> | Button wired to redo(); disabled when there is nothing to redo | children (default ↷ 重做), className, style |
| <SignatureCard.DeviceStatus> | 当前设备 badge | label (default 当前设备:), names (Partial<DeviceNames>, default { mouse: "鼠标", touch: "触屏", pen: "手写板", unknown: "未检测" }), className, style |
| <SignatureCard.Canvas> | Grid, input canvas, placeholder | height (480, numbers are px), grid (true, false or { size?: number, color?: string }, defaults 64 / #e4e9f0), placeholder (default 请在此区域签名(支持鼠标 / 触屏 / 手写板); null hides it), insetToolbar + insetSide (top/bottom/left/right) to dock a control inside the edge — the input surface shrinks around it so ink never runs beneath, className, style |
Callbacks on the root: onSignStart(device) with "mouse" | "touch" | "pen",
onChange(result) after each stroke, undo, redo, and clearing — result
carries every format (see below) and null means empty — and onClear().
Custom controls with useSignatureCard()
import { useSignatureCard } from "@miracles-cn/react-signature-card";
function MyClearButton() {
const { clear, hasInk, disabled } = useSignatureCard();
return (
<button onClick={clear} disabled={disabled || !hasInk}>
重写
</button>
);
}The hook returns
{ device, hasInk, canUndo, canRedo, disabled, clear, undo, redo, isEmpty, toDataURL, exportToDataURL, getResult }
and throws when used outside of <SignatureCard>.
Undo / redo
Every stroke is one history step. undo() removes the last stroke, redo()
re-applies it, and starting a new stroke drops the redo history. Both fire
onChange like any other change:
function HistoryButtons() {
const { undo, redo, canUndo, canRedo } = useSignatureCard();
return (
<>
<button onClick={() => undo()} disabled={!canUndo}>↶</button>
<button onClick={() => redo()} disabled={!canRedo}>↷</button>
</>
);
}or use the ready-made <SignatureCard.UndoButton /> /
<SignatureCard.RedoButton />. Formal signing flows that only want 清空重签 can
simply leave them out.
Exporting
Both the ref and useSignatureCard() expose exportToDataURL():
const png = ref.current?.exportToDataURL(); // transparent PNG
const jpg = ref.current?.exportToDataURL({ format: "jpeg", quality: 0.8 });
const svg = ref.current?.exportToDataURL({ format: "svg" }); // true vector
const onBlue = ref.current?.exportToDataURL({ background: "#e6f4ff" });| Option | Default | Notes |
| ------------ | ----------------------------------------- | --------------------------------------------------------------- |
| format | "png" | "png", "jpeg" or "svg" |
| quality | browser default (0.92) | JPEG only |
| background | white for "jpeg", transparent otherwise | any CSS color; the strokes themselves are transparent by design |
JPEG has no alpha channel — a naive canvas export would turn the transparent
background black, so white is composited in unless you pass another
background. SVG is serialized from the recorded strokes (viewBox in CSS
pixels), so it is real vector data and stays sharp at any size.
toDataURL(type?, quality?) remains available for compatibility.
Everything at once
onChange and getResult() hand you all formats in a single object:
<SignatureCard
onChange={(result) => {
if (!result) return; // cleared
console.log(result.png); // transparent PNG data URL
console.log(result.jpeg); // JPEG data URL (white background)
console.log(result.svg); // vector SVG data URL
console.log(result.path); // stroke matrix: Point[][]
}}
/>;
const result = ref.current?.getResult();result.path is the recorded stroke data — one array of points per stroke, in
canvas coordinates:
[
[{ "x": 10, "y": 20, "p": 1.2 }, { "x": 12, "y": 22, "p": 1.1 }],
[{ "x": 40, "y": 8 }]
]Convert to plain number pairs when your backend prefers them:
result.path.map((stroke) => stroke.map((p) => [p.x, p.y])).
Live designer
SignatureCardDesigner is a standalone component that turns the rendered card
itself into the editor — no side panel:
- Drag-and-drop on the card: drag any part directly — no handles. The card's own buttons are inert previews in the designer (they show their real state but can't fire). Press and move reorders: a badge naming the part follows the cursor, the source dims, and a blue insertion bar marks the landing spot along the toolbar's item axis. Press and HOLD instead and the part dyes with blue first, then the whole toolbar behind it — the dyed level decides what the drag moves (a button, or the toolbar itself, which then lands on one of the eight labeled drop zones around the canvas). Press Esc to cancel. Driven by pointer events, so mouse and touch both work.
- Show/hide with the corner eye: every toolbar button carries a small eye
badge pinned to its top-right corner — faint at rest, full strength on a
hovered part, and an eye-off at full strength on a hidden (dimmed) part. One
click toggles it; the hit area is larger than the badge looks. There is no
toolbar-level toggle: hiding every button (the invisible spacer doesn't count)
is equivalent to hiding the toolbar — the card then renders as if
hiddenBlockscontained"toolbar". A config saved by an older version with a hidden toolbar shows a restore-only eye-off instead. - Reposition the toolbar by dragging it: while the toolbar is dragged, eight
labeled drop zones ring the canvas — bands just inside its edges land the
floating
-insetplacements, strips just outside its edges the dockedtop/bottom/left/rightones (the side placements render the toolbar items vertically). Dropping on the canvas middle or outside the zones cancels. - Hide with a deleted look: hiding doesn't remove a widget — in the designer
it stays in place, dimmed and grayscale, until restored. Nothing disappears,
so nothing can be covered; the exported config still records it in
hiddenBlocks/hiddenButtons. - The canvas is the anchor: it cannot be dragged or hidden, but it stays a drop target so the toolbar can be arranged around it.
- Direct resize: drag the blue edge at the bottom of the signature area.
import { SignatureCardDesigner } from "@miracles-cn/react-signature-card";
<SignatureCardDesigner
cardProps={{ brush: true }}
onConfigChange={(config) =>
localStorage.setItem("sig-layout", JSON.stringify(config))}
/>;The layout (SignatureLayoutConfig, delivered by onConfigChange) is
JSON-serializable; pass a saved one back via defaultConfig:
{
"blocks": ["toolbar", "canvas"],
"hiddenBlocks": [],
"buttons": ["undo", "redo", "clear", "spacer", "device"],
"hiddenButtons": [],
"toolbarPosition": "top",
"parts": {
"toolbar": {
"buttonSize": 32,
"texts": {
"undo": "↶ 撤销",
"redo": "↷ 重做",
"clear": "🗑️ 清空重签",
"deviceLabel": "当前设备:",
"deviceNames": {
"mouse": "鼠标",
"touch": "触屏",
"pen": "手写板",
"unknown": "未检测"
}
}
},
"canvas": {
"height": 480,
"grid": true,
"placeholder": "请在此区域签名(支持鼠标 / 触屏 / 手写板)"
}
}
}toolbarPosition (optional) is one of "top", "bottom", "left", "right",
"top-inset", "bottom-inset", "left-inset", "right-inset": the plain
sides reserve a row/column next to the canvas, the -inset variants float the
toolbar just inside the canvas edge (purely visual — the input surface shrinks
around the floating toolbar, so ink never runs beneath it), and left/right
render the toolbar items vertically. When it is omitted the blocks order
decides (toolbar before the canvas = on top).
parts (optional) customizes each built-in part's props in layout mode — every
field falls back to the built-in default when omitted:
parts.toolbarmirrors the toolbar component's props (minuschildren, which the config'sbuttons/hiddenButtonscompose):buttonSizeis the buttons' height in pixels (the font size and horizontal padding scale with it from the built-in 32px look),textscustomizes the undo / redo / clear labels and the device badge (deviceLabelplus thedeviceNamesmap, merged over the defaults),textStylestyles the toolbar texts (font size / color / family — applied inline, so it takes over from the built-in 14px /#333look, hover color swap included), andclassName/styleland on the toolbar itself (the style merges over the position-derived one).parts.canvasmirrors the canvas component's props (minusinsetToolbar/insetSide, which stay owned bytoolbarPosition):heightsets the signature area's height,gridtoggles or styles the background grid,placeholdercustomizes the empty-state copy (nullhides it),placeholderStylestyles it (font size / color / ... — overrides the built-in 20px /#bfbfbf), andclassName/styleland on the signing area.
The same buttonSize / texts / textStyle are also real props of the
SignatureCard.Toolbar component, so classic (non-layout) compositions can
scope them to the buttons they compose inside it — each child still wins with
its own props.
The "spacer" toolbar item is a flexible blank placeholder: widgets before it
stay at the toolbar's start and widgets after it are pushed to its end. Move it
around (or add several) to control where the split happens — in vertical
(left/right) toolbars the split is along the column.
Notes: the designer is uncontrolled after mount. Dragging is driven by pointer events (no HTML5 drag), so it also works on touch devices.
Applying a designed layout
SignatureCard understands the config directly through its layout prop — see
the layout row in the Parts table above.
An empty config falls back to the classic layout, deleted widgets are skipped,
and the canvas is always rendered — the toolbar arranges itself around it
according to toolbarPosition (or the blocks order when that field is
absent).
Brush width dynamics
By default the ink is fixed-width (固定黑色笔迹). Enable brush for the
handwriting effect where strokes get bolder when drawn slowly and taper when
drawn fast:
<SignatureCard brush /> // sensible defaults
<SignatureCard brush={{ minScale: 0.5, maxScale: 2.2 }} /> // wider dynamics| BrushOptions | Default | Notes |
| -------------------- | ------- | --------------------------------------------------- |
| minScale | 0.65 | Width scale for the fastest strokes |
| maxScale | 1.8 | Width scale for the slowest strokes / full pressure |
| usePointerPressure | true | Use real stylus pressure when the device reports it |
About pressure: a finger on a capacitive touchscreen cannot measure force —
browsers report a constant PointerEvent.pressure for touch, so for fingers and
mice the effect is simulated from drawing speed. Real pressure is used
automatically when a pressure-capable stylus (Apple Pencil, Wacom, Surface Pen,
…) is the active pointer.
The per-point scale is preserved in result.path as p (1 = penWidth,
rendered width = penWidth × p), so custom renderers can reproduce the exact
line. SVG approximates each stroke's dynamics as its average width, because SVG
cannot vary stroke-width within one path.
Ref API
const ref = useRef<SignatureCardRef>(null);
ref.current?.clear(); // 清空重签
ref.current?.isEmpty(); // true before the first stroke
const png = ref.current?.toDataURL(); // string | null
const jpeg = ref.current?.toDataURL("image/jpeg", 0.8);
const svg = ref.current?.exportToDataURL({ format: "svg" });
const all = ref.current?.getResult(); // SignatureExportResult | null
const ok = ref.current?.undo(); // true if a stroke was removed
const redone = ref.current?.redo(); // true if a stroke was re-applied
ref.current?.canUndo(); // boolean
ref.current?.canRedo(); // booleanDevelopment
Requires Deno 2+. The npm-side tooling (Storybook) uses an
npm-managed node_modules/ — run npm install once.
deno task fmt # format with deno fmt
deno task lint # deno lint
deno task check # strict TypeScript type check
deno task test # run tests
deno task build # build the npm package into ./npm
deno task storybook # Storybook dev server on http://localhost:6006Local demo
A minimal browser playground lives in demo/:
deno run -A npm:[email protected] demo/main.tsx --bundle --outfile=demo/bundle.js
# then serve the demo/ folder with any static file serverPublishing
Releases are cut by .github/workflows/release.yml
through OIDC trusted publishing — no NPM_TOKEN exists anywhere. npm
authenticates with a short-lived, package-scoped token minted for that one
workflow run, and the npm-publish environment is restricted to main and
v* tags.
Bump version in deno.json (the npm build reads name and version from there),
commit, then push a matching tag:
git commit -am "chore(release): 0.21.0"
git tag v0.21.0 && git push origin v0.21.0The workflow verifies the tag against deno.json, runs
fmt --check / lint / check / test, checks the generated manifest and the
tarball contents, then publishes. A manual run (Actions → Release → Run
workflow) is a dry run unless confirm is set to
publish @miracles-cn/react-signature-card@<version>.
No provenance attestation is generated: npm does not support provenance from a
private source repository. See RELEASE.md for the one-time
npm trust github setup, the remaining hardening steps, and local dry-run /
break-glass tasks:
deno task publish:npm:dry # build + inspect exactly what would be packed