Username Availability
A controlled handle field that gates a debounced availability check behind local rules, names the rule that broke, tells unreachable apart from taken, and shows the normalised handle before submit.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/username-availability.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "UsernameAvailability" component
(lucide-react for Check / CircleAlert / CircleDashed / CircleX / Loader2 /
RefreshCw / WifiOff, plus the shadcn Button and Input primitives).
Contract
- forwardRef to the inner input; props extend InputHTMLAttributes minus
value / defaultValue / onChange / type, which the component redefines.
- value: string and onChange: (value: string) => void — fully controlled. The
raw text is never rewritten under the caret; the canonical form is shown
beside the field instead.
- checkAvailability: (value: string) => Promise<boolean> — required, since a
field that cannot ask is just a validated input. true = free, false = taken,
a rejection = unreachable.
- rules?: { minLength = 3, maxLength = 20, separator: "_" | "." | "-" = "_",
pattern?: RegExp, patternLabel?: string, reserved?: string[] }.
- debounceMs = 400; onStatusChange?: (status, normalized) => void, which hands
the parent the exact string a submit should send.
- export type UsernameStatus = "idle" | "checking" | "available" | "taken" |
"invalid" | "unreachable", plus normalizeUsername, checkUsernameRules and
suggestUsernames as plain functions, so a form reuses the same verdict the
field shows — two implementations of "valid" eventually disagree.
Behavior
- Normalisation is one pipeline and runs before everything else: NFKC (folds
the fullwidth forms a mobile or CJK keyboard emits), strip zero-width / bidi
/ BOM characters (invisible characters let two identical-looking handles be
stored as two rows), trim, toLowerCase — not toLocaleLowerCase, or a Turkish
device folds differently — then collapse every run of word breaks
(whitespace, ".", "_", "-", U+2010-U+2015) into the single canonical
separator. It is idempotent, so the value the rules judge, the value the
network is asked about and the value that gets stored are one string.
- Local rules, evaluated on the normalised value, reported in a fixed order,
each with a short positive label for the checklist and a sentence naming the
break: length (counted in CODE POINTS — Postgres char_length and most handle
backends count code points, not UTF-16 units), charset (whole-string test
against pattern), edges (no leading or trailing separator), reserved (only
rendered when a reserved list is supplied — a rule that can never fail is
dead UI). Clone the pattern without /g and /y before testing: a stateful
regex from a consumer remembers lastIndex and answers differently each call.
- The charset message names the offending characters. When not one of them
passed, probe the pattern with single characters ("a", "z", "0", "9" and the
separators): if it accepts one of those it is an alphabet, so name the
characters anyway — a handle typed entirely in another script, or entirely in
punctuation, is exactly where naming them helps most. If it accepts none of
them it is describing a whole-string shape (a required prefix, an embedded
length window), per-character blame would be invented — fall back to a
general sentence.
- The network is asked only about a value that is non-empty and breaks no
rule. Local rules first is the whole point: a request whose answer cannot
change the outcome is a request that should never have been sent.
- Six states, all derived, none stored: empty -> idle; a broken rule ->
invalid (message = the first break); otherwise the answer for THIS exact
normalised value -> available / taken / unreachable; anything else ->
checking, which deliberately covers the debounce window as well as the
request so the row shows one calm state instead of flickering.
- Store the answer together with the value it answers for and read it only
while that value is still current, so a slow reply about a handle the user
has already typed past can never repaint the new one. The debounce timer
lives in an effect keyed on the normalised value; its cleanup clears the
timer and marks the in-flight promise stale, which covers supersede,
unmount and manual retry with one mechanism.
- unreachable is a first-class state, never folded into taken and never into
available: a check that failed knows nothing, and either lie ends as a
duplicate row or a lost signup. It offers a real "Try again" that clears the
answer and re-arms the effect — placed OUTSIDE the live region, since a
control inside one is re-announced on every update. Keep it mounted and
enabled for the whole check it starts: unmounting it (or disabling it) blurs
the button under the person who just pressed it, dropping a keyboard user at
the top of the page with the retry still unanswered.
- taken renders up to three suggestions built only from the letters already
typed: the parts joined without the separator, initial + tail, then a
counter that CONTINUES a trailing digit run instead of stacking a second one
("ada7" -> "ada8"). Match the WHOLE run and continue it only while it is at
most six digits, so the counter stays exact; a longer run is a number that
belongs to the name, so keep every digit of it and append a fresh counter
("user1000000" -> "user10000001"). Splitting the run and incrementing half of
it would drop characters the user typed. Candidates that would exceed
maxLength shorten the stem, never the counter, and are re-trimmed so they
cannot end on a separator; every candidate is then re-run through the same
rules before it is offered, and none is claimed to be free — picking one just
starts a fresh check.
- Whenever the normalised value differs from the typed text, show it: "Saves
as" plus a "Use it" button that adopts it once every rule passes, and a bare
"Reads as" while a rule is broken — nothing is ever saved as a string the
check is not even asked about, and adopting it would swap the typed text for
something still invalid while taking the reading away. That is the whole
normalisation contract: visible before submit, adoptable in one click, never
forced mid-typing.
- Keep callback props in a ref an effect refreshes: consumers write inline
arrows, and a fresh identity every render re-arms the debounce forever.
Rendering & styling
- Semantic tokens only: the shadcn Input (aria-invalid drives its destructive
border), bg-muted for the normalised chip, text-primary for available,
text-destructive for taken / invalid / broken rules, text-muted-foreground
for checking and unreachable — unknown is not the user's mistake.
- Trailing icon in an aria-hidden slot inside the field: spinning Loader2
(motion-reduce:animate-none), Check, CircleX, CircleAlert, WifiOff. With
animation off the field still checks, announces and gates — only the spin
stops.
- One status line, role="status" + aria-live="polite", always mounted (a live
region created at the same moment its text appears is not announced) and
empty in idle.
- The rule checklist is a real <ul aria-label>, each item an aria-hidden icon
plus an sr-only "Met: " / "Not met: " / "Not checked yet: " prefix; it wraps
rather than truncates. Both the checklist and the status line are wired into
the input's aria-describedby alongside any describedby the consumer passes.
- Handle text is font-mono; every button is a real <button type="button"> with
the shadcn focus-visible ring; cn() merges className onto the wrapper.
Customization levers
- Policy: rules is the whole knob. GitHub-style is
{ minLength: 1, maxLength: 39, separator: "-", pattern: /^[a-z0-9-]+$/,
patternLabel: "..." } — and "no consecutive hyphens" comes free from the
separator folding. Pass patternLabel whenever you pass pattern, or the
checklist names a rule you are not enforcing.
- Reserved list: ship it from the server with the rest of the policy so the
field and the backend cannot drift; omit it and the rule disappears.
- Debounce: raise debounceMs for an expensive lookup, or set it to 0 and let
the stale-answer guard do the work (the demo does exactly that).
- Suggestion strategy: change the ladder or the count in suggestUsernames —
add a domain word, drop the counter — as long as every candidate is still
re-validated before it is offered.
- Surface: drop the checklist and keep only the status line for a compact form
row, or move the status text into a form library's message slot.
- Adoption: call onChange(normalizeUsername(value)) on blur if you would
rather settle the field than show a "Use it" button.Concepts
- Local rules before the network — length, charset, edge separators and reserved words are decided in the browser, and the check is sent only for a value that passes all of them. A request whose answer cannot change the verdict is a request that should never have been sent, and on a signup form that is most of them.
- Normalise, then ask — the case-folded, separator-folded string is what the rules judge, what the check is asked about and what a submit stores. One string end to end is why
Ada.Lovelaceandada lovelacecannot both be registered, and why the field can show you the handle you are actually claiming. - Name the rule, not the failure — "invalid handle" tells someone to guess. "Too long: 25 characters, the limit is 20" and "Remove “!”" tell them what to do next, and the checklist keeps the other rules visible so fixing one does not surface a surprise.
- Unreachable is not taken — a check that failed knows nothing. Reporting it as taken pushes the user off a handle that was free; reporting it as available publishes a duplicate. It stays a separate state with a retry, and nothing downstream unlocks on it.
- The answer carries its question — every reply is stored with the value it answers for and read only while that value is still current, so a slow reply about a handle you have already typed past cannot repaint the new one. One effect cleanup covers supersede, unmount and manual retry.
- Suggestions are candidates, not promises — the three offers are derived from the letters already typed, re-validated against the same rules, and explicitly unchecked; picking one starts a fresh check instead of claiming a handle the server never confirmed.
Bank Details Form
Payout details whose fields follow the country — IBAN and BIC for SEPA, sort code and account number for the UK, routing and account number for the US — with a real mod-97 IBAN check, the ABA weighted checksum, and the account masked after entry.
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.