Skip to content
Corsair UI
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-signature

No 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

PropTypeDefault
text

What to write.

string—
font

URL of a TTF, OTF or WOFF font (not WOFF2), served from your site or with CORS.

string—
fontSize

Size in viewBox units; scale the SVG with CSS.

number72
ink / inkWidth

Fill behind the pen.

boolean / numbertrue / 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 };