Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ChevronsUpDown, Loader2, RotateCw, Search, TriangleAlert, Type } from "lucide-react"
import { cn } from "@/lib/utils"

/* ------------------------------------------------------------------ *
 * Options
 * ------------------------------------------------------------------ */

export type FontCategory = "sans" | "serif" | "mono" | "display"

export interface FontOption {
  /** Display name — "Inter", "IBM Plex Serif". Rendered in the face itself. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/font-picker.json

Prompt

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-family string 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: block face 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.

On This Page