Engraved Numerals
Prices, dates and years cut in stroke by stroke: a gold hairline traces every character's outline in a Didone, then the figure fills with ink as the line fades.
- Category
- Text Effects
- Added
- 2026-09-26
- Deps
- none
- Updated
- 2026-09-26
Props
"use client";
import { useEffect, useRef, useState } from "react";
/**
* EngravedNumerals — a price, a date or a year cut in, stroke by stroke.
*
* The figure is set as SVG text in a Didone. When it comes into view a
* one-pixel line in the metal traces the outline of every character, the
* way an engraver's burin follows the letter, each character a beat after
* the one before. As each outline closes the character fills with ink, and
* the gold line fades away (or stays, as a gilded edge, with keepStroke).
* Because the dash pattern restarts on every contour, counters and outer
* shapes are cut at the same time, as a hand would.
*
* Give it a string, or a number with an Intl format, or a year with roman
* set (1831 becomes MDCCCXXXI). The SVG is measured from the rendered text,
* so it takes exactly the figure's width at the given size and scales down
* to fit a narrower column. Screen readers get the plain value.
*
* Before the script runs the figure is hidden, and shown filled wherever
* scripting is off. Under prefers-reduced-motion it is simply shown.
* Type reads var(--font-didone) and falls back to system Didones, then
* Georgia. Needs Tailwind v4 (or v3.4+). No dependencies beyond React.
*/
const DIDONE = 'var(--font-didone, "Bodoni Moda", "Didot", "Bodoni 72", "Bodoni MT", Georgia, serif)';
const TEXT = 'var(--font-text, "Iowan Old Style", "Baskerville", "Libre Baskerville", Georgia, "Times New Roman", serif)';
const TONES = {
ivory: { ink: "#14120f", muted: "rgba(20,18,15,0.62)" },
charcoal: { ink: "#f4f1ea", muted: "rgba(244,241,234,0.62)" },
} as const;
const HEX = /^#[0-9a-fA-F]{6}$/;
// Static rules only: nothing a prop controls is written into this sheet.
// Durations and delays arrive as custom properties on each character.
const CSS = [
"@keyframes en-cut { from { stroke-dashoffset: var(--en-l); } to { stroke-dashoffset: 0; } }",
"@keyframes en-fill { from { fill-opacity: 0; } to { fill-opacity: 1; } }",
"@keyframes en-fade { from { stroke-opacity: 1; } to { stroke-opacity: 0; } }",
".en-armed .en-g { fill-opacity: 0; stroke-dasharray: var(--en-l) var(--en-l); stroke-dashoffset: var(--en-l); }",
".en-play .en-g { stroke-dasharray: var(--en-l) var(--en-l); animation-name: en-cut, en-fill, en-fade;",
" animation-duration: var(--en-d), 900ms, 900ms; animation-timing-function: cubic-bezier(0.45,0,0.25,1), cubic-bezier(0.22,1,0.36,1), ease;",
" animation-fill-mode: both; animation-delay: var(--en-a), var(--en-b), var(--en-c); }",
".en-play.en-keep .en-g { animation-name: en-cut, en-fill; }",
"@media (scripting: none) { .en-armed .en-g { fill-opacity: 1; stroke-opacity: 0; } }",
"@media (prefers-reduced-motion: reduce) { .en-armed .en-g, .en-play .en-g { animation: none; fill-opacity: 1; stroke-dashoffset: 0; stroke-opacity: 0; } .en-keep .en-g { stroke-opacity: 1; } }",
].join("\n");
const ROMAN: [number, string][] = [
[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"],
[50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"],
];
function toRoman(n: number) {
let v = Math.round(n);
if (v < 1 || v > 3999) return String(n);
let out = "";
for (const [k, s] of ROMAN) {
while (v >= k) {
out += s;
v -= k;
}
}
return out;
}
/**
* True once the given share of the element is on screen, or as much of it as
* the view can hold. isIntersecting alone is true at the first visible pixel.
*/
function seen(e: IntersectionObserverEntry, share: number) {
if (!e.isIntersecting) return false;
const rootH = e.rootBounds ? e.rootBounds.height : window.innerHeight;
return e.intersectionRatio >= share - 0.01 || e.intersectionRect.height >= rootH * share - 1;
}
export type EngravedNumeralsProps = {
/** The figure: a string as it should read, or a number to format. */
value: string | number;
/** Intl options for a numeric value, e.g. { style: "currency", currency: "GBP", maximumFractionDigits: 0 }. */
format?: Intl.NumberFormatOptions;
/** Locale for format. Fixed by default so server and client agree. */
locale?: string;
/** Set a whole number as roman numerals (1 to 3999). */
roman?: boolean;
/** A small-capitals line above: "Sold for", "Established". */
label?: string;
/** An italic line below. */
caption?: string;
/** Font size of the figure in CSS pixels. It scales down to fit a narrower column. */
size?: number;
/** Milliseconds between one character's cut and the next. */
stagger?: number;
/** Milliseconds to cut one character's outline (1400 by default). */
duration?: number;
/** Keep the gold outline after the fill, as a gilded edge. */
keepStroke?: boolean;
/** Equal-width figures, for columns of prices that must align. Off, figures take their natural widths. */
tabular?: boolean;
/** "view" cuts the first time half of it is on screen; "mount" at once. */
trigger?: "view" | "mount";
align?: "left" | "center" | "right";
tone?: "ivory" | "charcoal";
/** The line's metal, as #rrggbb. */
metal?: string;
className?: string;
};
export function EngravedNumerals({
value,
format,
locale = "en-GB",
roman = false,
label,
caption,
size = 112,
stagger = 90,
duration = 1400,
keepStroke = false,
tabular = false,
trigger = "view",
align = "center",
tone = "ivory",
metal = "#b8964f",
className = "",
}: EngravedNumeralsProps) {
const t = tone === "charcoal" ? TONES.charcoal : TONES.ivory;
const gold = HEX.test(metal) ? metal : "#b8964f";
const fs = Math.max(12, Math.min(400, size));
const text =
typeof value === "number"
? roman
? toRoman(value)
: new Intl.NumberFormat(locale, format).format(value)
: value;
const chars = Array.from(text);
const rootRef = useRef<HTMLElement>(null);
const textRef = useRef<SVGTextElement>(null);
const [phase, setPhase] = useState<"armed" | "play">("armed");
// An estimate for the server and the first paint, replaced by a measure.
const [box, setBox] = useState({ x: 0, y: -fs * 0.82, w: chars.length * fs * 0.62, h: fs * 1.06 });
// Measure the rendered text once the font is in, and whenever it changes.
useEffect(() => {
const el = textRef.current;
if (!el) return;
let alive = true;
const measure = () => {
if (!alive) return;
try {
const b = el.getBBox();
if (b.width > 0) setBox({ x: b.x, y: b.y, w: b.width, h: b.height });
} catch {
/* not rendered yet */
}
};
Promise.resolve().then(measure);
if (document.fonts) document.fonts.ready.then(measure);
return () => {
alive = false;
};
}, [text, fs, tabular]);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
if (trigger === "mount" || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
Promise.resolve().then(() => setPhase("play"));
return;
}
const io = new IntersectionObserver(
(entries) => {
if (seen(entries[entries.length - 1], 0.5)) {
setPhase("play");
io.disconnect();
}
},
{ threshold: 0.5 },
);
io.observe(root);
return () => io.disconnect();
}, [trigger]);
const pad = Math.max(2, fs * 0.03);
const vb = [box.x - pad, box.y - pad, box.w + pad * 2, box.h + pad * 2];
const d = Math.max(200, duration);
const s = Math.max(0, stagger);
return (
<figure
ref={rootRef}
className={"m-0 flex flex-col " + (align === "left" ? "items-start text-left" : align === "right" ? "items-end text-right" : "items-center text-center") + " " + className}
style={{ color: t.ink }}
>
<style>{CSS}</style>
{label && (
<span className="mb-4 block text-[11px] uppercase tracking-[0.22em]" style={{ fontFamily: DIDONE, color: t.muted }}>
{label}
</span>
)}
<span className="sr-only">{text}</span>
<svg
aria-hidden
width={vb[2]}
height={vb[3]}
viewBox={vb.map((n) => n.toFixed(2)).join(" ")}
className={(phase === "play" ? "en-play" : "en-armed") + (keepStroke ? " en-keep" : "")}
style={{ maxWidth: "100%", height: "auto", overflow: "visible" }}
>
<text
key={text}
ref={textRef}
x={0}
y={0}
fontSize={fs}
fill={t.ink}
stroke={gold}
strokeWidth={Math.max(0.6, fs / 140)}
strokeLinejoin="round"
style={{ fontFamily: DIDONE, fontVariantNumeric: tabular ? "lining-nums tabular-nums" : "lining-nums proportional-nums" }}
>
{chars.map((c, i) => {
const a = i * s;
const v: Record<string, string> = {
// Longer than any one contour at this size, so each closes.
"--en-l": (fs * 6).toFixed(0) + "px",
"--en-d": d + "ms",
"--en-a": a + "ms",
"--en-b": Math.round(a + d * 0.72) + "ms",
"--en-c": Math.round(a + d + 500) + "ms",
};
return (
<tspan key={i} className="en-g" style={v}>
{c}
</tspan>
);
})}
</text>
</svg>
{caption && (
<figcaption className="mt-4 text-[15px] italic" style={{ fontFamily: TEXT, color: t.muted }}>
{caption}
</figcaption>
)}
</figure>
);
}
About this text effect
Auction results, founding dates and prices are the figures heritage houses set largest, and this effect cuts them in rather than fading them up. The figure is SVG text in a Didone. When half of it is on screen, a one-pixel line in the metal traces the outline of every character, the way a burin follows a letter, each character a beat after the last. As each outline closes, the character fills with ink and the gold line fades, or stays as a gilded edge if you keep it. Because the dash pattern restarts on every contour, the counters of an 8 and its outer shape are cut together, as a hand would. Pass a string, a number with an Intl format, or a year to be set in roman numerals. The SVG is measured from the rendered text once the font has loaded, so it is exactly as wide as the figure and scales down in a narrow column, and screen readers get the plain value.
Curator’s note
Part of the Neoclassical kit, drop 3, phase 8: the text effect. Free: the canon's plan had it Pro, but by the tier rule (1.5) it is SVG text and CSS animation, not rendering tech, like the other drops' text effects. The form pattern moved up to Pro in its place, so the phase stays 2/2.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Hairline Arch Hero
heroesA centred Didone headline under a segmental arch drawn in one gold hairline: it rises from both bases, meets at a keystone, and catches a raking light under the pointer.
Engraved Rule Button
componentsA heritage button in spaced Didone capitals: on hover a gold hairline engraves itself under the label from the centre out. No fill, no scale, no spring.
Plinth Object Card
componentsAn object standing on one gold plinth line with a catalogue caption: lot number, Didone title, date, provenance and estimate. The line draws out on hover.