Browse the docs
TextNo. 74 of 109
Text Signature
Writes any text in any font as a signature: outlines drawn by the pen, then inked.
Installation
pnpm dlx shadcn@latest add @corsair-ui/text-signatureNo setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.
- Also adds
- utilsuse-in-viewsignature
- npm
clsxopentype.jstailwind-merge
Examples
In a handwriting font
A script font, parsed in the browser on demand.
Finer pen, slower hand
API
| Prop | Type | Default |
|---|---|---|
textWhat to write. | string | — |
fontURL of a TTF, OTF or WOFF font (not WOFF2), served from your site or with CORS. | string | — |
fontSizeSize in viewBox units; scale the SVG with CSS. | number | 72 |
ink / inkWidthFill behind the pen. | boolean / number | true / fontSize × 0.18 |
…Timing, trigger and stroke props of Signature. | SignatureProps | — |
Accessibility
- It is role="img", named by the text unless you pass an aria-label.
- The font parser loads on demand, only on pages that use it. With prefers-reduced-motion it holds still and shows the finished state.
Compatibility
- Tailwind CSS
- 3.4 and 4, both checked in CI
- React
- 19
- Rendering
- Client component ("use client")
Source
1 file, installed as source you own
text-signature.tsxView on GitHub"use client";
import type { Font, Path } from "opentype.js";
import { useEffect, useState } from "react";
import { Signature, type SignatureProps } from "@/registry/default/ui/signature";
// One download and parse per font, however many signatures use it.
const fonts = new Map<string, Promise<Font>>();
function loadFont(url: string) {
let font = fonts.get(url);
if (!font) {
font = (async () => {
const [{ parse }, response] = await Promise.all([import("opentype.js"), fetch(url)]);
if (!response.ok) throw new Error(`Could not load the font at ${url} (${response.status}).`);
return parse(await response.arrayBuffer());
})();
// A failed download can be retried by the next signature that asks.
font.catch(() => fonts.delete(url));
fonts.set(url, font);
}
return font;
}
// Hundredths of a unit, and never in exponent notation at these sizes.
const round = (value: number) => String(Math.round(value * 100) / 100);
const point = (x: number, y: number) => `${round(x)} ${round(y)}`;
/**
* SVG path data for one glyph. Path#toPathData would do, except that in
* opentype.js 2.0 it writes NaN for coordinates a hair above a whole number,
* and browsers stop drawing a path at its first NaN.
*/
function pathData(glyph: Path) {
return glyph.commands
.map((command) => {
switch (command.type) {
case "M":
case "L":
return command.type + point(command.x, command.y);
case "Q":
return `Q${point(command.x1, command.y1)} ${point(command.x, command.y)}`;
case "C":
return `C${point(command.x1, command.y1)} ${point(command.x2, command.y2)} ${point(command.x, command.y)}`;
case "Z":
return "Z";
}
})
.join("");
}
/** One outline per glyph, in writing order, and a viewBox that fits them all. */
function lettering(font: Font, text: string, fontSize: number) {
const baseline = font.ascender * (fontSize / font.unitsPerEm);
const glyphs = font.getPaths(text, 0, baseline, fontSize, { kerning: true });
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const paths: string[] = [];
for (const glyph of glyphs) {
const d = pathData(glyph);
if (!d) continue;
const box = glyph.getBoundingBox();
minX = Math.min(minX, box.x1);
minY = Math.min(minY, box.y1);
maxX = Math.max(maxX, box.x2);
maxY = Math.max(maxY, box.y2);
paths.push(d);
}
if (paths.length === 0) return null;
// A margin keeps the pen's round caps and the ink inside the box.
const margin = fontSize * 0.08;
const viewBox = [minX - margin, minY - margin, maxX - minX + margin * 2, maxY - minY + margin * 2]
.map((value) => Math.round(value * 100) / 100)
.join(" ");
return { paths, viewBox };
}
interface TextSignatureProps extends Omit<SignatureProps, "paths" | "viewBox"> {
/** What to write. */
text: string;
/**
* URL of a TTF, OTF or WOFF font (not WOFF2, which opentype.js cannot
* read). Script and handwriting fonts look the part. It is fetched in the
* browser, so it has to be served from your site or allow CORS.
*/
font: string;
/** Size of the lettering in viewBox units; the SVG itself scales with CSS. */
fontSize?: number;
}
/**
* Writes any text in any font as a signature: the glyph outlines are drawn
* by the pen and filled with ink behind it. The font is parsed in the
* browser with opentype.js, loaded on demand so it only costs the pages that
* use it. The SVG is named by the text for screen readers, and until the
* font arrives it keeps its size and shows nothing.
*
* @example
* <TextSignature text="Anne Bonny" font="/fonts/handwriting.ttf" className="h-16 w-auto" />
*/
function TextSignature({
text,
font,
fontSize = 72,
ink = true,
strokeWidth = 1,
inkWidth,
"aria-label": label,
...props
}: TextSignatureProps) {
const [lettered, setLettered] = useState<{
key: string;
paths: string[];
viewBox: string;
} | null>(null);
const key = `${font}\u0000${fontSize}\u0000${text}`;
useEffect(() => {
let cancelled = false;
loadFont(font)
.then((parsed) => {
const result = lettering(parsed, text, fontSize);
if (!cancelled) setLettered(result ? { key, ...result } : null);
})
.catch((error: unknown) => {
if (cancelled) return;
setLettered(null);
console.error(error);
});
return () => {
cancelled = true;
};
}, [font, fontSize, text, key]);
const ready = lettered?.key === key ? lettered : null;
return (
<Signature
// A new outline set is a new drawing: remount so the pen starts over.
key={ready?.key ?? "blank"}
paths={ready?.paths ?? []}
viewBox={ready?.viewBox ?? `0 0 ${fontSize * Math.max(text.length, 1) * 0.5} ${fontSize}`}
ink={ink}
inkWidth={inkWidth ?? fontSize * 0.18}
strokeWidth={strokeWidth}
aria-label={label ?? text}
{...props}
/>
);
}
export { TextSignature, type TextSignatureProps };