Slug Input
A URL field that transliterates the title as you type, detaches the moment you edit it by hand, and debounces an availability check with clickable -2 / -3 suggestions.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/slug-input.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SlugInput" component (lucide-react for
Check / CircleAlert / Loader2 / RotateCcw).
Contract
- forwardRef whose ref points at the inner input; props extend
InputHTMLAttributes minus value / defaultValue / onChange / prefix / type /
maxLength, which the component redefines.
- source?: string — the title the slug is transliterated from, live, for as
long as the field is still linked.
- defaultValue?: string — an existing slug. Anything non-empty starts the field
DETACHED: editing a published post's title must not silently move its
permalink.
- onValueChange?: (slug: string) => void — fires on every change of the
effective slug, including once on mount, so the parent never has to re-run
the transliteration itself.
- prefix?: string — read-only origin rendered inside the field.
- maxLength?: number — cap applied after transliteration; undefined or <= 0
means no cap.
- checkAvailability?: (slug: string) => Promise<boolean> — true = free.
- debounceMs = 400, onStatusChange?: (status: SlugStatus) => void,
slugify?: (input: string) => string, invalid?: boolean, disabled.
- export type SlugStatus = "idle" | "checking" | "available" | "taken" and the
default transliteration as a plain function, so forms can reuse both.
- The component owns the value. It is not a controlled input: "controlled by
the parent" and "derived from source" are two writers for one string, and
wiring both produces either a render loop or a field that fights the user.
The parent mirrors the value through onValueChange instead.
Behavior
- Transliteration, one pipeline used by both the source and the keyboard:
NFD normalise, strip combining marks, lowercase, spell out the letters NFD
cannot decompose (ß→ss, æ→ae, œ→oe, ø→o, đ/ð→d, þ→th, ł→l, ı→i), delete
quotes and apostrophes outright (don't → dont, not don-t), turn every other
run of non-alphanumerics into a single "-", then trim leading and trailing
hyphens and clamp to maxLength — trimming the trailing hyphen again if the
clamp cut through one.
- Linked vs detached: while linked the rendered slug IS the transliterated
source, derived during render — no effect syncs it, so it can never lag one
keystroke behind. The first change event whose result differs from the
current slug flips the field to detached and the source stops writing.
- Re-link: while detached, and only when the auto value is non-empty and
actually different from what is in the field, show a "Reset to auto" button.
It restores the derived value and re-links, so a later title edit is followed
again. Gate it on a real difference: a button that visibly does nothing is
worse than no button.
- Typing goes through the same transliteration, which is what makes a space
become "-" and a pasted paragraph become a slug. One exception: the settled
form trims the trailing hyphen, which would make a word separator
un-typeable, so the typing form keeps the separator the user is halfway
through. Discover WHICH characters earn one by probing slugify with a
trailing letter (slugify(raw + "a") inserting one extra separator before the
"a" means the raw text ends on an open separator) rather than hardcoding a
character class — that keeps typed and pasted text identical ("v1.2" is
"v1-2" both ways, "don't" is "dont" both ways) and a custom slugify keeps its
own alphabet and its own separator character.
- Caret: the browser has already edited the DOM when change fires, so
transliterate the text before the caret and use its length as the new caret
position, then write the conformed value back into the node synchronously and
setSelectionRange. Carrying the caret as "after N slug characters" is what
lets a user delete a hyphen in the middle without the caret jumping to the
end. The synchronous write also matters when the value did not change (an
illegal keystroke): React re-renders nothing, so without it the rejected
characters stay on screen.
- Blur settles the value by trimming that trailing hyphen.
- IME: hold the half-composed text in state and render it untouched; run the
transliteration on compositionend. Re-slugging mid-composition reorders or
drops characters. A backup ref covers engines that omit isComposing on the
last event of a run.
- Availability, four states derived rather than stored: idle (no checker, or an
empty slug), checking, available, taken. Store the answer together with the
slug it answers for, and read it only when that slug is still the current one
— a late answer for an older slug can then never be displayed. The debounce
timer lives in an effect keyed on the slug, and its cleanup both clears the
timer and marks the in-flight promise cancelled, which covers the supersede
race and unmount with one mechanism. "checking" deliberately covers the
debounce window as well as the request, so the row shows one calm state
instead of flickering between empty and spinner.
- A rejected check resolves to "unknown" and renders as idle. Claiming
"available" because the network failed is the one lie this component must
never tell.
- taken renders up to three clickable suggestions: base-2, base-3, base-4, and
post-2 continues at post-3 (bounded digit run, so a long numeric tail stays
part of the name). Suffixes fit inside maxLength by shortening the base;
suggestions are never claimed to be free — clicking one adopts it and starts
a fresh check, which is honest and costs nothing.
- Callback props are kept in a ref that an effect refreshes, so an inline arrow
from the consumer cannot re-arm the debounce on every render.
Rendering & styling
- Wrapper: a bordered row (h-9 rounded-md border-input bg-transparent px-3
font-mono text-sm shadow-xs) with focus-within:border-ring +
focus-within:ring-3 focus-within:ring-ring/50, switching to border-destructive
when invalid or taken; disabled dims it and blocks the cursor.
- The prefix is a span with max-w-[50%] shrink truncate text-muted-foreground:
it ellipsises instead of pushing the input out of a narrow card, and it is
referenced from the input's aria-describedby so screen readers still get the
whole origin.
- Trailing icon slot: spinning Loader2 (motion-reduce:animate-none) while
checking, Check in text-primary when available, CircleAlert in
text-destructive when taken. With animation off the field still checks,
announces and blocks — the spinner is the only thing that stops.
- One status line under the field, role="status" + aria-live="polite", always
mounted (a live region added at the same moment its text appears is not
announced) and empty in idle. It is referenced by aria-describedby together
with the prefix, and aria-invalid follows invalid || taken.
- Reset and the suggestion chips are real buttons with
focus-visible:ring-2 ring-ring; semantic tokens only, so dark mode is free.
Customization levers
- Transliteration: pass slugify for pinyin, a Unicode-preserving rule
(/[^\p{L}\p{N}]+/u), an underscore separator, or a stop-word stripper. The
typing path, the caret math and the maxLength clamp all follow it.
- Detach policy: start linked even with a defaultValue (a draft that has never
been published), or never re-link at all by dropping the reset button.
- Feedback surface: move the status text into a form library's message slot,
or drop the in-field icons and keep only the text.
- Suggestions: change the count, the separator ("-2" vs "_2" vs a random
4-character suffix), or verify each candidate before offering it if your
backend has a batch endpoint.
- Debounce: raise debounceMs for an expensive lookup, or set it to 0 and let
the request cancellation do the work.
- Density and type: h-9 / px-3 / text-sm are the sizing knobs; drop font-mono
if the slug should read as prose rather than as a URL.Concepts
- Derive until touched — while the field is linked, the slug is not stored anywhere: it is the transliterated
source, computed during render. That is why it can never lag a keystroke behind the title, and why "the user edited it" is a single boolean rather than a diffing heuristic. - Detach is a promise, not a mode — the moment a change event produces something different, the title loses write access for good. Half-following (re-syncing on the next title edit) is the behaviour that silently overwrites a slug someone deliberately chose.
- Re-link, not just reset — the button restores the derived value and puts the field back under the title's control; it only appears when the auto value actually differs, so it never renders as a click with no visible effect.
- One pipeline, two endings — source text and typed text run through the same transliteration; the only difference is that the typing form keeps the separator being typed. Which characters earn one is probed from
slugifyitself, so a typedv1.2and a pastedv1.2land on the samev1-2, whiledon'tstaysdontin both. - Caret in slug space — the caret is carried as "after N slug characters" and re-measured by transliterating the text before it, so deleting a hyphen in the middle of a long slug leaves the caret where the user put it instead of at the end.
- The answer carries its question — the availability result is stored together with the slug it belongs to and only read while that slug is still current, so a slow reply about an old slug cannot repaint the new one. The debounce effect's cleanup cancels the supersede race and the unmount race with the same flag.
- Failure is not availability — a rejected check degrades to idle.
checking → availableon a dropped request is the one wrong answer that gets published as a duplicate URL.