"use client";
import * as React from "react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface TypeAxis {
/** Four-letter axis tag, e.g. "wght", "wdth", "slnt", "opsz". */
tag: string;
/** Human name shown on the slider, e.g. "Weight". */
name: string;
min: number;
max: number;
default: number;
/** Slider step; defaults to 1 for wide ranges and 0.1 for narrow ones. */
step?: number;
}
export interface TypeSpecimenProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
/** CSS font-family used to render the specimen (e.g. "var(--font-display)" or '"Inter", sans-serif'). */
fontFamily: string;
/** Display name, also used in the CSS snippet. Defaults to `fontFamily`. */
fontName?: string;
/** Variable axes, each rendered as a labelled slider. */
axes?: TypeAxis[];
/** Initial sample text (editable). */
sample?: string;
/** Initial font size in px. */
size?: number;
/** Initial line height (unitless). */
leading?: number;
/** Initial letter spacing in em. */
tracking?: number;
/** Show the size waterfall tab. */
showWaterfall?: boolean;
/** Show the character set tab. */
showGlyphs?: boolean;
/** Show the CSS snippet tab. */
showCss?: boolean;
/** Sizes for the waterfall, in px. */
waterfall?: number[];
/** Characters shown in the character set grid. */
characters?: string;
/** Slider, focus and highlight colour (any CSS colour). */
accent?: string;
}
type Tab = "waterfall" | "glyphs" | "css";
const DEFAULT_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789&@?!%#$€£¥(){}[]/*+=<>";
const DEFAULT_WATERFALL = [12, 16, 24, 36, 48, 64];
const round = (n: number, step = 0.01) => Number((Math.round(n / step) * step).toFixed(4));
const stepFor = (axis: TypeAxis) => axis.step ?? (axis.max - axis.min > 20 ? 1 : 0.1);
const codepoint = (ch: string) => `U+${(ch.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, "0")}`;
function Slider({
id,
label,
tag,
value,
min,
max,
step,
unit = "",
disabled,
onChange,
}: {
id: string;
label: string;
tag?: string;
value: number;
min: number;
max: number;
step: number;
unit?: string;
disabled?: boolean;
onChange: (value: number) => void;
}) {
const pct = ((value - min) / (max - min)) * 100;
const display = `${round(value, step < 1 ? Math.min(step, 0.01) : 1)}${unit}`;
return (
<div className={disabled ? "opacity-45" : ""}>
<div className="flex items-baseline justify-between gap-3">
<label htmlFor={id} className="flex items-baseline gap-2 text-[12px] text-zinc-700 dark:text-zinc-300">
{label}
{tag && <span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500">{tag}</span>}
</label>
<output htmlFor={id} className="font-mono text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400">
{display}
</output>
</div>
<input
id={id}
type="range"
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
aria-valuetext={display}
onChange={(e) => onChange(Number(e.currentTarget.value))}
style={{ ["--fill" as string]: `${Math.round(pct * 100) / 100}%` }}
className="ts-range mt-1 block w-full rounded-full outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ts-accent)] disabled:cursor-not-allowed"
/>
</div>
);
}
/**
* An interactive specimen for a variable font: an editable sample, a labelled slider per axis plus
* size, leading and tracking, a size waterfall, a character set with a magnifying loupe and the
* CSS for the current settings, ready to copy.
*/
export function TypeSpecimen({
fontFamily,
fontName,
axes = [],
sample = "Sphinx of black quartz, judge my vow",
size = 72,
leading = 1.05,
tracking = -0.02,
showWaterfall = true,
showGlyphs = true,
showCss = true,
waterfall = DEFAULT_WATERFALL,
characters = DEFAULT_CHARACTERS,
accent = "#FF7448",
className = "",
style,
...props
}: TypeSpecimenProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const id = (part: string) => `ts-${uid}-${part}`;
const axesKey = axes.map((a) => `${a.tag}:${a.min}:${a.max}:${a.default}`).join("|");
const axisDefaults = () => Object.fromEntries(axes.map((a) => [a.tag, a.default]));
const hasOpsz = axes.some((a) => a.tag === "opsz");
const [text, setText] = React.useState(sample);
const [fontSize, setFontSize] = React.useState(size);
const [lineHeight, setLineHeight] = React.useState(leading);
const [letterSpacing, setLetterSpacing] = React.useState(tracking);
const [axisValues, setAxisValues] = React.useState<Record<string, number>>(axisDefaults);
const [linkOpsz, setLinkOpsz] = React.useState(true);
const [tab, setTab] = React.useState<Tab | null>(null);
const [glyph, setGlyph] = React.useState(0);
const [copied, setCopied] = React.useState(false);
// Adopt new initial values when the props change (e.g. switching fonts), without an effect.
const [synced, setSynced] = React.useState({ sample, size, leading, tracking, axesKey });
if (synced.sample !== sample || synced.size !== size || synced.leading !== leading || synced.tracking !== tracking || synced.axesKey !== axesKey) {
setSynced({ sample, size, leading, tracking, axesKey });
if (synced.sample !== sample) setText(sample);
if (synced.size !== size) setFontSize(size);
if (synced.leading !== leading) setLineHeight(leading);
if (synced.tracking !== tracking) setLetterSpacing(tracking);
if (synced.axesKey !== axesKey) setAxisValues(axisDefaults());
}
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const gridRef = React.useRef<HTMLDivElement>(null);
const copyTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const tabs: { id: Tab; label: string }[] = [
...(showWaterfall ? [{ id: "waterfall" as const, label: "Waterfall" }] : []),
...(showGlyphs ? [{ id: "glyphs" as const, label: "Characters" }] : []),
...(showCss ? [{ id: "css" as const, label: "CSS" }] : []),
];
const activeTab = tabs.find((t) => t.id === tab)?.id ?? tabs[0]?.id ?? null;
/* -------------------------------- settings -------------------------------- */
const valueOf = (axis: TypeAxis) => {
if (axis.tag === "opsz" && linkOpsz) return Math.min(axis.max, Math.max(axis.min, Math.round(fontSize)));
return axisValues[axis.tag] ?? axis.default;
};
const variation = axes.map((a) => `"${a.tag}" ${round(valueOf(a), stepFor(a) < 1 ? 0.1 : 1)}`).join(", ");
const typeStyle: React.CSSProperties = {
fontFamily,
fontVariationSettings: variation || undefined,
// Optical sizing is driven explicitly through the opsz axis.
fontOpticalSizing: hasOpsz ? "none" : undefined,
};
const sampleStyle: React.CSSProperties = {
...typeStyle,
fontSize: `min(${fontSize}px, 15cqw)`,
lineHeight,
letterSpacing: `${letterSpacing}em`,
transition: reduceMotion ? "none" : "font-variation-settings 140ms ease-out, letter-spacing 140ms ease-out",
};
const family = fontName ? `"${fontName}", ${/mono/i.test(fontName) ? "monospace" : "sans-serif"}` : fontFamily;
const cssLines = [
`font-family: ${family};`,
`font-size: ${fontSize}px;`,
`line-height: ${round(lineHeight)};`,
`letter-spacing: ${round(letterSpacing)}em;`,
...(variation ? [`font-variation-settings: ${variation};`] : []),
];
const css = `.specimen {\n${cssLines.map((l) => ` ${l}`).join("\n")}\n}`;
const reset = () => {
setText(sample);
setFontSize(size);
setLineHeight(leading);
setLetterSpacing(tracking);
setAxisValues(axisDefaults());
setLinkOpsz(true);
};
const copy = async () => {
try {
await navigator.clipboard.writeText(css);
setCopied(true);
clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1600);
} catch {
setCopied(false);
}
};
React.useEffect(() => () => clearTimeout(copyTimer.current), []);
// Grow the sample field with its content (field-sizing is not available everywhere).
React.useLayoutEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
});
React.useEffect(() => {
const el = textareaRef.current;
if (!el) return;
const fit = () => {
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
};
const ro = new ResizeObserver(fit);
ro.observe(el);
document.fonts?.ready.then(fit);
return () => ro.disconnect();
}, []);
/* --------------------------------- glyphs --------------------------------- */
const glyphs = Array.from(characters);
const activeGlyph = Math.min(glyph, Math.max(0, glyphs.length - 1));
const onGlyphKey = (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
const grid = gridRef.current;
const columns = grid ? getComputedStyle(grid).gridTemplateColumns.split(" ").length : 8;
let next = index;
if (event.key === "ArrowRight") next = index + 1;
else if (event.key === "ArrowLeft") next = index - 1;
else if (event.key === "ArrowDown") next = index + columns;
else if (event.key === "ArrowUp") next = index - columns;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = glyphs.length - 1;
else return;
event.preventDefault();
next = Math.min(glyphs.length - 1, Math.max(0, next));
setGlyph(next);
grid?.querySelector<HTMLButtonElement>(`[data-index="${next}"]`)?.focus();
};
const onTabKey = (event: React.KeyboardEvent<HTMLButtonElement>) => {
const i = tabs.findIndex((t) => t.id === activeTab);
let next = -1;
if (event.key === "ArrowRight") next = (i + 1) % tabs.length;
else if (event.key === "ArrowLeft") next = (i - 1 + tabs.length) % tabs.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = tabs.length - 1;
if (next === -1) return;
event.preventDefault();
setTab(tabs[next].id);
document.getElementById(id(`tab-${tabs[next].id}`))?.focus();
};
/* ---------------------------------- render --------------------------------- */
const mono = "font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400";
return (
<div
{...props}
className={`@container w-full overflow-hidden rounded-2xl border border-zinc-900/10 bg-white text-zinc-950 [--ts-thumb:#ffffff] [--ts-track:rgba(24,24,27,0.12)] dark:border-white/10 dark:bg-zinc-950 dark:text-zinc-50 dark:[--ts-thumb:#18181b] dark:[--ts-track:rgba(255,255,255,0.14)] ${className}`}
style={{ ["--ts-accent" as string]: accent, ...style }}
>
<style>{`.ts-range{-webkit-appearance:none;appearance:none;height:18px;background:transparent;cursor:pointer}
.ts-range::-webkit-slider-runnable-track{height:2px;border-radius:2px;background:linear-gradient(to right,var(--ts-accent) var(--fill),var(--ts-track) var(--fill))}
.ts-range::-moz-range-track{height:2px;border-radius:2px;background:linear-gradient(to right,var(--ts-accent) var(--fill),var(--ts-track) var(--fill))}
.ts-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;margin-top:-6px;width:14px;height:14px;border-radius:999px;background:var(--ts-thumb);border:1.5px solid var(--ts-accent);box-shadow:0 1px 4px rgba(0,0,0,.18)}
.ts-range::-moz-range-thumb{width:12px;height:12px;border-radius:999px;background:var(--ts-thumb);border:1.5px solid var(--ts-accent);box-shadow:0 1px 4px rgba(0,0,0,.18)}`}</style>
{/* Header */}
<div className="flex items-center justify-between gap-4 border-b border-zinc-900/[0.07] px-5 py-3 dark:border-white/[0.07]">
<div className="flex min-w-0 items-baseline gap-3">
<span className="truncate text-[15px] font-semibold tracking-[-0.01em]" style={{ fontFamily }}>
{fontName ?? fontFamily}
</span>
<span className={`${mono} hidden @[420px]:inline`}>
{axes.length ? `Variable · ${axes.length} ${axes.length === 1 ? "axis" : "axes"}` : "Static"}
</span>
</div>
<button
type="button"
onClick={reset}
className={`${mono} shrink-0 rounded-full border border-zinc-900/15 px-3 py-1.5 outline-hidden transition-colors hover:border-zinc-900/40 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ts-accent)] dark:border-white/15 dark:hover:border-white/40 dark:hover:text-white`}
>
Reset
</button>
</div>
{/* Sample + controls */}
<div className="grid grid-cols-1 @[720px]:grid-cols-[minmax(0,1fr)_260px]">
<div className="min-w-0 px-5 py-6 @[720px]:px-7 @[720px]:py-8">
<label htmlFor={id("sample")} className="sr-only">
Sample text
</label>
<textarea
ref={textareaRef}
id={id("sample")}
rows={1}
value={text}
spellCheck={false}
onChange={(e) => setText(e.currentTarget.value)}
style={sampleStyle}
className="block w-full resize-none overflow-hidden bg-transparent text-zinc-950 caret-[var(--ts-accent)] outline-hidden [field-sizing:content] selection:bg-[color-mix(in_srgb,var(--ts-accent)_30%,transparent)] dark:text-zinc-50"
/>
<p className={`${mono} mt-4`} aria-hidden="true">
Click the text to edit
</p>
</div>
<div
role="group"
aria-label="Type settings"
className="space-y-4 border-t border-zinc-900/[0.07] px-5 py-5 @[720px]:border-l @[720px]:border-t-0 dark:border-white/[0.07]"
>
{axes.length > 0 && <div className={mono}>Axes</div>}
{axes.map((axis) => (
<div key={axis.tag}>
<Slider
id={id(`axis-${axis.tag}`)}
label={axis.name}
tag={axis.tag}
value={valueOf(axis)}
min={axis.min}
max={axis.max}
step={stepFor(axis)}
disabled={axis.tag === "opsz" && linkOpsz}
onChange={(v) => setAxisValues((prev) => ({ ...prev, [axis.tag]: v }))}
/>
{axis.tag === "opsz" && (
<label className="mt-1.5 flex cursor-pointer items-center gap-2 text-[11px] text-zinc-500 dark:text-zinc-400">
<input
type="checkbox"
checked={linkOpsz}
onChange={(e) => {
const on = e.currentTarget.checked;
if (!on) setAxisValues((prev) => ({ ...prev, opsz: valueOf(axis) }));
setLinkOpsz(on);
}}
className="size-3.5 rounded accent-[var(--ts-accent)] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ts-accent)]"
/>
Match optical size to font size
</label>
)}
</div>
))}
<div className={`${mono} ${axes.length ? "pt-2" : ""}`}>Setting</div>
<Slider id={id("size")} label="Size" value={fontSize} min={12} max={200} step={1} unit="px" onChange={setFontSize} />
<Slider id={id("leading")} label="Leading" value={lineHeight} min={0.7} max={2} step={0.01} onChange={setLineHeight} />
<Slider id={id("tracking")} label="Tracking" value={letterSpacing} min={-0.1} max={0.2} step={0.005} unit="em" onChange={setLetterSpacing} />
</div>
</div>
{/* Tabs: waterfall / characters / css */}
{activeTab && (
<div className="border-t border-zinc-900/[0.07] dark:border-white/[0.07]">
<div role="tablist" aria-label="Specimen views" className="flex gap-1 px-4 pt-3">
{tabs.map((t) => (
<button
key={t.id}
id={id(`tab-${t.id}`)}
type="button"
role="tab"
aria-selected={activeTab === t.id}
aria-controls={id(`panel-${t.id}`)}
tabIndex={activeTab === t.id ? 0 : -1}
onClick={() => setTab(t.id)}
onKeyDown={onTabKey}
className={`rounded-full px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.14em] outline-hidden transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ts-accent)] ${
activeTab === t.id
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-950"
: "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
}`}
>
{t.label}
</button>
))}
</div>
<div
id={id(`panel-${activeTab}`)}
role="tabpanel"
aria-labelledby={id(`tab-${activeTab}`)}
tabIndex={activeTab === "waterfall" ? 0 : -1}
className="px-5 pb-5 pt-4 outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ts-accent)]"
>
{activeTab === "waterfall" && (
<ul className="space-y-2">
{waterfall.map((px) => (
<li key={px} className="flex items-baseline gap-4 border-b border-zinc-900/[0.05] pb-2 last:border-0 dark:border-white/[0.05]">
<span className={`${mono} w-10 shrink-0 tabular-nums`}>{px}px</span>
<span
className="min-w-0 flex-1 truncate"
style={{ ...typeStyle, fontSize: px, lineHeight: 1.2, letterSpacing: `${letterSpacing}em` }}
>
{text.split("\n")[0] || sample}
</span>
</li>
))}
</ul>
)}
{activeTab === "glyphs" && (
<div className="grid grid-cols-1 gap-4 @[560px]:grid-cols-[150px_minmax(0,1fr)]">
{/* Loupe: the hovered / focused / selected glyph, magnified. */}
<div
aria-live="polite"
className="flex items-center gap-4 rounded-xl border border-zinc-900/10 p-3 @[560px]:flex-col @[560px]:items-stretch dark:border-white/10"
>
<div
aria-hidden="true"
className="relative grid aspect-square w-20 shrink-0 place-items-center overflow-hidden rounded-lg bg-[repeating-linear-gradient(to_bottom,transparent_0_19px,color-mix(in_srgb,var(--ts-accent)_22%,transparent)_19px_20px)] text-[56px] leading-none @[560px]:w-full @[560px]:text-[88px]"
style={typeStyle}
>
{glyphs[activeGlyph]}
</div>
<div className="min-w-0">
<span className="sr-only">Character </span>
<span className="block font-mono text-[12px] tabular-nums text-zinc-900 dark:text-zinc-100">{codepoint(glyphs[activeGlyph] ?? " ")}</span>
<span className={`${mono} block`}>
{activeGlyph + 1} of {glyphs.length}
</span>
</div>
</div>
<div
ref={gridRef}
role="group"
aria-label="Character set"
className="grid grid-cols-[repeat(auto-fill,minmax(38px,1fr))] gap-px overflow-hidden rounded-xl border border-zinc-900/10 bg-zinc-900/10 dark:border-white/10 dark:bg-white/10"
>
{glyphs.map((ch, i) => (
<button
key={`${ch}-${i}`}
type="button"
data-index={i}
tabIndex={i === activeGlyph ? 0 : -1}
aria-label={`${ch}, ${codepoint(ch)}`}
aria-pressed={i === activeGlyph}
onClick={() => setGlyph(i)}
onFocus={() => setGlyph(i)}
onPointerEnter={(e) => {
if (e.pointerType === "mouse") setGlyph(i);
}}
onKeyDown={(e) => onGlyphKey(e, i)}
style={typeStyle}
className={`relative grid aspect-square place-items-center bg-white text-[20px] leading-none outline-hidden transition-transform duration-150 motion-reduce:transition-none focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--ts-accent)] hover:z-10 hover:scale-[1.45] hover:rounded-md hover:shadow-[0_8px_24px_-10px_rgba(0,0,0,0.45)] dark:bg-zinc-950 ${
i === activeGlyph ? "text-[var(--ts-accent)]" : "text-zinc-800 dark:text-zinc-200"
}`}
>
<span aria-hidden="true">{ch}</span>
</button>
))}
</div>
</div>
)}
{activeTab === "css" && (
<div className="relative">
<pre className="overflow-x-auto rounded-xl border border-zinc-900/10 bg-zinc-900/[0.03] p-4 font-mono text-[12px] leading-relaxed text-zinc-700 dark:border-white/10 dark:bg-white/[0.03] dark:text-zinc-300">
<code>
<span className="text-zinc-500 dark:text-zinc-400">.specimen</span> {"{"}
{"\n"}
{cssLines.map((line) => {
const at = line.indexOf(":");
return (
<React.Fragment key={line}>
{" "}
<span className="text-[color-mix(in_srgb,var(--ts-accent)_70%,currentColor)]">{line.slice(0, at)}</span>
{line.slice(at)}
{"\n"}
</React.Fragment>
);
})}
{"}"}
</code>
</pre>
<button
type="button"
onClick={copy}
className={`${mono} absolute right-2.5 top-2.5 rounded-full border border-zinc-900/15 bg-white px-3 py-1.5 outline-hidden transition-colors hover:border-zinc-900/40 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ts-accent)] dark:border-white/15 dark:bg-zinc-950 dark:hover:border-white/40 dark:hover:text-white`}
>
{copied ? "Copied" : "Copy CSS"}
</button>
<span className="sr-only" aria-live="polite">
{copied ? "CSS copied to clipboard." : ""}
</span>
</div>
)}
</div>
</div>
)}
</div>
);
}