Font Picker
A typeface field where every option is rendered in its own face — grouped sans / serif / mono / display, previewed with your own pangram, with system stacks always present and remote families fetched through an injected loader that reports loading and failure per font.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/font-picker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "FontPicker" component (React 19 +
lucide-react only; no font library, no positioning library, no Radix — the
catalogue is data, the loader is injected, and the panel is drawn by the
component itself). Merge classNames with cn().
Contract
- export const FontPicker = React.forwardRef<HTMLDivElement, FontPickerProps>,
where FontPickerProps extends
Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue">:
- value? / defaultValue?: string — the CSS font-family stack. Controlled when
`value` is passed, uncontrolled otherwise.
- onValueChange?: (stack: string) => void — fires on every commit (row click,
Enter on the highlighted row) with that option's own `stack`, untouched.
- fonts?: FontOption[] (default []) — your catalogue.
- systemFonts?: boolean (default true) — merge the exported SYSTEM_FONTS in.
- loadFont?: (font: FontOption) => Promise<unknown> — see Behavior.
- previewText?: string (default "The quick brown fox")
- placeholder? / searchPlaceholder? / emptyText? / label? (default
"Typeface") / disabled? / className (merged onto the root through cn();
remaining props spread there).
- export interface FontOption { name, stack, category: "sans" | "serif" |
"mono" | "display", fallback?, remote?, aliases?: string[] }.
`stack` is BOTH the value and what previews are rendered with, so a row shows
exactly what the page will show. `remote: true` marks a family that has to be
fetched; omit it for stacks already on the machine.
- export const SYSTEM_FONTS: FontOption[] — ~13 stacks that are on the machine
already (system-ui, Arial, Verdana, Tahoma, Trebuchet MS, Georgia, Times New
Roman, Palatino, ui-serif, ui-monospace, Courier New, Impact, Comic Sans MS),
spread across the four categories. They need no network, no loader and no
permission, which is why they are merged into every catalogue by default:
whatever happens to the remote families, the list can never be entirely
broken.
Behavior — the catalogue
- Identity is the *normalised* stack: `stack.replace(/\s+/g, " ").trim()`. It is
the React key, the de-duplication key, the per-font status key and what the
incoming `value` is compared against. The value handed back to the consumer is
always the option's own raw `stack`.
- De-duplicate on that key, first occurrence winning, so a caller's entry beats
the built-in system row it duplicates. A repeated stack would otherwise mean
two React keys, two role="option" nodes and two rows both claiming to be
checked.
- A value that is not in the catalogue still gets a row: synthesize
{ name: stack, stack } into a "Current value" group pinned above the four
category groups, print the stack verbatim and render it with itself. A theme
file pointing at a family this build never shipped must not silently fall back
to the placeholder and drop the saved value.
- Groups render in a fixed order (Current value, Sans-serif, Serif, Monospace,
Display); within a group the caller's order is preserved — it is usually
meaningful (house face first). Rank unknown category strings last instead of
dropping those rows, so data coming from untyped sources still renders.
- Search folds the query to lower case, splits it on whitespace and requires
every token to appear in the row's corpus (name + stack + category label +
aliases), so "plex mono" finds "IBM Plex Mono" and "kai 楷" finds 楷体.
`aliases` is invisible search fodder: foundry names, classification slang,
romanisations, 中文名.
Behavior — the injected loader
- `loadFont` is the ONLY way a family is fetched. The component never touches
document.fonts, never injects a <link>, and never guesses whether a family is
present — the app owns that (next/font, a FontFace, a stylesheet, an internal
CDN with a token).
- Only options with `remote: true` are ever routed through it. System entries
are always "ready" by definition. With no loader at all, remote rows are never
requested and render optimistically with their full stack: the loader is what
turns "probably there" into a *reported* state.
- One-shot per family per mount: a Set in a ref is READ and WRITTEN in the same
synchronous pass before the loader is called, so re-renders, re-opened panels,
keystroke-driven re-filters and StrictMode double-invocation cannot produce a
second request.
- Requests are issued from two effects: the selected family on mount (the
trigger renders it in its own face, panel or no panel) and every currently
listed row while the panel is open (the preview IS the product). Because the
ref dedupes, an inline `fonts={[…]}` literal that re-runs those effects every
render costs nothing.
- Read `loadFont` through a ref updated in an effect, not from the closure — an
inline arrow prop is the normal way to pass one and must not re-key effects.
- Per-font status is a Record keyed by the normalised stack: "loading" the
moment it is requested, "ready" on resolve, "error" on reject OR on a
synchronous throw (wrap the call in try/catch — a loader that throws is a
failed font, not a crashed render). One font in flight never blanks the rest.
- A promise cannot be cancelled, so unmount is handled by an alive flag in a
ref: late resolutions simply refuse to write. Do not pretend the fetch stopped.
Behavior — failure is a rendering, not a hole
- An errored row keeps rendering, with the family that never arrived removed
from the stack: `stack.split(",").slice(1).join(", ")`, falling back to the
category's generic family when nothing is left (`fallback` overrides). That is
what the browser itself would paint, and pinning it explicitly is what keeps
the specimen legible while a @font-face with font-display: block hides its
text for up to three seconds.
- The row says so in words — "Could not load — rendering Georgia, serif" — never
an icon alone; the trigger carries the same sentence as sr-only text beside
its warning glyph.
- A footer appears while anything has failed: a count plus one Retry that clears
the one-shot marks for the failed families and re-requests only those. Retry
moves focus to the search field FIRST, synchronously, because the button
unmounts the instant the last failure clears and a control that vanishes under
the caret drops focus onto <body>.
Behavior — the panel
- Trigger: a real <button role="combobox"> with aria-haspopup="listbox",
aria-expanded, and aria-controls only while open (so the id always resolves).
It shows the selected family's name rendered in that family, its category, a
spinner while loading, a warning glyph on failure, and an sr-only `label` so
the accessible name says what the field is before it says what is in it.
- Opening resets the query and the highlight, focuses the search input, and
measures placement ONE frame later inside a requestAnimationFrame: real panel
height against the room under the trigger, flipping above only when below
cannot hold it AND above is roomier. setState happens inside the rAF callback,
never in the effect body; the frame is cancelled on close. Placement is
decided once and does not follow scroll — portal to the body with a full
geometry pass (see this registry's LanguagePicker) if your triggers live in
scrollable panes.
- Exactly one element has role="combobox": the trigger. The search field stays a
plain input with aria-autocomplete="list", aria-controls and
aria-activedescendant, so assistive tech announces one combobox, not two.
- The list is role="listbox" holding role="group" wrappers (each labelled by its
own heading through aria-labelledby) holding role="option" rows with
aria-selected. The empty-state node is role="presentation" — a bare node
between a listbox and its options breaks the ownership chain and screen
readers announce an empty list. Rows are never focusable; the check mark and
the status glyphs are aria-hidden.
- Keyboard: the trigger opens on click, Enter, Space (it is a real button) and
ArrowUp/ArrowDown, and closes on Escape. Inside, the search field owns
ArrowUp/ArrowDown (move one row, across group boundaries), Home/End (first /
last row), Enter (commit the highlighted row) and Escape (close and return
focus to the trigger, with stopPropagation so a picker inside a dialog closes
only itself). Tab is not special-cased: the panel is a DOM sibling of the
trigger, so tabbing out lands somewhere sensible and the focusin guard closes
the panel behind it.
- The highlight is DERIVED, never hand-synced: an explicit index is honoured
only while it points at a live row, otherwise it falls back to the checked row
(empty query) or the first hit (while typing). Filtering never has to repair
state, and an empty result set highlights nothing — Enter then commits nothing
rather than committing whatever was last highlighted.
- Grouping is computed once and the flat keyboard index is derived FROM the
rendered groups, so the two views of the list can never drift apart.
- Rows commit on click and preventDefault their mousedown, so pressing a
non-focusable row does not blur the search input to <body> and close the panel
before the click lands.
- Dismissal: a document pointerdown outside the root (pointerdown, not click, so
the panel is gone before the press lands underneath) and a focusin outside it.
Both listeners exist only while open and are removed on close and on unmount.
- `disabled` is aria-disabled plus guards in the handlers, never the disabled
attribute: the browser blurs a node the instant it goes inert, and this field
can go inert under a user standing on it. Going inert also closes the panel;
if that unmount took focus with it (activeElement === document.body), focus is
handed back to the trigger — a deliberate successor, never the body.
Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground / border /
border-input / bg-accent / text-accent-foreground / bg-muted /
text-muted-foreground / text-destructive / ring-ring. No hardcoded colours.
- Trigger: h-10 w-full rounded-md border, a Type glyph, name + category, status
glyph, ChevronsUpDown. Panel: rounded-md border bg-popover shadow-md, a search
row with its own border-b, a max-h-72 min-h-0 flex-1 overflow-auto list
(tabIndex={-1} keeps Chromium from making the scroll container a tab stop),
and the failure footer.
- Each row is two lines plus a status line: the family name in its own face
(text-sm font-medium, with a "System" badge when it needs no network) over the
preview specimen in the same face (text-base, dir="auto" so an Arabic or
Hebrew pangram lays itself out). Both truncate rather than wrap — a specimen
that reflows into three lines stops being a specimen.
- The only animation is the loading spinner (animate-spin
motion-reduce:animate-none) and colour transitions
(motion-reduce:transition-none). With motion off, the word "Loading" still
says what is happening; nothing about the control depends on movement.
- "use client" is required: state, effects, DOM measurement, focus management.
Customization levers
- The catalogue: `fonts` is yours; SYSTEM_FONTS is exported, so
SYSTEM_FONTS.filter(f => f.category !== "display") or systemFonts={false} both
work. Push aliases for how your team actually talks ("body", "code", "标题").
- The specimen: `previewText` is the single most valuable knob — a Latin pangram
tells a Chinese reader nothing. Swap in your own copy, or feed it the sentence
the user is currently editing so the preview is literally their text.
- Row density: drop the specimen line for a compact menu, or add a third line
showing the stack itself if your users think in stacks. Raise max-h-72 for a
taller list.
- Categories: the four are a Record + an order array at the top of the file. Add
"handwriting" or "cjk" by extending both; unknown keys already render last
rather than disappearing.
- The loader is the integration point. document.fonts.load("1em Inter") for
fonts declared in CSS; new FontFace(...).load() for ones you fetch; a
Promise.race with a timeout if you want a deadline (the component keeps no
clock of its own on purpose); a per-tenant CDN call with an auth header. Reject
to make the fallback path visible — that path is the feature.
- Loading policy: rows are requested while the panel is open, deduped per
family. For a thousand-family catalogue, hand `fonts` a paged or virtualised
slice, or preload inside your loader; the component asks once per family per
mount and never more.Concepts
- Every option is its own specimen — a row does not describe a typeface, it is set in it. Name and preview text both render with that entry's stack, which is also the value, so the list can never advertise something different from what the page will paint.
- The value is the stack, not an id — commits hand back the CSS
font-familystring verbatim. It drops straight into a style attribute, a CSS variable or a theme record with no lookup table in between, and that is also why identity is the whitespace-normalised stack rather than a name that two foundries might share. - Fallback is derived, not invented — a failed family is removed from its own stack and the remainder is rendered, because that is exactly what the browser would do. Pinning it explicitly is what keeps the row legible while a
font-display: blockface is still hiding its text, so failure looks like a warning next to readable type instead of a blank line. - A system floor under everything — the built-in system stacks are merged into every catalogue by default. They need no network, no loader and no permission, so a total loader failure degrades the list to "fewer choices", never to "nothing renders".
- One-shot requests — a Set in a ref is read and written in the same synchronous pass before the loader is called, so re-renders, re-opened panels, per-keystroke re-filters and StrictMode double-invocation all collapse into exactly one request per family per mount. Retry is the one deliberate way to clear that mark.
- Script-aware preview text — the specimen is a prop because "The quick brown fox" tells a Chinese, Arabic or Devanagari reader nothing about the face they are about to ship. Feed it your own pangram, or the sentence the user is editing right now, and the row becomes a decision instead of a guess.
Schema Form
A form generated from a zod object schema — one control per field type, required markers and every verdict read from that same schema, typed values on submit, and an explicit notice for shapes it will not guess at.
Number Ticker
A count-up number that eases from 0 to its target the first time it scrolls into view — Intl-formatted, prefix/suffix aware.