# Username

> A username picker with a penguin holding a sign: your name appears on it as you type; if it’s taken, its smug owner slides in hugging a sign with your name on it.

Picking a username is a field, a spinner and a red “taken”. This one hands the name to Handle, an illustrated penguin holding a blank sign on the lit stage. As you type, the name appears on his sign letter by letter. When the check says it’s free he beams and a green tick lands on the corner of the sign. When it’s taken his sign goes blank and his face falls, and its owner, a smug penguin in a red scarf, slides in from the side hugging a sign with your name on it: “@ana is mine.” The suggestions your check returns come back as chips under the field, and one tap writes them on the sign. Under the penguins it is a careful picker: lowercase as you type, a length range and allowed characters with a plain hint, a debounced, abortable check, and Claim wired to your function.

## Install

```bash
npx shadcn@latest add https://hairlineui.com/r/username.json
```

Needs React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

- npm: none
- Hairline UI components it needs (the CLI installs them too): `input`, `status-button`, `text-morph`
- Docs and live preview: https://hairlineui.com/ui/username
- Registry item (source): https://hairlineui.com/r/username.json

## Usage

```tsx
import { Username } from "@/components/username";

<Username
  check={(name, signal) => fetch(`/api/usernames/${name}`, { signal }).then((r) => r.json())}
  onClaim={(name) => fetch("/api/me/username", { method: "PUT", body: JSON.stringify({ name }) })}
/>
```

## With your users table

Return { available, suggestions } from one endpoint and let a unique index have the last word on claim.

```tsx
"use client";
import { Username } from "@/components/username";

export function PickUsername({ onDone }: { onDone: () => void }) {
  return (
    <Username
      check={async (name, signal) => {
        const res = await fetch(`/api/usernames/${encodeURIComponent(name)}`, { signal });
        if (!res.ok) throw new Error("Couldn’t check");
        return res.json(); // { available: boolean, suggestions?: string[] }
      }}
      onClaim={async (name) => {
        const res = await fetch("/api/me/username", { method: "PUT", body: JSON.stringify({ name }) });
        if (res.status === 409) throw new Error("Someone just took it. Try another.");
        if (!res.ok) throw new Error("Couldn’t save that.");
        setTimeout(onDone, 900);
      }}
    />
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `check` | `(name: string, signal: AbortSignal) => Promise<{ available: boolean; suggestions?: string[] }>` | – | Is the name free? Called 380ms after typing stops; aborted when a newer name starts. |
| `onClaim` | `(name: string) => Promise<unknown>` | – | Keep the name. Throw an Error to show its message. |
| `minLength / maxLength` | `number` | `3 / 20` | The length range, said in the line under the title. |
| `pattern / patternHint` | `RegExp / string` | `/^[a-z0-9_]+$/ / "Letters, numbers and _ only."` | What the whole name may contain, and what to say when it doesn’t. |
| `prefix` | `string` | `"@"` | Shown in the field and on the signs; use your domain for workspace URLs. |
| `defaultValue / title / name` | `string` | `"" / "Pick a username" / "Handle"` | A starting name, the heading, and the penguin’s name in his lines. |

## States

- **empty**: Handle holds up a sign with just “@”; “Handle will hold it up”.
- **typing**: The name appears on his sign as you type (letters rise in through Text morph), shrinking to fit longer names; “Handle is asking around” while the check runs.
- **free**: He beams (pose crossfade, 220ms), a green tick springs onto the sign’s corner, the hint says Available and Claim @name lights up.
- **taken**: His sign goes blank and his face falls; the owner slides in from the right (520ms) hugging a sign with the name; “@ana is mine.” Suggestions rise in under the field, 50ms apart.
- **not allowed**: A character outside the pattern: he looks lost, and the field shows the hint (“Letters, numbers and _ only.”) with no check.
- **claimed**: Claim → Claiming → Claimed with a drawn check; “Hello, @ana”. If onClaim throws, the message shows under the field.

## What it handles

A username field usually waits until submit to say a name is taken, and leaves people guessing what to try. This block checks as they type, says why a name won’t do, and offers names that are free.

- **Checked as you type**: check runs 380ms after typing stops; a newer name aborts the older request.
- **Rules said plainly**: Lowercase as you type; too short, too long or a character that isn’t allowed says so before any check.
- **Something to try**: When a name is taken, the suggestions your check returns are one-tap chips.
- **Your prefix**: prefix shows @, or your domain for workspace URLs.
- **Claim with states**: Claim @name → Claiming → Claimed, or the server’s reason under the field.
- **A check that failed**: If check throws, the field says it couldn’t check instead of guessing.

## Replaces

a username field, an availability check, debounce and abort, suggestions, a claim button

## Where it fits

- **Onboarding**: Choose a handle right after signing up.
- **Profile settings**: Change your username, with suggestions when it’s gone.
- **Workspace URLs**: Claim your team’s slug (relay.app/northwind).
- **Creator links**: Pick the link people will share.

## Notes

- The field is a labelled username input; its hint says Available or Taken and its error says why a name won’t do. A status region announces availability and the claim.
- The penguins are decoration (aria-hidden). Their poses are illustrations shipped inside handle-art.ts as WebP data URIs (37KB of images), with each sign’s position and tilt measured so the name is written on it in code.
- The scene is drawn at 364 × 176 and scaled to the stage; the owner slides in with a transform.
- With reduced motion the poses and the owner change in place.
- Built from Input, Status button and Text morph, which the CLI installs with it. No animation library.

License: MIT (https://hairlineui.com/license#components)
