Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Check,
  CircleAlert,
  CircleDashed,
  CircleX,
  Loader2,
  RefreshCw,
  WifiOff,
  type LucideIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/username-availability.json

Prompt

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.Lovelace and ada lovelace cannot 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.

On This Page