# Hairline UI: all 113 components
> 113 free, MIT-licensed React components with interactions that feel expensive: AI chat parts, inputs, data and charts, landing page sections. Each installs in one command with the shadcn CLI and takes on the project's shadcn theme.
Needs React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react. No animation library and no provider to set up.
- Install one: `npx shadcn@latest add https://hairlineui.com/r/.json`
- MCP server for coding agents: https://hairlineui.com/api/mcp (Streamable HTTP, no key; setup at https://hairlineui.com/mcp)
- shadcn registry index: https://hairlineui.com/r/registry.json
## Text morph (`text-morph`)
Text that changes the way it should: shared letters slide over, new ones rise out of a blur, the width eases.
```bash
npx shadcn@latest add https://hairlineui.com/r/text-morph.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/text-morph.md
```tsx
import { TextMorph } from "@/components/text-morph";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `string` | – | The text. Change it and it morphs. |
| `duration` | `number` | `460` | Morph time in ms. |
| `direction` | `"up" \| "down"` | `"up"` | New letters rise from below ("up") or drop from above ("down"). |
| `animateWidth` | `boolean` | `true` | Ease the width; turn off for text that wraps over several lines. |
## Status button (`status-button`)
Save, Saving, Saved: the spinner opens in, the check draws itself, errors shake, and the label morphs between them.
```bash
npx shadcn@latest add https://hairlineui.com/r/status-button.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`
- Full docs: https://hairlineui.com/ui/status-button.md
```tsx
import { StatusButton, type ActionStatus } from "@/components/status-button";
const [status, setStatus] = useState("idle");
setStatus("idle")} />
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `status` | `"idle" \| "pending" \| "success" \| "error"` | `"idle"` | What the button shows. |
| `labels` | `Partial>` | `Save, Saving, Saved, Try again` | Wording per state. |
| `icon` | `ReactNode` | – | An icon beside the idle label, e.g. a link icon for Copy link. |
| `variant` | `"primary" \| "outline" \| "ghost"` | `"primary"` | Filled with your brand colour, a hairline outline, or quiet text for toolbars. |
| `size` | `"default" \| "sm"` | `"default"` | 36px, or 28px for headers and toolbars (Copy in a code block, Undo in a receipt). |
| `onReset / resetAfter` | `() => void / number` | `— / 1800` | Called that long after success, to go back to idle. |
## Number roll (`number-roll`)
Numbers that change like an odometer: every digit rolls in the direction the number moved.
```bash
npx shadcn@latest add https://hairlineui.com/r/number-roll.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/number-roll.md
```tsx
import { NumberRoll } from "@/components/number-roll";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | – | The number to show; change it and the digits roll. |
| `format / locales` | `Intl.NumberFormatOptions / string` | – | Currency, percent, decimals, compact ("9.4K") and grouping, as Intl.NumberFormat does them. |
| `prefix / suffix` | `string` | – | Text around the number, e.g. "+" or " users". |
| `from` | `number` | – | Start here and roll to value once painted. |
| `duration` | `number` | `900` | Roll time in ms. |
| `direction` | `"up" \| "down"` | – | Always roll this way. Use "down" for a countdown’s seconds, so 00 → 59 rolls back like a clock instead of spinning forward. |
## Pricing toggle (`pricing-toggle`)
Monthly or yearly: the thumb slides with a little give, and every price on the page rolls to its new amount.
```bash
npx shadcn@latest add https://hairlineui.com/r/pricing-toggle.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`
- Full docs: https://hairlineui.com/ui/pricing-toggle.md
```tsx
import { Price, PricingToggle } from "@/components/pricing-toggle";
const [billing, setBilling] = useState("monthly");
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value / onValueChange` | `string / (value) => void` | – | The chosen billing period. |
| `options` | `{ value; label; badge? }[]` | `Monthly, Yearly “Save 20%”` | Any number of periods, e.g. add Lifetime. |
| `Price: amount / currency / period` | `number / string / string` | `— / "USD" / "/mo"` | The figure that rolls, its currency, and the period after it (hidden at 0). |
| `Price: note / direction` | `string / 1 \| -1` | – | The line under the price, sliding in from the way the toggle moved. |
| `Price: numberClassName` | `string` | – | Size and weight of the figure. |
## Waitlist field (`waitlist-field`)
An email field that becomes its own answer: the button stretches across it, then settles as "You’re on the list · #1,248".
```bash
npx shadcn@latest add https://hairlineui.com/r/waitlist-field.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/waitlist-field.md
```tsx
import { WaitlistField } from "@/components/waitlist-field";
joinWaitlist(email)} />
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onSubmit` | `(email) => Promise<{ position? } \| void>` | – | Runs on a valid email. Resolve to show the joined state, throw to show Try again. |
| `joined` | `{ position? } \| null` | – | Show the joined state straight away, e.g. for someone already on the list. |
| `placeholder` | `string` | `"you@company.com"` | The field’s placeholder. |
| `labels` | `Partial<{ idle; pending; success; error; invalid }>` | – | Wording for each state and the invalid hint. |
## Code input (`code-input`)
The six boxes, done properly: digits rise in, a paste cascades, a wrong code shakes and clears, the right one becomes a pill with a check.
```bash
npx shadcn@latest add https://hairlineui.com/r/code-input.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `status-button`, `text-morph`
- Full docs: https://hairlineui.com/ui/code-input.md
```tsx
import { CodeInput } from "@/components/code-input";
verify(code)} // resolve true to accept, false or throw to reject
onResend={() => sendCode()}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onComplete` | `(code) => Promise \| boolean \| void` | – | Runs when every box is filled. Resolve or return true to accept; throw or return false to reject. |
| `onResend` | `() => Promise \| void` | – | Shows the resend line; the countdown starts on mount and after each resend. |
| `resendAfter` | `number` | `30` | Seconds before a code can be resent. |
| `length` | `number` | `6` | How many boxes. |
| `groups` | `number[]` | – | Split the boxes, e.g. [3, 3] puts a dash between two threes. |
| `value / defaultValue / onValueChange` | `string` | – | Controlled or not; a full value set from outside is checked too. |
| `alphanumeric` | `boolean` | `false` | Letters as well as digits (shown upper case). |
| `label` | `string` | `"Enter the 6-digit code"` | The field’s label; it morphs through the states. |
| `autoFocus` | `boolean` | – | Focus the first box on mount. |
## Steps form (`steps-form`)
A multi-step form in one card that changes shape: steps slide in from the way you’re going, checks shake and explain, and Create turns into what comes next.
```bash
npx shadcn@latest add https://hairlineui.com/r/steps-form.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `status-button`, `text-morph`
- Full docs: https://hairlineui.com/ui/steps-form.md
```tsx
import { StepsForm } from "@/components/steps-form";
, validate: () => (name ? null : "Give it a name.") },
{ id: "plan", label: "Plan", title: "Pick a plan", content: },
]}
onSubmit={() => createWorkspace({ name, plan })}
submitLabels={{ idle: "Create workspace", pending: "Creating", success: "Created" }}
done={}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `steps` | `{ id, label, title, description?, content, validate? }[]` | – | In order. You own the fields’ state; content is whatever the step shows. |
| `onSubmit` | `() => void \| Promise` | – | Runs after the last step’s check. Throw to stay on the last step; the error’s message is shown. |
| `submitLabels` | `{ idle?, pending?, success? }` | `"Create", "Creating", "Created"` | The last button’s labels as it runs. |
| `done` | `ReactNode` | – | What the card turns into once it’s submitted. |
| `onStepChange` | `(index: number) => void` | – | Runs when the step changes, e.g. for analytics. |
## Date range picker (`date-range-picker`)
A range is one liquid band: it follows your pointer between days, presets flow it to their span, and the dates morph as it moves.
```bash
npx shadcn@latest add https://hairlineui.com/r/date-range-picker.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/date-range-picker.md
```tsx
import { DateRangePicker } from "@/components/date-range-picker";
const today = new Date();
const [range, setRange] = useState({ start: addDays(today, -29), end: today });
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value / defaultValue / onValueChange` | `{ start: Date; end: Date } \| null` | – | The applied range, controlled or not. onValueChange runs on Apply. |
| `presets` | `{ label, range: (today) => DateRange }[]` | `DATE_RANGE_PRESETS` | Today, Last 7 days, Last 30 days, This month, Last month, This year. Pass [] for a calendar on its own. |
| `min / max` | `Date` | – | Days outside them are dimmed and can’t be picked; the month arrows stop at them. |
| `unit` | `"days" \| "nights"` | `"days"` | What the footer counts. Nights need at least one, so the same day twice doesn’t end a range. |
| `weekStartsOn` | `0 \| 1` | `1` | Sunday or Monday first. |
| `locales` | `string \| string[]` | `"en-GB"` | For the dates, the month and the weekday letters. |
| `align` | `"start" \| "end"` | `"start"` | Line the panel up with the pill’s left or right edge; it shifts to stay on screen either way. |
| `open / defaultOpen / onOpenChange` | `boolean / boolean / (open) => void` | – | Whether the panel is open; controlled or not. |
| `placeholder` | `string` | `"Pick dates"` | The pill’s label before a range is set. |
## Morphing nav (`morphing-nav`)
One dropdown that follows you along the menu, gliding and resizing to each item’s content.
```bash
npx shadcn@latest add https://hairlineui.com/r/morphing-nav.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/morphing-nav.md
```tsx
import { MorphingNav } from "@/components/morphing-nav";
},
{ id: "pricing", label: "Pricing", href: "/pricing" },
]}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `{ id; label; href?; content? }[]` | – | Items with content open the panel; items with only an href are links. |
| `collisionPadding` | `number` | `16` | Space kept from the screen edges when placing the panel. |
## Feature tabs (`feature-tabs`)
One panel, many features: the line slides between tabs, fills as autoplay runs, and the panel eases to each.
```bash
npx shadcn@latest add https://hairlineui.com/r/feature-tabs.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/feature-tabs.md
```tsx
import { FeatureTabs } from "@/components/feature-tabs";
},
{ id: "ship", label: "Ship", content: },
]}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `tabs` | `{ id; label; content }[]` | – | Each tab and what its panel shows. |
| `value / defaultValue / onValueChange` | `string / string / (id) => void` | – | The chosen tab, controlled or not. |
| `autoplay` | `number` | – | Move on every this many ms, with the line as the timer. Off with reduced motion. |
## Scroll story (`scroll-story`)
A how-it-works section that tells itself as you scroll: steps light up at the middle, a rail fills, and one card reshapes into each step’s picture.
```bash
npx shadcn@latest add https://hairlineui.com/r/scroll-story.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`
- Full docs: https://hairlineui.com/ui/scroll-story.md
```tsx
import { ScrollStory } from "@/components/scroll-story";
},
{ title: "Make it yours", body: "Your name, your colour.", visual: },
{ title: "Go live", body: "Push it and watch the first visitors arrive.", visual: },
]}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `steps` | `{ title: string; body?: ReactNode; visual: ReactNode }[]` | – | In order. Give visuals a set width (the card sizes itself to them); keep them under about 280px wide for phones. |
| `side` | `"start" \| "end"` | `"end"` | Which side the picture sits on when there’s room for two columns. |
| `onStepChange` | `(index: number) => void` | – | Runs when a different step becomes active. |
## Stats band (`stats-band`)
Numbers that arrive when you do: hairlines draw themselves, then each figure rolls up from zero in turn.
```bash
npx shadcn@latest add https://hairlineui.com/r/stats-band.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`
- Full docs: https://hairlineui.com/ui/stats-band.md
```tsx
import { StatsBand } from "@/components/stats-band";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `stats` | `{ value; label; format?; prefix?; suffix? }[]` | – | Each figure and its label. format takes Intl.NumberFormat options. |
| `stagger` | `number` | `140` | Time between one stat starting and the next (ms). |
| `numberClassName` | `string` | – | Size and weight of the figures. |
## Announcement pill (`announcement-pill`)
"New: …" above your headline that opens into the news: the pill itself grows into a changelog card.
```bash
npx shadcn@latest add https://hairlineui.com/r/announcement-pill.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/announcement-pill.md
```tsx
import { AnnouncementPill } from "@/components/announcement-pill";
}>
Agents can now hand off
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | – | The announcement line. |
| `badge` | `string` | `"New"` | The tinted label at the start. |
| `more` | `string` | – | A second phrase that folds open on hover or keyboard focus. |
| `card / cardWidth` | `ReactNode / number` | `— / 360` | What the pill opens into, and how wide (never wider than the screen). |
| `href` | `string` | – | Without a card, the pill links here. |
## Testimonials (`testimonials`)
One quote, chosen by its people: faces step forward, the words cross-fade through a blur, a ring keeps time.
```bash
npx shadcn@latest add https://hairlineui.com/r/testimonials.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`
- Full docs: https://hairlineui.com/ui/testimonials.md
```tsx
import { Testimonials } from "@/components/testimonials";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `{ quote; name; role?; avatar? }[]` | – | Each person and what they said. Without an avatar, initials tinted from the name. |
| `autoplay` | `number` | – | Move to the next person every this many ms, with the ring as the timer. Off with reduced motion. |
| `defaultIndex` | `number` | `0` | Who speaks first. |
## FAQ accordion (`faq-accordion`)
Questions on hairlines that open like they mean it: the answer eases to its height, the plus folds into a minus.
```bash
npx shadcn@latest add https://hairlineui.com/r/faq-accordion.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/faq-accordion.md
```tsx
import { FaqAccordion } from "@/components/faq-accordion";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `{ question; answer; id? }[]` | – | Each question, and its answer as text or any React. |
| `type` | `"single" \| "multiple"` | `"single"` | One answer open at a time, or any number. |
| `defaultOpen` | `number[]` | `[]` | Questions open at first, by index. |
## Logo marquee (`logo-marquee`)
A quiet strip of logos that notices you: it eases to a stop on hover and the logo you point at stays bright.
```bash
npx shadcn@latest add https://hairlineui.com/r/logo-marquee.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/logo-marquee.md
```tsx
import { LogoMarquee } from "@/components/logo-marquee";
, …]} />
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `logos` | `ReactNode[]` | – | Images or inline SVGs, each with an accessible name. |
| `speed` | `number` | `36` | Pixels per second, whatever the strip’s length. |
| `gap` | `number` | `56` | Space between logos (px). |
| `direction` | `"left" \| "right"` | `"left"` | Which way the strip drifts. |
| `pauseOnHover` | `boolean` | `true` | Ease to a stop while pointed at. |
## Expanding gallery (`expanding-gallery`)
An image opens from its exact place in the grid, becomes a spacious viewer, and finds its way back.
```bash
npx shadcn@latest add https://hairlineui.com/r/expanding-gallery.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/expanding-gallery.md
```tsx
import { ExpandingGallery } from "@/components/expanding-gallery";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `images` | `GalleryImage[]` | – | Stable id, src, alt, title, width and height; optional description and meta. Intrinsic proportions are preserved. |
| `label` | `string` | `Selected work` | The gallery heading and viewer heading. |
| `onImageChange` | `(image: GalleryImage \| null) => void` | – | Called when a study opens or changes. Receives null on close. |
| `className` | `string` | – | Additional classes on the gallery root. Standard div attributes are forwarded. |
## Card deck (`card-deck`)
A tactile stack that fans out, follows your hand, and deals the next card with a measured flick.
```bash
npx shadcn@latest add https://hairlineui.com/r/card-deck.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`
- Full docs: https://hairlineui.com/ui/card-deck.md
```tsx
import { CardDeck } from "@/components/card-deck";
card.title}
renderCard={(card) =>
{card.title}
}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `T[] where T extends { id: string }` | – | The collection in reading order. IDs must be unique. |
| `renderCard` | `(item: T, state: { active: boolean; index: number }) => ReactNode` | – | Your card contents. Only the front card is interactive; rear cards and outgoing copies are inert. |
| `getLabel` | `(item: T) => string` | `item.id` | Accessible name for each card. |
| `value / defaultValue` | `string` | – | Controlled selected ID or initial selected ID. Defaults to the first card. |
| `onValueChange` | `(id: string) => void` | – | Receives the next selected ID after a gesture or button action. |
| `height` | `number` | `340` | Card height in CSS pixels, at least 120. Leave space for your longest content. |
| `label` | `string` | `Card collection` | Accessible label for the collection region. |
| `className` | `string` | – | Additional classes on the root. Standard div attributes are forwarded. |
## Workflow canvas (`workflow-canvas`)
Connected steps you can move, inspect and follow as work flows through them.
```bash
npx shadcn@latest add https://hairlineui.com/r/workflow-canvas.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/workflow-canvas.md
```tsx
import { WorkflowCanvas } from "@/components/workflow-canvas";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `nodes` | `WorkflowNode[]` | – | Unique id, title and x/y canvas coordinates. Optional subtitle, icon, status and detail. Nodes measure 164 × 78px. |
| `edges` | `{ id: string; from: string; to: string }[]` | – | Unique connection IDs and source/destination node IDs. Missing endpoints are ignored. |
| `width / height` | `number` | `660 / 340` | Canvas bounds in CSS pixels before zoom. Viewport height is 300px. |
| `label` | `string` | `Workflow` | Region label and visible canvas heading. |
| `onNodeMove` | `(id: string, position: { x: number; y: number }) => void` | – | Final coordinates after a committed drag. Persist these in your application. |
| `onNodeSelect` | `(id: string \| null) => void` | – | The inspected node ID, or null when its details close. |
| `className` | `string` | – | Additional root classes. Standard div attributes are forwarded. |
## Image compare (`image-compare`)
Two images, one precise divide. Drag to reveal what changed, then let the handle settle.
```bash
npx shadcn@latest add https://hairlineui.com/r/image-compare.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `text-morph`
- Full docs: https://hairlineui.com/ui/image-compare.md
```tsx
import { ImageCompare } from "@/components/image-compare";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `before / after` | `CompareImage` | – | src, alt and optional className for each image. Supply aligned images for a meaningful comparison. |
| `beforeLabel / afterLabel` | `string` | `Before / After` | Labels on each visible side. |
| `value / defaultValue` | `number` | `50` | Controlled or initial reveal percentage, clamped to 0–100. |
| `onValueChange / onValueCommit` | `(value: number) => void` | – | Continuous position updates and committed releases or keyboard changes. |
| `aspectRatio` | `number` | `1.6` | Width divided by height. Both images occupy this same frame. |
| `label` | `string` | `Image comparison` | Accessible name for the region and slider. |
## Magnifying dock (`magnifying-dock`)
A dock that makes room for the tool you reach for, then opens it in place.
```bash
npx shadcn@latest add https://hairlineui.com/r/magnifying-dock.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`
- Full docs: https://hairlineui.com/ui/magnifying-dock.md
```tsx
import { Inbox, CalendarDays, NotebookPen } from "lucide-react";
import { MagnifyingDock } from "@/components/magnifying-dock";
, badge: 3, content: },
{ id: "calendar", label: "Calendar", title: "Today", icon: , content: },
{ id: "notes", label: "Notes", icon: , content: },
]}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `DockItem[]` | – | Each tool: id, label, icon, and optionally content (what it opens into), title (the panel heading, defaults to label), badge (a count) and disabled. |
| `value / defaultValue` | `string` | – | Controlled selected tool or the initial one. Defaults to the first enabled tool. |
| `onValueChange` | `(id: string) => void` | – | Called when a different tool is chosen. |
| `open / defaultOpen` | `boolean` | – | Whether the selected tool’s panel is open. Tools without content never open. |
| `onOpenChange` | `(open: boolean) => void` | – | Called when the panel opens or folds away. |
| `size` | `number` | `40` | Rest size of a tool in pixels, 32–56. |
| `magnification` | `number` | `1.6` | Peak scale under the pointer, 1–2. It eases down to 1.22 while a panel is open so tools stay clear of it. |
| `panelWidth` | `number` | `340` | Width of the open panel; it never exceeds the space available. |
| `label` | `string` | `Tools` | Accessible name for the toolbar. |
## Hold to confirm (`hold-to-confirm`)
A deliberate action, held for a moment: the ring fills, the label changes, and confirmation settles into a check.
```bash
npx shadcn@latest add https://hairlineui.com/r/hold-to-confirm.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `text-morph`
- Full docs: https://hairlineui.com/ui/hold-to-confirm.md
```tsx
import { HoldToConfirm } from "@/components/hold-to-confirm";
{ await archiveProject(); }}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onConfirm` | `() => void \| Promise` | – | Runs once after the hold threshold. Resolves to success or rejects to error. |
| `duration` | `number` | `1100` | Required hold in milliseconds, clamped to 300–5000. |
| `resetAfter` | `number` | `2200` | Delay before returning to idle. Set 0 to keep the result visible. |
| `labels` | `Partial>` | – | Labels for idle, holding, pending, success and error. |
| `icon` | `ReactNode` | – | Idle and holding icon. Defaults to a fingerprint. |
| `disabled` | `boolean` | `false` | Disables the action and cancels an active hold. |
## Swipe actions (`swipe-actions`)
A list row that slides over its actions: cells grow with your finger, a full swipe runs the first one, and the row folds away.
```bash
npx shadcn@latest add https://hairlineui.com/r/swipe-actions.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/swipe-actions.md
```tsx
import { SwipeActions } from "@/components/swipe-actions";
import { Archive, MailOpen } from "lucide-react";
, tone: "primary", onAction: markRead }]}
trailing={[{ id: "archive", label: "Archive", icon: , tone: "primary", removes: true, onAction: archive }]}
>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | – | Names the row for screen readers and in announcements, e.g. the subject. |
| `leading` | `SwipeAction[]` | – | Under the left edge, revealed by swiping right. The first runs on a full swipe. |
| `trailing` | `SwipeAction[]` | – | Under the right edge, listed from the edge inward. The first runs on a full swipe. |
| `SwipeAction` | `{ id, label, icon, tone?, removes?, onAction }` | – | tone is "neutral" (muted), "primary" or "destructive". removes slides the row off and folds it away; onAction may return a promise and throw to undo that. |
| `meta` | `ReactNode` | – | At the row's end, e.g. the time. Gives way to the action icons on hover. |
| `enter` | `boolean` | `false` | Unfold on mount, for a row put back by an undo. |
| `divider` | `boolean` | `true` | A hairline under the row that folds away with it. Clip the last one with -mb-px on the list. |
| `disabled` | `boolean` | `false` | No gestures and no actions. |
## Drop zone (`drop-zone`)
A considered file uploader. Clear actions, quiet progress and useful feedback.
```bash
npx shadcn@latest add https://hairlineui.com/r/drop-zone.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`
- Full docs: https://hairlineui.com/ui/drop-zone.md
```tsx
import { DropZone } from "@/components/drop-zone";
{
await uploadFile(file, { onProgress: progress, signal });
}}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onUpload` | `DropZoneUpload` | – | Uploads a file with { progress, signal }. Resolve to mark Uploaded; throw to show Retry. Without this callback, files stay Ready. |
| `onFilesChange` | `(files: File[]) => void` | – | Current selection after adding, replacing or removing files. Progress updates do not trigger this callback. |
| `onFilesRejected` | `(rejections: DropZoneRejection[]) => void` | – | Rejected files with a reason and a type, size, count or duplicate code, for your own feedback or logging. |
| `accept` | `string` | – | Native file-input syntax: MIME types, wildcards or extensions, e.g. "image/*,.pdf". |
| `maxSize` | `number` | – | Maximum size per file in bytes. The limit appears in the default hint. |
| `maxFiles` | `number` | – | Maximum number of files in the selection. Excess files are skipped; valid files still get added. |
| `multiple` | `boolean` | `true` | false accepts one file and replaces the previous valid selection. An invalid replacement keeps the current file. |
| `title` | `string` | `"Drop files here"` | Heading inside the drop area. |
| `hint` | `string` | – | Custom file requirements below the heading. Defaults to the accepted types and configured limits. |
| `disabled` | `boolean` | `false` | Disables browsing, adding, removing and retrying. In-flight uploads continue. |
| `ref` | `Ref` | – | browse(), drop(files, at?), drag(x, y, types?) and cancel() for app-driven file selection. Coordinates are accepted only inside the drop area. |
| `className` | `string` | – | Styles the root. Standard div attributes and style are also forwarded. |
## Drop zone · Membrane (`drop-zone-membrane`)
A drop zone whose border is a membrane: it reaches for the file, holds it, swallows it and passes it through to its row.
```bash
npx shadcn@latest add https://hairlineui.com/r/drop-zone-membrane.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`
- Full docs: https://hairlineui.com/ui/drop-zone-membrane.md
```tsx
import { DropZoneMembrane } from "@/components/drop-zone-membrane";
{
await uploadFile(file, { onProgress: progress, signal });
}}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onUpload` | `(file, { progress, signal }) => void \| Promise` | – | Uploads one file. Call progress(0–1), honour the signal, resolve when done or throw to show an error with Retry. Without it, files are listed as ready. |
| `onFilesChange` | `(files: File[]) => void` | – | The files in the list after every add and remove, e.g. to keep a form field in step. |
| `accept` | `string` | – | MIME types, wildcards and extensions, like an input's accept: "image/*,.pdf". |
| `maxSize` | `number` | – | Largest file in bytes. Bigger files are refused with the limit as the reason. |
| `maxFiles` | `number` | – | How many files the list holds. |
| `multiple` | `boolean` | `true` | false takes one file at a time; a new drop replaces it. |
| `title` | `string` | `"Drop files here"` | The resting label. |
| `hint` | `string` | – | Under the label after "Browse or paste", and the label while a type is refused. |
| `disabled` | `boolean` | `false` | Ignores drags, drops and clicks. |
| `ref` | `Ref` | – | drag(x, y, types) → held, cancel(), drop(files, at?) and browse(), for drags your app runs itself (a file from a sidebar) and for touch. |
## Wheel picker (`wheel-picker`)
Drums you can fling: each column is a real cylinder that decays onto a row, with a lens in full colour over the middle.
```bash
npx shadcn@latest add https://hairlineui.com/r/wheel-picker.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`
- Full docs: https://hairlineui.com/ui/wheel-picker.md
```tsx
import { Wheel, WheelPicker } from "@/components/wheel-picker";
const hours = Array.from({ length: 12 }, (_, i) => i + 1);
const minutes = Array.from({ length: 60 }, (_, i) => ({ value: i, label: String(i).padStart(2, "0") }));
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `WheelPicker itemHeight` | `number` | `36` | Row height in px (28–56). The drum's radius, the band and the text size follow from it. |
| `WheelPicker haptics` | `boolean` | `true` | A 3ms vibration per row while a finger turns a wheel, on phones that support it. |
| `options` | `(T \| { value: T; label?: string; disabled?: boolean })[]` | – | Strings, numbers or objects. Disabled options are dimmed and the wheel turns past them. |
| `value / defaultValue` | `T` | – | Controlled or uncontrolled. A controlled change turns the wheel to the new option. |
| `onValueChange` | `(value: T) => void` | – | Called once the wheel comes to rest on a new option, not while it turns. |
| `loop` | `boolean` | `false` | Turn past the end back to the start, like minutes or months. |
| `label` | `string` | – | What the wheel sets, for screen readers: "Hours". |
| `unit` | `string \| (value: T) => string` | – | Fixed beside the lens, e.g. "min", or a function for "1 hour" / "2 hours". It morphs when the value changes. |
| `align` | `"start" \| "center" \| "end"` | `"center"` | Where the options sit in their column. |
## Morphing chart (`morphing-chart`)
Bars, line and area as one set of marks: bars condense into beads, a thread is strung through them, the fill pours down. Drag the switch to scrub it.
```bash
npx shadcn@latest add https://hairlineui.com/r/morphing-chart.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/morphing-chart.md
```tsx
import { MorphingChart } from "@/components/morphing-chart";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `labels` | `string[]` | – | Along the bottom, one per value. They thin out from the latest when there isn't room. |
| `values` | `number[]` | – | One per label. New values ease into place; negative values are drawn at zero. |
| `titles` | `string[]` | – | Longer names for the readout, e.g. "October 2025". Defaults to labels. |
| `shape / defaultShape` | `"bars" \| "line" \| "area"` | `"bars"` | Controlled or not. |
| `onShapeChange` | `(shape) => void` | – | Called when the reader picks a shape, or lets go of the thumb on one. |
| `title` | `string` | `"Revenue"` | Above the figure. It morphs when it changes. |
| `caption` | `string` | – | Under the figure, e.g. "Last 12 months". The point's name takes its place while one is read. |
| `format` | `Intl.NumberFormatOptions` | – | How the figure and the readout read. The scale uses the same options in compact notation. |
| `locales` | `string \| string[]` | – | For number formatting. |
## Range ruler (`range-ruler`)
Two hairline needles across a graduated scale. Thumbs glide without layout thrash and repel each other on contact.
```bash
npx shadcn@latest add https://hairlineui.com/r/range-ruler.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`
- Full docs: https://hairlineui.com/ui/range-ruler.md
```tsx
import { RangeRuler } from "@/components/range-ruler";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | – | Accessible label displayed above the ruler. |
| `value / defaultValue` | `[number, number]` | `[20, 80]` | Controlled or initial interval bounds. |
| `min / max / step` | `number` | `0 / 100 / 1` | Lower and upper boundaries and step increment. |
| `minDistance` | `number` | `0` | Minimum allowed distance between the two needles. |
| `unit` | `string` | – | Optional unit suffix for badges and readouts (e.g. $, ms, MB). |
| `onValueChange` | `(value: [number, number]) => void` | – | Fires during dragging for lightweight live previews. |
| `onValueCommit` | `(value: [number, number]) => void` | – | Fires on pointer release or keyboard adjustment. |
| `disabled` | `boolean` | `false` | Disables all interactions and cancels any active gesture. |
## Ratio balancer (`ratio-balancer`)
A multi-segment allocation track. Drag dividers to rebalance constrained percentages with zero initial jump.
```bash
npx shadcn@latest add https://hairlineui.com/r/ratio-balancer.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`
- Full docs: https://hairlineui.com/ui/ratio-balancer.md
```tsx
import { RatioBalancer } from "@/components/ratio-balancer";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | – | Accessible heading displayed above the allocation bar. |
| `segments / defaultSegments` | `RatioSegment[]` | – | Array of segment objects with id, label, and ratio (summing to 100). |
| `minRatio` | `number` | `5` | Minimum allowed percentage for any segment during drag. |
| `disabled` | `boolean` | `false` | Disables all divider dragging. |
| `onValueChange` | `(ratios: number[]) => void` | – | Fired during divider movement with immediate ratio updates. |
| `onValueCommit` | `(ratios: number[]) => void` | – | Fired upon pointer release or keyboard adjustment with clean normalized integers. |
## Split pane (`split-pane`)
A desktop-grade resizable dual panel. Zero-lag drag, rubber-band spring limits, and double-click preset snapping.
```bash
npx shadcn@latest add https://hairlineui.com/r/split-pane.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/split-pane.md
```tsx
import { SplitPane } from "@/components/split-pane";
Left Editor}
second={
Right Preview
}
onSplitCommit={setSplit}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `first / second` | `React.ReactNode` | – | Content rendered inside the left/top and right/bottom panes. |
| `split / defaultSplit` | `number` | `50` | Controlled or initial percentage allocated to the first pane. |
| `minSize / maxSize` | `number` | `15 / 85` | Boundary thresholds in percentage before rubber-band resistance begins. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Primary axis of separation. |
| `collapsible` | `boolean` | `true` | Enables pane collapse buttons on the divider. |
| `disabled` | `boolean` | `false` | Locks the divider in place and prevents resizing. |
| `onSplitChange` | `(split: number) => void` | – | Fired during pointer movement with raw split percentages. |
| `onSplitCommit` | `(split: number) => void` | – | Fired upon pointer release or keyboard snap with normalized value. |
## Tag input (`tag-input`)
The invite field, done properly: typed letters grow a chip round themselves, pasted lists spill out of the caret, and a duplicate flies into its twin.
```bash
npx shadcn@latest add https://hairlineui.com/r/tag-input.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`
- Full docs: https://hairlineui.com/ui/tag-input.md
```tsx
import { TagInput, type TagInputTag } from "@/components/tag-input";
const [tags, setTags] = useState([]);
v.toLowerCase()}
validate={(v) => (/^\S+@\S+\.\S+$/.test(v) ? null : `“${v}” isn’t an email address`)}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `TagInputTag[]` | – | Controlled tags: { id, value, label?, image?, error? }. |
| `defaultValue` | `TagInputTag[]` | – | Starting tags when uncontrolled. |
| `onValueChange` | `(tags: TagInputTag[]) => void` | – | Every add, remove, edit and resolve. Tags with an error are included so you can show them; filter them out before sending. |
| `validate` | `(value: string) => string \| null` | – | Return a message to mark a value invalid. The chip turns red and the message shows under the field. |
| `resolve` | `(value: string) => Promise<{ label?, image? } \| null>` | – | Looks a value up once it's added. Return a label (and image) for the chip to morph into, or null to leave it. |
| `suggestions` | `TagInputSuggestion[] \| (query) => TagInputSuggestion[] \| Promise<…>` | – | A list to filter by value, label and description, or a function (which may be async) that finds matches. Values already added are left out. |
| `normalize` | `(value: string) => string` | – | Applied before checking and comparing, e.g. lower-casing addresses so duplicates are caught. |
| `separators` | `RegExp` | `/[,;\n]+/` | What splits typed and pasted text. Add \s for addresses. |
| `max` | `number` | – | How many tags the field takes. More shakes the field and shows limitMessage. |
| `limitMessage` | `string` | `"No room for more"` | The line shown when a value doesn't fit under max. |
| `createLabel` | `((text) => string) \| null` | `(t) => `Add “${t}”`` | The suggestion row that adds what's typed as it is. null hides it. |
| `collapse` | `number` | – | Lines to show while the field is out of focus; the rest fold into a "+N" count. |
| `label` | `string` | `"Add tags"` | Accessible name of the field and its list. |
| `placeholder` | `string` | – | Shown while the field is empty. |
| `hint` | `string` | – | A line under the field. Errors and notices take its place while they last. |
| `name` | `string` | – | Submits the valid values, comma-separated, with a form. |
| `disabled` | `boolean` | `false` | Nothing can be added, edited or removed. |
| `ref` | `Ref` | – | paste(text), focus() and clear(), e.g. for a "Paste from spreadsheet" button or clearing after sending. |
## Avatar cropper (`avatar-cropper`)
The profile photo field without a modal: the avatar itself grows into the crop, and shrinks back with the face you framed.
```bash
npx shadcn@latest add https://hairlineui.com/r/avatar-cropper.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `status-button`, `text-morph`, `number-roll`
- Full docs: https://hairlineui.com/ui/avatar-cropper.md
```tsx
import { AvatarCropper } from "@/components/avatar-cropper";
{
const { url } = await uploadAvatar(photo);
return url;
}}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` | – | Whose photo: the initials when there's none, and the accessible names. |
| `onSave` | `(photo: Blob, area: CropArea) => Promise` | – | Receives the cropped square and { centerX, centerY, size, rotation } in the original's pixels. Resolve with the stored URL (or nothing to show the crop itself); throw to show the error. |
| `value` | `string \| null` | – | Controlled photo URL. |
| `defaultValue` | `string \| null` | – | Starting photo URL when uncontrolled. |
| `onValueChange` | `(url: string \| null) => void` | – | After a save lands or a removal. |
| `onRemove` | `() => Promise \| void` | – | Shows Remove while there's a photo. |
| `shape` | `"circle" \| "square"` | `"circle"` | "square" crops a rounded square, for workspace and team logos. |
| `size` | `number` | `64` | The avatar's size in px. |
| `outputSize` | `number` | `512` | Side of the saved square in px. |
| `outputType` | `string` | `"image/webp"` | Any type canvas.toBlob encodes, with outputQuality (0.9). |
| `maxSize` | `number` | `10_000_000` | Largest file in bytes; bigger ones are refused with a shake and the limit. |
| `maxZoom` | `number` | `4` | How far past the fit it zooms. |
| `title` | `string` | `"Profile photo"` | Beside the avatar. |
| `hint` | `string` | – | Under the title; it gives way to how to crop while editing. |
| `ref` | `Ref` | – | open(file \| blob \| url) and browse(), e.g. for a photo from your own picker or a camera. |
## Week calendar (`week-calendar`)
A scheduling week you hold in the hand: drag a slot into an event while its times roll, and anything it lands on eases aside.
```bash
npx shadcn@latest add https://hairlineui.com/r/week-calendar.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/week-calendar.md
```tsx
import { WeekCalendar, type CalendarEvent } from "@/components/week-calendar";
const [events, setEvents] = useState(initial);
api.events.create(event)}
onUpdate={(event) => api.events.update(event.id, event)}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `events` | `CalendarEvent[]` | – | Controlled events: { id, title, start, end, tone?, busy? }. Events on days in view are shown; tone picks one of the five chart colours. |
| `defaultEvents` | `CalendarEvent[]` | – | Starting events when uncontrolled. |
| `onEventsChange` | `(events: CalendarEvent[]) => void` | – | Every change at once, before any save settles. |
| `onCreate` | `(event) => Promise` | – | A new event once it's named. Resolve with the stored event to take its id; throw to take it back. |
| `onUpdate` | `(event, previous) => Promise` | – | An event moved or resized. Throw to put it back. |
| `onDelete` | `(event) => Promise` | – | Delete or Backspace on an event. Throw to bring it back. |
| `date` | `Date` | – | Controlled: any day in the range to show. |
| `defaultDate` | `Date` | `today` | Starting day when uncontrolled. |
| `onDateChange` | `(firstDay: Date) => void` | – | After ‹ › or Today. |
| `days` | `1 \| 3 \| 5 \| 7 \| "auto"` | `"auto"` | "auto" shows 7 from 560px wide, 3 from 300px, otherwise 1. 5 is Monday to Friday. |
| `weekStartsOn` | `0 \| 1` | `1` | Sunday or Monday. |
| `startHour / endHour` | `number` | `7 / 21` | The hours shown. |
| `step` | `number` | `15` | Snap in minutes. |
| `defaultDuration` | `number` | `30` | Length of an event made by a click or a hold. |
| `hourHeight` | `number` | `44` | Pixels per hour. |
| `height` | `number` | `420` | Height of the scrolling hours. |
| `locale` | `string` | – | For weekday, date and time formats (12 or 24 hour follows it). |
## Inline edit (`inline-edit`)
Rename a title, a project or a row where it stands: the text becomes the field without moving a pixel, and saves itself.
```bash
npx shadcn@latest add https://hairlineui.com/r/inline-edit.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`
- Full docs: https://hairlineui.com/ui/inline-edit.md
```tsx
import { InlineEdit } from "@/components/inline-edit";
renameProject(name)} />
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | – | Accessible name of the field, e.g. "Project name". |
| `value` | `string` | – | The saved text, controlled. A new value morphs in when you are not editing. |
| `defaultValue` | `string` | `""` | The saved text when uncontrolled. |
| `onValueChange` | `(value) => void` | – | Called with the stored text after a successful save. |
| `onSave` | `(value, previous) => Promise \| string \| void` | – | Saves the new text. Return what was stored (it morphs in if different); throw an Error to refuse with its message. |
| `validate` | `(value) => string \| null` | – | Refuses a value before it is sent; the message opens under the field. |
| `normalize` | `(value) => string` | `collapse spaces, trim` | Applied before validating and saving. |
| `wrap` | `boolean` | `false` | Fill the width and wrap onto more lines (titles). Off, it is one line that sizes to its text and truncates with an ellipsis at rest. |
| `maxLength` | `number` | – | Stops typing at this length and counts the last ten characters. |
| `blurAction` | `"save" \| "cancel"` | `"save"` | What leaving the field does with an edit. |
| `allowEmpty` | `boolean` | `false` | Saves an empty value instead of going back to the saved one. |
| `placeholder` | `string` | `"Untitled"` | Shown while the text is empty. |
| `name` | `string` | – | Submits the saved value with a form. |
## Combobox (`combobox`)
Pick one or several from a list your server searches: results morph between queries instead of flashing, and picks fly into chips.
```bash
npx shadcn@latest add https://hairlineui.com/r/combobox.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`, `status-button`
- Full docs: https://hairlineui.com/ui/combobox.md
```tsx
import { Combobox } from "@/components/combobox";
{
const res = await fetch(`/api/people?q=${encodeURIComponent(q)}`, { signal });
return res.json(); // [{ value, label, description?, image? }]
}}
onValueChange={(person) => assign(person?.value ?? null)}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | – | Accessible name, e.g. "Assignee". |
| `options` | `ComboboxOption[]` | – | A list to filter here: { value, label, description?, image?, initials?, icon?, keywords?, group?, disabled? }. Matches rank label starts, then word starts, then anywhere, then description and keywords. |
| `search` | `(query, { signal }) => Promise` | – | Ask your server instead. Called with "" when the panel opens; answers are cached per query; throw to show an error with Retry. |
| `multiple` | `boolean` | `false` | Pick several: value is a list, picks become chips and the panel stays open. |
| `value / defaultValue` | `ComboboxOption \| null, or ComboboxOption[]` | – | The picked option objects (not just ids), so a search result needs no lookup to show. |
| `onValueChange` | `(value) => void` | – | The option (or null) for one; the whole list for several. |
| `onCreate` | `(query) => Promise` | – | Offers "Create …" when nothing matches exactly. Return the new option, or nothing to use the text as it is. Throw to refuse. |
| `max` | `number` | – | How many can be picked when multiple; more shakes the row and says so. |
| `variant` | `"field" \| "pill"` | `"field"` | A 38px form field, or a 28px pill for sidebars and toolbars. |
| `debounce` | `number` | `180` | Milliseconds after the last keystroke before search runs. |
| `limit` | `number` | `100` | Rows shown at once; the rest wait for a narrower search. |
| `clearable` | `boolean` | `false` | A × that clears a single choice. |
| `width / align` | `number / "start" \| "end"` | `280 / "start"` | The panel's least width (never narrower than the field) and the edge it lines up with. |
| `name` | `string` | – | Submits each chosen value as a hidden input with a form. |
## Cron input (`cron-input`)
A schedule field people can read: cron or plain English in, the meaning in words and the next runs out, with clock changes flagged.
```bash
npx shadcn@latest add https://hairlineui.com/r/cron-input.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`
- Full docs: https://hairlineui.com/ui/cron-input.md
```tsx
import { CronInput } from "@/components/cron-input";
setSchedule(expression)}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value / defaultValue` | `string` | `"0 9 * * 1-5"` | The schedule as cron. defaultValue may also be English ("every weekday at 9am"); the field starts with the words. |
| `onValueChange` | `(expression, schedule) => void` | – | Each valid expression (tidied to single spaces, @macros expanded) and its parsed fields. |
| `timeZone / defaultTimeZone` | `string` | `"UTC"` | The IANA zone the schedule runs in; the runs are listed in it. Many schedulers (Vercel, GitHub Actions) only run in UTC. |
| `timeZones` | `string[]` | – | Zones to choose from. With two or more, the zone is a dropdown (Pill select) showing each zone's current offset. |
| `onTimeZoneChange` | `(timeZone) => void` | – | Called when a zone is picked. |
| `runs` | `number` | `5` | How many upcoming runs to list; 0 hides them. |
| `presets` | `{ label, expression }[]` | `CRON_PRESETS` | Shortcuts above the field; [] hides them. |
| `english` | `boolean` | `true` | Read schedules typed in English. |
| `hour12` | `boolean` | `false` | Write times as 1:30pm. |
| `label` | `string` | `"Schedule"` | Accessible name of the field. |
| `name` | `string` | – | Submits the expression with a form. |
## Time range picker (`time-range-picker`)
"Last 15 minutes" for logs, metrics and traces: type 2h or yesterday 14:00–16:00, step by the window, pause live, or drag across a chart to zoom.
```bash
npx shadcn@latest add https://hairlineui.com/r/time-range-picker.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`
- Full docs: https://hairlineui.com/ui/time-range-picker.md
```tsx
import { TimeRangePicker, resolveTimeRange, type TimeRange } from "@/components/time-range-picker";
const [range, setRange] = useState({ type: "relative", duration: 15 * 60_000 });
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value / defaultValue` | `TimeRange` | `{ type: "relative", duration: 3600000 }` | { type: "relative", duration } (the last N ms, live), { type: "since", start } (until now, live) or { type: "absolute", start, end }. |
| `onValueChange` | `(range, { start, end }) => void` | – | The new range and the times it covers at that moment. Store the range: a relative one keeps moving. |
| `presets` | `{ label, range }[]` | `TIME_RANGE_PRESETS` | Quick ranges. range may be a function of now (Today so far, Yesterday). |
| `timeZone` | `string` | – | Read and show wall-clock times in this IANA zone; the viewer's own by default. |
| `hour12` | `boolean` | `false` | Write times as 2pm. |
| `align` | `"start" \| "end"` | `"end"` | Which edge of the plate the panel lines up with. |
| `open / defaultOpen / onOpenChange` | `boolean / boolean / (open) => void` | – | Control the panel. |
| `label` | `string` | `"Time range"` | Accessible name of the plate. |
| `TimeBrush` | `{ start, end, onSelect, minSpan?, snap?, formatTime?, timeZone? }` | – | Wrap a chart whose x axis runs from start to end; onSelect gets the span dragged across. Spans under minSpan (60s) are ignored. snap rounds each edge to one the chart can show (a day boundary on a daily chart), and the band jumps between them while you drag; formatTime(time, edge) writes the edges, e.g. dates instead of hours. |
## JSON inspector (`json-inspector`)
Read, search and quote a webhook payload or an API response, or see exactly what changed between two versions of a record.
```bash
npx shadcn@latest add https://hairlineui.com/r/json-inspector.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`, `status-button`
- Full docs: https://hairlineui.com/ui/json-inspector.md
```tsx
import { JsonInspector } from "@/components/json-inspector";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `data` | `unknown` | – | A value to show. Circular references show as "[circular]". |
| `text` | `string` | – | JSON text instead of a value; if it doesn't parse, the error is shown with the line and column. |
| `compare` | `unknown` | – | The value before. With it, the inspector shows how compare became data. |
| `arrayKey` | `string` | – | In a diff, match array items by this key ("id") instead of by position. |
| `expandDepth` | `number` | `2` | Levels open at first (in a diff, everything with changes opens instead). |
| `title` | `ReactNode` | – | Shown at the top left, e.g. the event name and status. |
| `defaultQuery` | `string` | – | Start with this search, e.g. from a "find in payload" link. |
| `pathStyle` | `"js" \| "jq"` | `"js"` | How paths are written: data.items[0].id, or .data.items[0].id for jq. |
| `maxHeight` | `number` | `360` | Height of the scrolling body in px. |
| `label` | `string` | `"JSON"` | Accessible name of the tree. |
## Stepper input (`stepper-input`)
A tactile incrementer: tap to step, hold to accelerate, bounds give an elastic bumper shake, and digits roll in place.
```bash
npx shadcn@latest add https://hairlineui.com/r/stepper-input.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`
- Full docs: https://hairlineui.com/ui/stepper-input.md
```tsx
import { StepperInput } from "@/components/stepper-input";
updateSeats(seats)}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` | – | Accessible name for the stepper, announced to assistive technologies. |
| `value` | `number` | – | The controlled numeric value. |
| `defaultValue` | `number` | `0` | Initial numeric value when uncontrolled. |
| `onValueChange` | `(value: number) => void` | – | Callback invoked when the value changes. |
| `min` | `number` | `-Infinity` | Minimum allowed value. |
| `max` | `number` | `Infinity` | Maximum allowed value. |
| `step` | `number` | `1` | Amount to increment or decrement on single steps. |
| `largeStep` | `number` | – | Step multiplier for PageUp/PageDown and sustained acceleration (defaults to step * 10). |
| `unit` | `string` | – | Optional text suffix displayed beside the value (e.g. "seats", "px", "%"). |
| `format` | `Intl.NumberFormatOptions` | – | Formatting options forwarded to NumberRoll. |
| `variant` | `"pill" \| "field"` | `"pill"` | Border radius style: pill (rounded-full) or field (rounded-lg). |
| `size` | `"sm" \| "default"` | `"default"` | Control height and typography size: sm (28px) or default (36px). |
| `disabled` | `boolean` | `false` | Disables all interactions and dims the control. |
| `name` | `string` | – | Name attribute for hidden input submitted with HTML forms. |
## Sparkline (`sparkline`)
An ultra-compact inline trendline with magnetic scrubber and auto-trend color for table cells and stat cards.
```bash
npx shadcn@latest add https://hairlineui.com/r/sparkline.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/sparkline.md
```tsx
import { Sparkline } from "@/components/sparkline";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `data` | `(number \| { value: number; date?: string; label?: string })[]` | – | Array of data points or point objects with value and date. |
| `width` | `number \| string` | `"100%"` | Component width (can be percentage or pixel number). |
| `height` | `number` | `32` | Component height in pixels. |
| `color` | `"auto" \| "primary" \| "muted" \| string` | `"auto"` | Color scheme: auto (green/red based on trend), primary, or custom class. |
| `variant` | `"line" \| "area" \| "bars"` | `"area"` | Visual rendering style: line, area gradient, or bars. |
| `showScrubber` | `boolean` | `true` | Enables magnetic hover scrubber and micro-tooltip. |
| `showLastPoint` | `boolean` | `true` | Displays persistent anchor dot on the latest data point. |
| `strokeWidth` | `number` | `1.35` | Hairline stroke width in pixels. |
| `prefix` | `string` | – | Prefix symbol for tooltip values (e.g. "$"). |
| `suffix` | `string` | – | Suffix symbol for tooltip values (e.g. "%"). |
| `label` | `string` | `"Metric trend"` | Accessible label for assistive tech. |
## Privacy mask (`privacy-mask`)
A frosted optical unmasker for sensitive keys, salaries and tokens, with auto-hide timeout and one-tap copy.
```bash
npx shadcn@latest add https://hairlineui.com/r/privacy-mask.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/privacy-mask.md
```tsx
import { PrivacyMask } from "@/components/privacy-mask";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | – | The sensitive string to be masked and copied. |
| `label` | `string` | `"Confidential value"` | Accessible label for assistive technologies. |
| `trigger` | `"click" \| "hover" \| "hold"` | `"click"` | How the unmasking interaction is initiated. |
| `maskStyle` | `"blur" \| "dots" \| "truncate"` | `"blur"` | Visual mask format: optical blur, bullet dots, or truncated token. |
| `autoHideMs` | `number` | `6000` | Milliseconds before automatically re-masking (0 disables). |
| `copyable` | `boolean` | `true` | Shows an integrated copy button with self-drawing checkmark. |
## Data table (`data-table`)
A table whose rows travel: sort and every row glides to its rank, live data re-ranks itself, and selection turns the toolbar into its actions.
```bash
npx shadcn@latest add https://hairlineui.com/r/data-table.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`, `status-button`
- Full docs: https://hairlineui.com/ui/data-table.md
```tsx
import { DataTable, type DataTableColumn } from "@/components/data-table";
const columns: DataTableColumn[] = [
{ key: "company", header: "Customer", sortable: true, searchable: true },
{ key: "mrr", header: "MRR", align: "right", sortable: true },
];
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `columns` | `DataTableColumn[]` | – | { key, header, value?, cell?, align?, width?, sortable?, searchable?, editable?, card? }. value is what sorts, searches and edits (defaults to row[key]); cell is what shows. |
| `rows` | `T[]` | – | Your rows. Change them any time (new data, a subscription) and the table glides whatever moved. |
| `getRowId` | `(row: T) => string` | `row.id` | A stable id per row: it's what lets a row glide instead of jumping. |
| `noun` | `[string, string]` | `["row", "rows"]` | What a row is called in the count, the toolbar and announcements. |
| `sort / defaultSort / onSortChange` | `{ key, dir } \| null` | – | Controlled or starting sort. |
| `searchable` | `boolean` | `true` | Shows the search field; it looks in columns marked searchable, or all of them. |
| `selectable` | `boolean` | `false` | A drawn checkbox per row and in the header; Shift-click selects a range. |
| `selected / defaultSelected / onSelectedChange` | `string[]` | – | Selected row ids, controlled or not. |
| `actions` | `DataTableAction[]` | – | { id, label, progress?, tone?, removes?, run }. Shown in the toolbar while rows are selected; run gets the selected rows. A removing action takes them out at once and runs after undoFor. |
| `undoFor` | `number` | `5000` | How long Undo is offered after a removing action, ms. |
| `onEdit` | `(row, key, value) => Promise` | – | Saves an edited cell (columns with editable). Throw to put the old value back. |
| `renderDetail` | `(row: T) => ReactNode` | – | Rows open into this, with a chevron at the end of each. |
| `pageSize` | `number` | `10` | Rows per page. |
| `label` | `string` | `"Table"` | Accessible name of the grid. |
## Kanban board (`kanban-board`)
A pipeline you rearrange by hand: the card becomes the stage it's over while still in the air, and every column's total rolls as it passes.
```bash
npx shadcn@latest add https://hairlineui.com/r/kanban-board.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/kanban-board.md
```tsx
import { KanbanBoard } from "@/components/kanban-board";
{issue.title}
}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `columns` | `KanbanColumn[]` | – | { id, title, tone?, limit?, done? }. tone picks one of the five chart colours for its chip; limit caps it; done draws a check on its chip. |
| `cards / defaultCards` | `T extends { id, column }[]` | – | The cards, in the order they stand in their columns. |
| `onCardsChange` | `(cards: T[]) => void` | – | The new order after every move, add and failed-save revert. |
| `renderCard` | `(card, { column, lifted }) => ReactNode` | – | The card's own content. The board adds the status chip (the column it's over while carried) and the value under it. |
| `cardLabel` | `(card: T) => string` | – | How a card is named in its accessible name and announcements. |
| `refuse` | `(card, column) => string \| null` | – | A reason a column won't take a card; the column says it while the card is over it. Limits are checked for you. |
| `onMove` | `(card, to, index) => Promise` | – | Saves a move (the card already has its new column). Throw to fly it back. |
| `value` | `(card: T) => number \| null` | – | An amount per card, shown on it and totalled (rolling) in each column head. |
| `valueFormat / locales` | `Intl.NumberFormatOptions / string` | – | How amounts are written, e.g. { style: "currency", currency: "USD" }. |
| `onAdd` | `(column, title) => T \| Promise` | – | Shows "+ Add …" at the foot of each column; return the new card. |
| `addLabel` | `string` | `"card"` | The noun in "+ Add …" and the new card's field. |
| `label` | `string` | `"Board"` | Accessible name of the board. |
## Filter bar (`filter-bar`)
The bar above a list, done like Linear’s: one panel from property to value, chips you change in place, and a sentence that becomes filters.
```bash
npx shadcn@latest add https://hairlineui.com/r/filter-bar.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`
- Full docs: https://hairlineui.com/ui/filter-bar.md
```tsx
import { FilterBar, matchFilters, type Filter, type FilterField } from "@/components/filter-bar";
const fields: FilterField[] = [
{ id: "status", label: "Status", type: "option", options: [{ value: "todo", label: "Todo" }, { value: "done", label: "Done" }] },
{ id: "title", label: "Title", type: "text" },
{ id: "updated", label: "Updated", type: "date" },
];
const [filters, setFilters] = useState([]);
const rows = matchFilters(issues, filters, fields);
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `fields` | `FilterField[]` | – | { id, label, type, icon?, options?, unit?, get? }. type is "option" (pick values), "text" (contains), "number" (at least, at most, exactly) or "date" (within or older than a number of days). |
| `value / defaultValue` | `Filter[]` | – | The filters: { id, field, op, values } for options, { …, text } for text, { …, number } for numbers, { …, days } for dates. |
| `onValueChange` | `(filters: Filter[]) => void` | – | Every add, change and removal. |
| `count / total` | `number` | – | How many rows match, and how many there are: "37 of 128 issues" rolls beside the bar. Leave count out when the list shows its own. |
| `noun` | `[string, string]` | `["result", "results"]` | What a row is called, for the count. |
| `parse` | `(text) => NewFilter[] \| Promise` | – | Turns a sentence into filters. Adds "Filter by “…”" to the panel for searches of three letters or more. Throw, or return none, to say it couldn't. |
| `hotkey` | `string \| null` | `"f"` | Opens the panel from anywhere on the page while nothing is being typed. null turns it off. |
| `locales` | `string` | – | For numbers in chips and the count. |
| `label` | `string` | `"Filters"` | Accessible name of the bar. |
| `matchFilters(rows, filters, fields)` | `function` | – | Keeps the rows every filter allows, reading each field with get (or row[id]). Arrays match an option filter if any value does. |
## Tree view (`tree-view`)
The sidebar of pages, folders or files: carry a row up, down and sideways to nest it, and folders open as you hold them.
```bash
npx shadcn@latest add https://hairlineui.com/r/tree-view.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/tree-view.md
```tsx
import { TreeView, type TreeNode } from "@/components/tree-view";
const [nodes, setNodes] = useState([
{ id: "docs", parent: null, title: "Docs" },
{ id: "intro", parent: "docs", title: "Introduction" },
{ id: "readme", parent: null, title: "README.md", leaf: true },
]);
router.push(`/p/${n.id}`)} />
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `nodes / defaultNodes` | `T extends { id, parent, title, leaf? }[]` | – | Every node, flat; parent is null at the top level. Siblings stand in the order they appear. leaf rows can't hold others. |
| `onNodesChange` | `(nodes: T[]) => void` | – | The new list after every move, rename, add, delete and undo. |
| `expanded / defaultExpanded / onExpandedChange` | `string[]` | – | Ids of the open rows. |
| `selected / defaultSelected` | `string \| null` | – | The chosen row. |
| `onSelect` | `(node: T) => void` | – | A row chosen by click, Enter or Space, e.g. to open its page. |
| `icon` | `(node, { expanded, hasChildren }) => ReactNode` | – | The row's icon. Defaults to a folder (open or closed), or a file for leaves. |
| `canDrop` | `(node, parent: T \| null) => string \| null` | – | A reason parent won't take node; null is the top level. Moving a row into itself or into a leaf is refused for you. |
| `onMove` | `(node, parent, index) => Promise` | – | Saves a move (node already has its new parent). Throw to put it back. |
| `onRename` | `(node, title) => Promise` | – | Turns on renaming (F2, double-click). Throw to put the old title back. |
| `onCreate` | `(parent, title) => T \| Promise` | – | Turns on + (inside rows, and New … at the foot). Return the stored node. |
| `onDelete` | `(node) => Promise` | – | Turns on delete. Called once the chance to undo is over; throw to bring the row back. |
| `addLabel` | `string` | `"page"` | The noun in "New …". |
| `undoFor` | `number` | `5000` | How long Undo is offered after a delete, ms. |
| `indent` | `number` | `14` | Pixels per level. |
| `label` | `string` | `"Pages"` | Accessible name of the tree. |
## Doc comments (`doc-comments`)
Review threads beside the text they quote: the one you choose lines up with its line, and the rest make way.
```bash
npx shadcn@latest add https://hairlineui.com/r/doc-comments.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `status-button`, `text-morph`
- Full docs: https://hairlineui.com/ui/doc-comments.md
```tsx
import { DocComments, type DocThread } from "@/components/doc-comments";
const [threads, setThreads] = useState([]);
{markdown}
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | – | The document. Any markup; it's read to find quotes and never changed. |
| `threads / defaultThreads` | `DocThread[]` | – | { id, anchor: { quote, prefix?, suffix? }, comments: { id, author, body, createdAt }[], resolved? }. Resolved threads aren't shown; a thread whose quote can't be found waits until it can. |
| `onThreadsChange` | `(threads: DocThread[]) => void` | – | Every new thread, reply and resolve. |
| `user` | `{ id, name, image? }` | – | Who is commenting. |
| `onCreate` | `(anchor, body) => Promise` | – | Turns on commenting on a selection. Resolve with the stored thread; throw to keep the words and offer Try again. |
| `onReply` | `(thread, body) => Promise` | – | Turns on replies. Resolve with the stored comment; throw to keep the words and offer Try again. |
| `onResolve` | `(thread) => Promise` | – | Turns on ✓. Runs once the chance to undo is over; throw to open the thread again. |
| `undoFor` | `number` | `4000` | How long Undo is offered after resolving, ms. |
| `locales` | `string` | – | For dates older than yesterday. |
| `label` | `string` | `"Comments"` | Accessible name of the whole. |
## API keys (`api-keys`)
The API keys screen, done properly: the secret rises in once, and Done folds it down into the "sk_live_••••4f2a" it leaves behind.
```bash
npx shadcn@latest add https://hairlineui.com/r/api-keys.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`, `status-button`
- Full docs: https://hairlineui.com/ui/api-keys.md
```tsx
import { ApiKeys } from "@/components/api-keys";
{
const res = await fetch("/api/keys", { method: "POST", body: JSON.stringify({ name, scopes }) });
if (!res.ok) throw new Error((await res.json()).error);
return res.json(); // { key, secret }
}}
onRevoke={(key) => fetch(`/api/keys/${key.id}`, { method: "DELETE" })}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `ApiKey[]` | – | Controlled keys: { id, name, prefix?, last4, scopes?, createdAt?, lastUsedAt?, expiresAt? }. Never the secret. |
| `defaultValue` | `ApiKey[]` | – | Starting keys when uncontrolled. |
| `onValueChange` | `(keys: ApiKey[]) => void` | – | The list after every create, roll and revoke, newest first. |
| `onCreate` | `({ name, scopes }) => Promise<{ key, secret }>` | – | Creates a key. Return it with the whole secret, which is shown once; throw to keep the form and show the error's message. |
| `onRoll` | `(key) => Promise<{ key, secret, previous? }>` | – | Issues a new secret. Return previous (the old key with expiresAt) to keep it listed while it still works. Without onRoll there is no roll button. |
| `onRevoke` | `(key) => Promise` | – | Revokes a key; the row leaves once it resolves. Throw to show Try again. Without it there is no bin. |
| `scopes` | `{ value, label }[]` | – | What a key may do; the form shows them as toggles and asks for at least one. |
| `defaultScopes` | `string[]` | – | Scopes a new key starts with. All of them by default. |
| `title` | `string` | `"API keys"` | Heading and the list's accessible name. |
| `description` | `string` | – | The line under the heading. |
| `placeholder` | `string` | `"e.g. Production server"` | The name field's placeholder. |
## Team members (`team-members`)
Settings → Members, done properly: invited addresses fly out of the field onto their rows, role pills grow into the list of roles, and seats roll.
```bash
npx shadcn@latest add https://hairlineui.com/r/team-members.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `tag-input`, `text-morph`, `number-roll`, `status-button`
- Full docs: https://hairlineui.com/ui/team-members.md
```tsx
import { TeamMembers } from "@/components/team-members";
api.invite(emails, role)}
onRoleChange={(member, role) => api.setRole(member.id, role)}
onRemove={(member) => api.remove(member.id)}
onResend={(member) => api.resend(member.id)}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `TeamMember[]` | – | Controlled members and invites: { id, email, name?, image?, role, status?, invitedAt?, lastActiveAt?, you?, locked? }. |
| `defaultValue` | `TeamMember[]` | – | Starting members when uncontrolled. |
| `onValueChange` | `(members: TeamMember[]) => void` | – | The list after every invite, role change, resend and removal. New invites come first. |
| `roles` | `{ value, label, description? }[]` | – | Roles people can be given, in menu order. A member whose role isn't listed (an owner) shows it as plain text. |
| `inviteRole` | `string` | – | The role new invites start with. The second role by default. |
| `seats` | `number` | – | Seats on the plan. Members and pending invites each take one; the field takes no more than are left. |
| `onInvite` | `(emails, role) => Promise` | – | Sends invites and returns the new members (status "invited"). Throw to keep the addresses and show the message. Without it there is no invite field. |
| `onRoleChange` | `(member, role) => Promise` | – | Saves a role; the pill changes at once and changes back if this throws. Without it roles are plain text. |
| `onRemove` | `(member) => Promise` | – | Removes a member or cancels an invite. Never offered for you or locked members. |
| `onResend` | `(member) => Promise` | – | Sends an invite again; the clock restarts from now. |
| `resendAfter` | `number` | `30` | Seconds before an invite can be sent again. |
| `suggestions` | `TagInputSuggestion[] \| (query) => …` | – | People to suggest in the invite field, e.g. from your directory. |
| `title` | `string` | `"Members"` | Heading and the list's accessible name. |
| `description` | `string` | – | The line under the heading. |
## Settings form (`settings-form`)
A settings page that saves the way people expect: switches save themselves, text edits gather into one bar that rises with "2 unsaved changes".
```bash
npx shadcn@latest add https://hairlineui.com/r/settings-form.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`, `status-button`
- Full docs: https://hairlineui.com/ui/settings-form.md
```tsx
import { SettingsForm } from "@/components/settings-form";
saveSettings(patch)}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `sections` | `{ title, description?, fields }[]` | – | Groups of fields. A field is { type: "toggle" \| "choice" \| "text" \| "textarea", name, label, description? } plus options for a choice and placeholder, prefix, rows, maxLength, validate, check and checkPassed for text. |
| `value` | `Record` | – | Saved values, controlled. Edits live in the form until they're saved. |
| `defaultValue` | `Record` | – | Saved values when uncontrolled. |
| `onValueChange` | `(values) => void` | – | The saved values after every successful save. |
| `onSave` | `(patch, values) => Promise` | – | Saves just the changed values (one field for a switch, every edit for the bar). Return what was stored; throw an Error to show its message, or SettingsFormError(message, { field: reason }) to put reasons under fields. |
| `autosave` | `boolean` | `true` | Toggles and choices save the moment they change. Set false to send them with the bar like text. |
| `warnOnLeave` | `boolean` | `true` | Asks before the page unloads while there are unsaved edits. |
| `field.validate` | `(value, values) => string \| null` | – | Refuses a text value before it's sent; the message opens under the field. |
| `field.check` | `(value, { signal }) => Promise` | – | An async check run 450ms after typing stops. Return null when the value is fine or the reason it isn't; a taken value can't be saved. |
## Auth card (`auth-card`)
Sign-in that asks one thing at a time: the email field shrinks into a chip round the address, and your server decides whether a password, a code or a link comes next.
```bash
npx shadcn@latest add https://hairlineui.com/r/auth-card.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `code-input`, `number-roll`, `status-button`, `text-morph`
- Full docs: https://hairlineui.com/ui/auth-card.md
```tsx
import { AuthCard } from "@/components/auth-card";
(await hasPassword(email) ? "password" : "code")}
onPassword={(email, password) => signIn(email, password)}
onCode={(email, code) => verify(email, code)}
onSuccess={() => router.push("/app")}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onEmail` | `(email) => Promise<"password" \| "code" \| "link">` | – | Asks your server what this address needs next (and sends the code or link if that's it). Throw to keep the field and show the message. |
| `onPassword` | `(email, password) => Promise` | – | Signs in with a password. Throw when it's wrong. |
| `onCode` | `(email, code) => Promise` | – | Checks a one-time code. Resolve when it's right; throw when it isn't. |
| `onResend` | `(email) => Promise` | – | Sends the code or link again. Without it there is no resend. |
| `onForgot` | `(email) => Promise` | – | Sends a password reset link; shows "Forgot password?" on the password step. |
| `providers` | `{ id, label, icon? }[]` | – | Sign-in providers above the email field, two to a row. |
| `onProvider` | `(id) => Promise` | – | Starts a provider's sign-in. A redirect keeps its button spinning; a popup that resolves moves the card to done. |
| `onPasskey` | `() => Promise` | – | Shows "Sign in with a passkey" and sets the email field's autocomplete to "username webauthn" for passkey autofill. |
| `onSuccess` | `() => void` | – | Runs after "Signed in" has shown, e.g. to redirect. |
| `mode` | `"sign-in" \| "sign-up"` | `"sign-in"` | Sets the default words and the password field's autocomplete. |
| `copy` | `Partial>` | – | Words per step: email, password, code, link, reset and done. |
| `logo` | `ReactNode` | – | Above the title. |
| `footer` | `ReactNode` | – | Under the email step: a link to sign up, terms. It folds away after the first step. |
| `defaultEmail` | `string` | – | Starts the field with an address, e.g. from an invite link. |
| `codeLength` | `number` | `6` | Digits in a one-time code. |
| `resendAfter` | `number` | `30` | Seconds before a code or link can be sent again. |
## Webhooks (`webhooks`)
Settings → Webhooks, done properly: "Send test" runs a bead down the wire to the endpoint and back in the answer's colour, and every delivery opens into its request and response.
```bash
npx shadcn@latest add https://hairlineui.com/r/webhooks.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`, `status-button`
- Full docs: https://hairlineui.com/ui/webhooks.md
```tsx
import { Webhooks } from "@/components/webhooks";
api.createEndpoint(input)}
onTest={(endpoint, event) => api.sendTest(endpoint.id, event)} // → the delivery
loadDeliveries={(endpoint, before) => api.deliveries(endpoint.id, before?.id)}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `events` | `{ value, description? }[]` | – | Event types an endpoint can subscribe to. |
| `onCreate` | `({ url, events }) => Promise` | – | Creates an endpoint: { id, url, events, enabled?, lastDelivery? }. Throw to keep the form and show the message. |
| `onTest` | `(endpoint, event) => Promise` | – | Sends a test and returns the delivery: { id, event, status (null for no answer), durationMs?, at, attempt?, request?, response? }. |
| `loadDeliveries` | `(endpoint, before?) => Promise` | – | Recent deliveries, newest first, loaded the first time an endpoint opens; before is the oldest one shown when paging back. Return an empty list when there are no more. |
| `onRedeliver` | `(delivery, endpoint) => Promise` | – | Sends a delivery again and returns the new attempt. Without it there is no Redeliver. |
| `onRevealSecret` | `(endpoint) => Promise` | – | Fetches the signing secret when someone asks to see it. Without it the secret stays dots. |
| `onToggle` | `(endpoint, enabled) => Promise` | – | Turns an endpoint on or off; the switch moves at once and goes back if this throws. |
| `onDelete` | `(endpoint) => Promise` | – | Deletes an endpoint after a confirm. |
| `value` | `WebhookEndpoint[]` | – | Controlled endpoints. lastDelivery updates after every test and redelivery. |
| `defaultValue` | `WebhookEndpoint[]` | – | Starting endpoints when uncontrolled. |
| `onValueChange` | `(endpoints) => void` | – | Every add, toggle, delete and new delivery. |
| `appName` | `string` | `"Your app"` | The near end of the test's wire. |
## Domain connect (`domain-connect`)
A custom domain from typed to live: copyable records that tick green as DNS finds them, checks that wait longer each time, and a rail that runs to Live.
```bash
npx shadcn@latest add https://hairlineui.com/r/domain-connect.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`, `status-button`
- Full docs: https://hairlineui.com/ui/domain-connect.md
```tsx
import { DomainConnect } from "@/components/domain-connect";
api.addDomain(domain)} // → { domain, status, records }
check={(domain, { signal }) => api.getDomain(domain, { signal })}
onRemove={(domain) => api.removeDomain(domain)}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onAdd` | `(domain) => Promise` | – | Registers the domain (already cut down to its host) and returns { domain, status, records, message? }. Throw to keep the field and show the message. |
| `check` | `(domain, { signal }) => Promise` | – | Reports what your server sees now. Called on the schedule and by Check now; the signal aborts it when the domain is removed or a newer check starts. |
| `onRemove` | `(domain) => Promise` | – | Removes the domain; the card goes back to the field. Without it there is no Remove. |
| `value` | `DomainState \| null` | – | Controlled state. status is "pending", "issuing", "active" or "failed"; each record is { type, name, value, priority?, status? }. |
| `defaultValue` | `DomainState \| null` | – | A domain that was already added, when uncontrolled. |
| `onValueChange` | `(state \| null) => void` | – | Every add, check and removal. |
| `schedule` | `number[]` | `[10, 20, 30, 60, 120, 300]` | Seconds before each automatic check; the last one repeats. Checks wait while the tab is hidden. |
| `title` | `string` | `"Custom domain"` | The card's heading. |
| `description` | `string` | – | The line under the heading. |
| `placeholder` | `string` | `"app.yourcompany.com"` | The field's placeholder and the example in its error. |
## Log stream (`log-stream`)
A live tail you can actually read: scroll up and it lets go while a "↓ 128 new" pill counts what arrives, with pause, level counts, search and lines that open into their fields.
```bash
npx shadcn@latest add https://hairlineui.com/r/log-stream.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`
- Full docs: https://hairlineui.com/ui/log-stream.md
```tsx
import { LogStream } from "@/components/log-stream";
{
const source = new EventSource("/api/logs/stream");
source.onmessage = (e) => push(JSON.parse(e.data));
return () => source.close();
}}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `subscribe` | `(push) => () => void` | – | Starts the tail. Call push with one line or many, as often as you like; return a function that stops it. |
| `defaultValue` | `LogLine[]` | – | Lines to start with, oldest first: { id, at, level, message, source?, data? }. |
| `loadEarlier` | `(before: LogLine) => Promise` | – | Loads lines older than the first one, oldest first. Return an empty list when there are no more. |
| `max` | `number` | `1000` | Lines kept while the view is pinned to the bottom. While you're reading further up nothing is dropped. |
| `height` | `number \| string` | `420` | The scrolling area's height. |
| `title` | `string` | `"Logs"` | Names the list and the search field for screen readers. |
## CSV import (`csv-import`)
Contacts → Import, done properly: your file's columns thread themselves to your fields, bad cells become fields you fix in place, and the rows go to your server in batches.
```bash
npx shadcn@latest add https://hairlineui.com/r/csv-import.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`, `status-button`
- Full docs: https://hairlineui.com/ui/csv-import.md
```tsx
import { CsvImport } from "@/components/csv-import";
api.importContacts(rows, { signal })}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `fields` | `CsvField[]` | – | { key, label, required?, match?, validate? }. match lists other column names to recognise; validate returns why a value won't do. |
| `onImport` | `(rows, { signal, start }) => Promise<{ failed? } \| void>` | – | Saves one batch of rows shaped { [key]: value }. Return failed: [{ index, reason }] for rows your server refused; throw to mark the whole batch skipped. |
| `batchSize` | `number` | `250` | Rows per onImport call. |
| `maxRows` | `number` | `50000` | Files with more rows are refused before matching. |
| `noun` | `{ one, many }` | `{ one: "row", many: "rows" }` | What a row is, for the counts and buttons. |
| `templateHref` | `string` | – | A "Download a template" link under the drop target. |
| `title` | `string` | `"Import"` | The card's heading. |
| `description` | `string` | – | The line under the heading. |
| `onDone` | `({ imported, skipped, cancelled }) => void` | – | After the last batch, e.g. to refresh the list behind it. |
| `ref` | `Ref` | – | load(text, name?) reads text as if it were a dropped file, e.g. from a paste or a sample. |
## Notification inbox (`notification-inbox`)
The bell, done properly: its own circle grows into the inbox, new notifications swing it and roll the count, and read ones fold out of Unread.
```bash
npx shadcn@latest add https://hairlineui.com/r/notification-inbox.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`, `status-button`
- Full docs: https://hairlineui.com/ui/notification-inbox.md
```tsx
import { NotificationInbox } from "@/components/notification-inbox";
feed.on("notification", push)}
onRead={(ids, read) => api.markRead(ids, read)}
onArchive={(ids) => api.archive(ids)}
onOpen={(n) => n.href && router.push(n.href)}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `InboxItem[]` | – | Controlled notifications, newest first: { id, title, body?, at, read?, actor?, href? }. |
| `defaultValue` | `InboxItem[]` | – | Starting notifications when uncontrolled. |
| `onValueChange` | `(items) => void` | – | Every arrival, read change and archive. |
| `subscribe` | `(push) => () => void` | – | Feeds new notifications; call push with one or many. Duplicates by id are ignored. |
| `onRead` | `(ids, read) => Promise` | – | Saves read state; the change shows at once and is undone if this throws. Without it there is no Mark all read. |
| `onArchive` | `(ids) => Promise` | – | Archives; the row leaves at once and comes back if this throws. Without it there is no archive button. |
| `onOpen` | `(item) => void` | – | Runs when a notification is opened, after it's marked read. Items with href are links. |
| `loadMore` | `(before) => Promise` | – | Older notifications for the All tab; an empty list hides Show older. |
| `align` | `"end" \| "start"` | `"end"` | Which edge of the bell the inbox lines up with. |
| `label` | `string` | `"Inbox"` | Names the bell and the dialog. |
| `defaultOpen` | `boolean` | `false` | Starts open. |
## Integrations (`integrations`)
Settings → Integrations, done properly: Connect waits on the provider's own window, then folds away as the account it connected opens in its place.
```bash
npx shadcn@latest add https://hairlineui.com/r/integrations.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`, `status-button`
- Full docs: https://hairlineui.com/ui/integrations.md
```tsx
import { Integrations, openOAuthWindow } from "@/components/integrations";
{
const { account } = await openOAuthWindow(`/api/oauth/${i.id}/start`);
return account;
}}
onDisconnect={(i) => fetch(`/api/integrations/${i.id}`, { method: "DELETE" })}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onConnect` | `(integration) => Promise<{ name, detail?, image? }>` | – | Runs the provider's sign-in and returns the account it connected. Called inside the click. Throw to show Try again and the message. |
| `onDisconnect` | `(integration) => Promise` | – | Disconnects after a confirm. Without it there is no Disconnect. |
| `value` | `Integration[]` | – | Controlled list: { id, name, description?, icon?, category?, account?, scopes? }. |
| `defaultValue` | `Integration[]` | – | Starting list when uncontrolled. |
| `onValueChange` | `(integrations) => void` | – | After every connect and disconnect. |
| `settingsHref` | `(integration) => string \| undefined` | – | A Settings link beside a connected integration's name. |
| `title` | `string` | `"Integrations"` | Heading and the list's accessible name. |
| `description` | `string` | – | The line under the heading. |
| `openOAuthWindow` | `(url, { name?, width?, height?, type?, timeout? }) => Promise` | – | Exported helper: opens a centred window and resolves with what your callback posts ({ type: "oauth:done", … }) by postMessage or BroadcastChannel; rejects if the window closes first, is blocked or times out. |
## Status bars (`status-bars`)
A status page that reads at a glance: ninety days a service drawn up bar by bar, one card that glides between them, and incidents whose updates open in as they're posted.
```bash
npx shadcn@latest add https://hairlineui.com/r/status-bars.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`, `number-roll`
- Full docs: https://hairlineui.com/ui/status-bars.md
```tsx
import { StatusBars } from "@/components/status-bars";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `services` | `StatusService[]` | – | { id, name, status ("operational" \| "degraded" \| "major" \| "maintenance"), uptime?, days }. days are oldest first, the last one today: { date: "2026-10-14", status, minutes?, incidents? }. |
| `incidents` | `StatusIncident[]` | – | { id, title, impact ("minor" \| "major" \| "maintenance"), stage, services?, updates }. updates are newest first: { id, at, stage, message }. Resolved ones fold into Past incidents. |
| `updatedAt` | `Date \| string \| number` | – | When the data was checked; shown as "Updated 2 min ago" and kept current. |
| `title` | `string` | `"Status"` | Read before the overall line by screen readers. |
## Command palette (`command-palette`)
⌘K that grows out of the search pill: fuzzy search, nested lists, and commands that run in place.
```bash
npx shadcn@latest add https://hairlineui.com/r/command-palette.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`
- Full docs: https://hairlineui.com/ui/command-palette.md
```tsx
import { CommandPalette, type CommandItem } from "@/components/command-palette";
const items: CommandItem[] = [
{ id: "orders", group: "Go to", label: "Orders", href: "/orders", shortcut: ["G", "O"] },
{ id: "export", group: "Actions", label: "Export sales", onSelect: () => exportCsv() },
{ id: "theme", group: "Actions", label: "Change theme", items: [
{ id: "light", label: "Light", onSelect: () => setTheme("light") },
{ id: "dark", label: "Dark", onSelect: () => setTheme("dark") },
] },
];
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `CommandItem[]` | – | id, label, and any of group, icon, shortcut, keywords, description, href, onSelect (return a promise to show progress) and items (a nested list). |
| `placeholder` | `string` | `"Search or jump to…"` | The field’s placeholder on the first page. |
| `hotkey` | `string` | `"k"` | The letter that toggles it with ⌘ or Ctrl; an empty string turns it off. |
| `open / defaultOpen / onOpenChange` | `boolean / boolean / (open) => void` | – | Control it, or let it manage itself. |
| `showTrigger / triggerLabel` | `boolean / string` | `true / "Search…"` | The pill it grows from, and its words. |
## Toast stack (`toast-stack`)
Notifications that stack, fan out on hover, follow a promise in one toast, and swipe away.
```bash
npx shadcn@latest add https://hairlineui.com/r/toast-stack.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`
- Full docs: https://hairlineui.com/ui/toast-stack.md
```tsx
import { Toaster, toast } from "@/components/toast-stack";
// Once, near the root of your app:
// Anywhere:
toast("Order archived", { action: { label: "Undo", onClick: restore } });
toast.success("Invite sent");
toast.promise(save(), { loading: "Saving changes", success: "Changes saved", error: "Couldn't save" });
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `toast(title, options)` | `function` | – | Shows a toast and returns its id. Options: description, type, duration, action { label, onClick }, id (to update one in place). |
| `toast.success / error / info / loading` | `function` | – | The same with a type, and its icon. |
| `toast.promise(promise, messages)` | `function` | – | One toast that morphs from loading to success or error. Messages can be functions of the result or error. |
| `toast.dismiss(id?)` | `function` | – | Dismiss one toast, or all of them. |
| `Toaster position` | `"bottom-right" \| "bottom-center" \| "bottom-left" \| "top-right" \| "top-center" \| "top-left"` | `"bottom-right"` | Which corner; the stack grows away from that edge. |
| `Toaster visible / hotkey` | `number / string` | `3 / "t"` | How many show in the stack, and the letter that, with Alt, moves focus to the newest toast. |
## Live activity (`live-activity`)
One pill for whatever is happening now: it updates in place, tap it and it grows into a panel, done blurs into a check.
```bash
npx shadcn@latest add https://hairlineui.com/r/live-activity.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/live-activity.md
```tsx
import { LiveActivity } from "@/components/live-activity";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `activity` | `LiveActivityData \| null` | – | { id, label, status?, progress?, count?, icon?, title?, detail?, steps?, actions? }; null folds the pill away. |
| `expanded / defaultExpanded / onExpandedChange` | `boolean / boolean / (open) => void` | – | Whether the panel is open; controlled or not. |
| `panelWidth` | `number` | `320` | The panel’s width. |
## Live cursors (`live-cursors`)
Other people’s cursors gliding over the same page: names that turn into speech bubbles, click ripples, and selections that follow them around.
```bash
npx shadcn@latest add https://hairlineui.com/r/live-cursors.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/live-cursors.md
```tsx
import { LiveCursors } from "@/components/live-cursors";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `cursors` | `{ id, name, x, y, color?, message?, clicks?, selection? }[]` | – | Everyone else. x and y run from 0 to 1 across the area; raise clicks by one per click; selection is a box in the same units. |
| `children` | `ReactNode` | – | What they’re all looking at; the cursors float over it. |
| `idleAfter` | `number` | `8000` | Milliseconds without moving before a name folds into its dot. |
| `onCursorMove` | `(point: { x, y } \| null) => void` | – | Your own mouse over the area in 0–1 units, or null when it leaves. |
| `onMessage` | `(text: string) => void` | – | Turns on "/" to say something at your pointer (mouse and trackpad only). |
## Live map (`live-map`)
The world as dots, lighting up where things happen: pings ripple, the light lingers, and one card follows the newest event from city to city.
```bash
npx shadcn@latest add https://hairlineui.com/r/live-map.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`
- Full docs: https://hairlineui.com/ui/live-map.md
```tsx
import { LiveMap } from "@/components/live-map";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `pings` | `{ id, lat, lon, label?, detail?, from? }[]` | – | Recent events, oldest first. Each id pings once, the first time it’s seen. |
| `markers` | `{ lat, lon, label? }[]` | – | Places that are always marked, like a store or an office. |
| `afterglow` | `number` | `9000` | Milliseconds for a ping’s light to fade to its faint tint. |
| `showLabel` | `boolean` | `true` | Show the card on the newest ping. |
## Theme toggle (`theme-toggle`)
The sun sets into a moon, and the new theme spreads from the button in a circle.
```bash
npx shadcn@latest add https://hairlineui.com/r/theme-toggle.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `text-morph`
- Full docs: https://hairlineui.com/ui/theme-toggle.md
```tsx
import { ThemeToggle } from "@/components/theme-toggle";
// Manages itself: remembers the choice, follows the system, sets the dark class on .
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"icon" \| "pill" \| "segmented"` | `"icon"` | A round button, a button with a label, or a three-way switch with System. |
| `value / defaultValue / onValueChange` | `"light" \| "dark" \| "system"` | `— / "system"` | Control it (e.g. from next-themes), or let it keep its own choice. |
| `storageKey` | `string` | `"theme"` | Where an uncontrolled toggle remembers the choice in localStorage. |
| `attribute` | `"class" \| "data-theme"` | `"class"` | How an uncontrolled toggle marks dark mode on ; it also sets color-scheme. |
| `reveal` | `boolean` | `true` | Spread the new theme from the button in a circle where View Transitions are available. |
## Pricing calculator (`pricing-calculator`)
Drag to your size and watch the price roll, the plan morph and, at the top, “Let’s talk”.
```bash
npx shadcn@latest add https://hairlineui.com/r/pricing-calculator.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`, `pricing-toggle`
- Full docs: https://hairlineui.com/ui/pricing-calculator.md
```tsx
import { PricingCalculator } from "@/components/pricing-calculator";
29 + users * 0.003}
plan={(users) => (users < 10_000 ? "Starter" : "Growth")}
marks={[{ value: 10_000, label: "10K" }]}
contactFrom={500_000}
yearlyDiscount={0.2}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `min / max / step` | `number` | `— / — / 1` | The range. On a log scale the step grows with the value (thousands near 10K, ten-thousands near 100K). |
| `value / defaultValue / onValueChange` | `number` | – | Control it, or let it keep its own. |
| `price / plan` | `(value) => number / (value) => string` | – | Your monthly price and plan name for a value. |
| `marks` | `{ value; label }[]` | – | Ticks and labels along the track, e.g. where tiers change; clicking a label throws the thumb there. |
| `scale` | `"linear" \| "log"` | `"linear"` | Log spaces orders of magnitude evenly, so 1K, 10K and 100K get the same room. |
| `unit / valueFormat / currency` | `string / Intl.NumberFormatOptions / string` | `— / — / "USD"` | How the value and price read. |
| `locales` | `string \| string[]` | `the browser’s` | Locale for every figure. Set it when the page renders on a server, so the server and the browser format prices alike. |
| `contactFrom` | `number` | – | From this value, "Let’s talk" replaces the price. |
| `yearlyDiscount` | `number` | – | e.g. 0.2 adds a Monthly/Yearly switch with "Save 20%". |
| `cta` | `{ label(plan); contactLabel?; onClick?(value, plan, billing) }` | – | The button under the price; its words follow the plan. |
## Comparison table (`comparison-table`)
Plans side by side, with a column that glides to the plan under your pointer and prices that roll.
```bash
npx shadcn@latest add https://hairlineui.com/r/comparison-table.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `pricing-toggle`, `number-roll`
- Full docs: https://hairlineui.com/ui/comparison-table.md
```tsx
import { ComparisonTable } from "@/components/comparison-table";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `plans` | `ComparisonPlan[]` | – | id, name, price (a number, { monthly, yearly }, or null for Custom), and any of description, featured, badge, cta { label, href, onClick }. |
| `groups` | `ComparisonGroup[]` | – | Sections of rows: { title, rows: [{ label, hint?, values: { [planId]: true \| false \| text } }], collapsed? }. |
| `yearlyBadge` | `string` | – | Shows the Monthly/Yearly switch with this badge, e.g. "Save 20%". |
| `currency` | `string` | `"USD"` | For the prices. |
| `stickyTop` | `number` | `0` | How far from the top the plan headers stick. |
## Onboarding checklist (`onboarding-checklist`)
The first five things, ticked off in place: a ring that fills, checks that draw, and a finish worth seeing.
```bash
npx shadcn@latest add https://hairlineui.com/r/onboarding-checklist.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/onboarding-checklist.md
```tsx
import { OnboardingChecklist } from "@/components/onboarding-checklist";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `tasks` | `OnboardingTask[]` | – | id, title, and any of description, done (controlled), and action { label, pendingLabel?, onClick? (return a promise to show progress), href? }. |
| `title / doneTitle / doneDescription` | `string` | `"Get started" / "You’re all set" / …` | The heading before and after, and the line shown once everything’s done. |
| `onTaskComplete` | `(id) => void` | – | Called when a task’s action resolves; mark it done here if you control done. |
| `onDismiss` | `() => void` | – | Shows Hide once everything is done. |
| `defaultCollapsed` | `boolean` | `false` | Start folded to the header. |
## Sortable list (`sortable-list`)
Drag to reorder and everything moves: rows slide out of the way, the dropped one settles, every rank rolls, and presets re-sort the lot.
```bash
npx shadcn@latest add https://hairlineui.com/r/sortable-list.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`
- Full docs: https://hairlineui.com/ui/sortable-list.md
```tsx
import { SortableList } from "@/components/sortable-list";
const [items, setItems] = useState(features);
f.name}
renderItem={(f) => {f.name}}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `T[] (each with an id)` | – | The list in its current order. |
| `onReorder` | `(items: T[]) => void` | – | Called with the new order after a drop, a keyboard move or a preset. Update items with it; if you don’t, the rows glide back. |
| `renderItem` | `(item, { index, lifted }) => ReactNode` | – | A row’s content, between the rank and the edge; the grip, rank, border and motion are handled. |
| `getLabel` | `(item) => string` | – | The name used in the grip’s label and the announcements. |
| `sorts` | `{ label; compare }[]` | – | Presets shown as a segmented control; the one the order currently matches shows as pressed. |
| `label` | `string` | `"Reorderable list"` | The list’s accessible name. |
## Changelog timeline (`changelog-timeline`)
What shipped, drawn as you read: a rail that fills, dots that light, filters that fold the rest away.
```bash
npx shadcn@latest add https://hairlineui.com/r/changelog-timeline.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/changelog-timeline.md
```tsx
import { ChangelogTimeline } from "@/components/changelog-timeline";
Drag shifts onto the week…
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `index` | `number` | – | The number shown in the marker. |
| `source` | `{ title; url; snippet?; icon? }` | – | What the card shows. icon takes a favicon element; a globe is shown without one. |
| `status` | `"available" \| "unavailable"` | `"available"` | Unavailable (or no source) strikes the marker and explains in the card. |
## Response versions (`response-versions`)
Step between regenerated answers with "2 / 3", and regenerate without losing the old ones.
```bash
npx shadcn@latest add https://hairlineui.com/r/response-versions.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`
- Full docs: https://hairlineui.com/ui/response-versions.md
```tsx
import { ResponseVersions } from "@/components/response-versions";
{versions[index]}
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `count / index` | `number` | – | How many versions, and which one is shown (zero-based). |
| `onIndexChange` | `(index: number) => void` | – | Called by the arrows. |
| `onRegenerate / regenerating` | `() => void / boolean` | – | Shows Regenerate, and a spinner while a version is written. |
| `children` | `ReactNode` | – | The content of the version shown. |
## Selection actions (`selection-actions`)
Select text and a pill appears under it. Pick an action or describe an edit, and the rewrite streams in place.
```bash
npx shadcn@latest add https://hairlineui.com/r/selection-actions.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/selection-actions.md
```tsx
import { SelectionActions } from "@/components/selection-actions";
import { Sparkles, Scissors } from "lucide-react";
, pendingLabel: "Improving" }]}
more={[{ id: "shorten", label: "Shorten", icon: , pendingLabel: "Shortening" }]}
onAction={rewrite} // returns a string, a promise, or an async iterable of chunks
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value / onValueChange` | `string / (value: string) => void` | – | The text, rendered with its line breaks. Keep calls onValueChange with the rewrite in place. |
| `actions` | `{ id; label; icon?; pendingLabel? }[]` | – | Always visible. pendingLabel is shown while it runs, e.g. "Shortening". |
| `more` | `SelectionAction[]` | – | Behind the chevron. Without them there is no chevron. |
| `onAction` | `(request) => string \| Promise \| AsyncIterable \| void` | – | Gets id ("custom" for typed edits), text, instruction, before, after and an AbortSignal. Return chunks to stream, nothing for actions handled elsewhere, or throw to show Try again. |
| `placeholder` | `string` | `"Describe edits"` | The instruction field’s placeholder and label. |
## Inline rewrite (`inline-rewrite`)
Drag a dial and the paragraph rewrites itself in place: shared words glide, the rest blur out and rise in.
```bash
npx shadcn@latest add https://hairlineui.com/r/inline-rewrite.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/inline-rewrite.md
```tsx
import { InlineRewrite } from "@/components/inline-rewrite";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `versions` | `{ label: string; text?: string }[]` | – | The dial’s stops in order. Leave text out to have onRewrite write it. |
| `value / defaultValue / onValueChange` | `number` | `0` | The chosen version’s index; controlled or not. Changes as the thumb crosses each stop. |
| `onRewrite` | `({ index, label, current, original, signal }) => Promise` | – | Writes a version that has no text, once the dial settles on it. The result is kept; throw to show it failed. |
| `originalIndex` | `number` | – | The version the +added and −cut counts compare against. Defaults to the one chosen first. |
| `label` | `string` | `"Rewrite"` | The dial’s accessible name, e.g. "Length" or "Tone". |
| `highlight` | `boolean` | `true` | Let new words glow for a moment after each rewrite. |
## Diff review (`diff-review`)
Accept or reject what the AI changed, word by word for prose or line by line for code.
```bash
npx shadcn@latest add https://hairlineui.com/r/diff-review.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/diff-review.md
```tsx
import { DiffReview } from "@/components/diff-review";
apply(decisions)}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `changes` | `{ id; before; after; label? }[]` | – | Each change; the label says where it is, like a paragraph or file name. |
| `mode` | `"words" \| "lines"` | `"words"` | Compare word by word (prose) or line by line (code). |
| `decisions / defaultDecisions` | `Record` | – | Controlled or initial decisions; missing ids are pending. |
| `onDecisionsChange` | `(decisions) => void` | – | Every decision after a change, in one call even for Accept all. |
| `onDecisionChange` | `(id, decision) => void` | – | Each individual decision, including Undo back to pending. |
| `onComplete` | `(decisions) => void` | – | Called when the last pending change gets a decision. |
| `readOnly` | `boolean` | `false` | Shows decisions without Undo or buttons, e.g. once they’ve been sent. |
| `title` | `string` | `"Suggested changes"` | The header. |
## Action receipts (`action-receipt`)
What the agent actually did, one quiet line each, with Undo while a ring drains. Several stack into one.
```bash
npx shadcn@latest add https://hairlineui.com/r/action-receipt.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/action-receipt.md
```tsx
import { ActionReceipts } from "@/components/action-receipt";
undoAction(id)}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `actions` | `{ id; title; detail?; icon?; status?; error?; undoUntil? }[]` | – | One per action. status is "done", "undoing", "undone" or "failed"; undoUntil (ms) enables Undo. |
| `onUndo` | `(id) => void \| Promise` | – | Shows Undo while the window is open, with a spinner until the promise settles. Set the action’s status to "undone" after. |
| `onUndoAll` | `() => void \| Promise` | – | For the stacked "Undo all"; defaults to calling onUndo for each undoable action. |
| `onRetry` | `(id) => void` | – | Shows Retry on failed actions. |
| `stackFrom` | `number` | `3` | Fold into a summary from this many actions; 0 never folds. |
| `undoWindow` | `number` | `10000` | Length of the undo window (ms), so the ring drains at the right pace. |
## Error and limit states (`chat-notice`)
Calm, specific notices for failed answers, being offline, rate limits and chats that grow too long.
```bash
npx shadcn@latest add https://hairlineui.com/r/chat-notice.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `status-button`, `text-morph`
- Full docs: https://hairlineui.com/ui/chat-notice.md
```tsx
import { ChatNotice } from "@/components/chat-notice";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `kind` | `"error" \| "offline" \| "rate-limit" \| "context"` | – | Picks the icon, wording and actions. |
| `title / description` | `string` | – | Override the default wording. |
| `resetAt / onReset` | `number / () => void` | – | Rate limit: when sending is allowed again, with a live countdown. |
| `onRetry / retrying` | `() => void / boolean` | – | Retry button, with a spinner (or "Reconnecting" when offline) while it runs. |
| `onUpgrade / upgradeLabel` | `() => void / string` | `"Upgrade"` | Rate limit: the way out. |
| `onSummarize / onNewChat` | `() => void` | – | Context: continue with a summary, or start over. |
| `details` | `string` | – | Technical details behind "Show details". |
## Usage meter (`usage-meter`)
Credits, messages or context left: quiet when there’s plenty, clear before it runs out.
```bash
npx shadcn@latest add https://hairlineui.com/r/usage-meter.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/usage-meter.md
```tsx
import { UsageMeter } from "@/components/usage-meter";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `used / limit` | `number` | – | How much is used, out of how much. |
| `unit / label` | `string` | `"credits" / "Usage"` | What’s counted, and the meter’s name. |
| `resetAt` | `number \| Date` | – | When it resets; shown under the bar and in the out state. |
| `variant` | `"bar" \| "ring"` | `"bar"` | A full-width bar, or a 16px ring with a percentage. |
| `lowAt` | `number` | `0.2` | Share left at which it turns amber. |
| `onUpgrade / upgradeLabel` | `() => void / string` | `"Upgrade"` | Shown when low or out. |
## Usage limits (`usage-limits`)
What an agent has left: the context window split by what fills it, and each plan limit with its share and a live reset countdown.
```bash
npx shadcn@latest add https://hairlineui.com/r/usage-limits.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`
- Full docs: https://hairlineui.com/ui/usage-limits.md
```tsx
import { UsageLimits } from "@/components/usage-limits";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `limits` | `PlanLimit[]` | – | { id, label, used, limit, resetsAt? } for each plan limit. |
| `context` | `{ limit; parts: ContextPart[] }` | – | The context window and what fills it, each part { id, label, tokens }; up to five colours. |
| `plan` | `string` | – | The plan’s name after "Plan limits". |
| `onPlanClick` | `() => void` | – | Makes the plan heading a button with an arrow, e.g. to billing. |
| `defaultOpen` | `boolean` | `false` | Start with the context breakdown open. |
| `locales` | `string \| string[]` | – | For numbers and the reset day. |
## Jump to latest (`chat-scroll`)
A chat that follows the answer as it streams, lets go the moment you scroll up, and brings you back.
```bash
npx shadcn@latest add https://hairlineui.com/r/chat-scroll.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `text-morph`
- Full docs: https://hairlineui.com/ui/chat-scroll.md
```tsx
import { ChatScroll } from "@/components/chat-scroll";
{messages.map(renderMessage)}
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | – | The messages. |
| `streaming` | `boolean` | `false` | While true, the button says "Writing" with a pulsing dot. |
| `followKey` | `string \| number` | – | Change it, e.g. to the latest user message id, to glide back to the bottom. |
| `threshold` | `number` | `48` | How close to the bottom (px) still counts as at the bottom. |
| `className / contentClassName` | `string` | – | Give the area a height (or flex-1); style the inner column. |
## Edit and resend (`message-edit`)
Fix a sent message where it sits. The bubble grows into an editor, then folds back and branches the reply.
```bash
npx shadcn@latest add https://hairlineui.com/r/message-edit.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `number-roll`
- Full docs: https://hairlineui.com/ui/message-edit.md
```tsx
import { MessageEdit } from "@/components/message-edit";
resend(message.id, text)} disabled={busy} />
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `string` | – | The message as sent; the editor starts from it. |
| `onSubmit` | `(text: string) => void` | – | Called with the edited text. Replace the message and regenerate from it. |
| `disabled / disabledReason` | `boolean / string` | `false / "Wait for the answer to finish"` | Pause editing, e.g. while an answer streams. |
| `versions` | `{ index; count; onIndexChange }` | – | Branches of this message, shown as "2 / 3" when there is more than one. |
| `children` | `ReactNode` | – | How the sent message renders (mentions, attachments); defaults to the text. |
## Chat history (`chat-history`)
Past chats grouped by day, searchable, with titles that write themselves, rename in place and delete with Undo.
```bash
npx shadcn@latest add https://hairlineui.com/r/chat-history.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): none
- Full docs: https://hairlineui.com/ui/chat-history.md
```tsx
import { ChatHistory } from "@/components/chat-history";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `chats` | `{ id; title?; updatedAt; pinned?; generating? }[]` | – | Grouped and sorted for you. Leave title empty (or set generating) while it’s being named. |
| `activeId / onSelect` | `string / (id) => void` | – | The open chat, marked with aria-current. |
| `onNew` | `() => void` | – | Shows the new-chat button beside search. |
| `onRename / onPin` | `(id, title) => void / (id, pinned) => void` | – | Show Rename and Pin on hover or focus. |
| `onDelete / undoWindow` | `(id) => void / number` | `— / 5000` | Called once the undo window has passed, after the row has folded away. |
| `searchable / defaultQuery` | `boolean / string` | `true / ""` | The search field and its starting text. |
## Earnings chart (`earnings-chart`)
A bar chart that changes instead of redrawing: bars glide between periods, the scale and the total roll, a readout follows the pointer.
```bash
npx shadcn@latest add https://hairlineui.com/r/earnings-chart.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/earnings-chart.md
```tsx
import { EarningsChart } from "@/components/earnings-chart";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `periods` | `EarningsPeriod[]` | – | Each with id, label (for the switch), bars ({ key, label, value, title? }) and optionally previous (the total before, for the chip) and comparison. |
| `value / defaultValue` | `string` | `the last period` | The period on show, by id; controlled or not. |
| `onValueChange` | `(id: string) => void` | – | Called when the period changes. |
| `title` | `string` | `"Earnings"` | Above the total. |
| `format` | `Intl.NumberFormatOptions` | `USD, no decimals` | How amounts read; the scale uses the same with compact notation. |
| `highlight` | `string` | `the last bar` | The bar drawn in your primary colour, by key. |
| `locales` | `string \| string[]` | – | For number formatting. |
## Steps chart (`steps-chart`)
A week against a daily goal: days that make it fill in, a wave runs the way you step through the weeks, the totals roll.
```bash
npx shadcn@latest add https://hairlineui.com/r/steps-chart.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/steps-chart.md
```tsx
import { StepsChart } from "@/components/steps-chart";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `weeks` | `StepsWeek[]` | – | Oldest first, each with id, label and seven days ({ key, label, value, title?, today? }). It opens on the last. |
| `goal` | `number` | `10000` | Steps a day; the goal line and the colour of the days that reach it. |
| `value / defaultValue` | `string` | `the last week` | The week on show, by id; controlled or not. |
| `onValueChange` | `(id: string) => void` | – | Called when the week changes. |
| `title` | `string` | `"Steps"` | Above the total. |
| `locales` | `string \| string[]` | – | For number formatting. |
## Revenue chart (`revenue-chart`)
This year as an area, last year behind it; point at a month and the header becomes the readout, switch the measure and the lines reshape.
```bash
npx shadcn@latest add https://hairlineui.com/r/revenue-chart.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/revenue-chart.md
```tsx
import { RevenueChart } from "@/components/revenue-chart";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `labels` | `string[]` | – | Along the bottom, one per point; they thin out on narrow screens. |
| `titles` | `string[]` | – | Longer names for the readout, e.g. "August". |
| `metrics` | `RevenueMetric[]` | – | { id, label, current, previous?, format?, total? ("sum" or "average") }. More than one shows a switch. |
| `value / defaultValue` | `string` | `the first measure` | The measure on show, by id; controlled or not. |
| `onValueChange` | `(id: string) => void` | – | Called when the measure changes. |
| `series` | `[string, string]` | `["This year", "Last year"]` | The two lines’ names, in the legend and the readout. |
| `locales` | `string \| string[]` | – | For number formatting. |
## Area chart (`area-chart`)
Several series stacked, overlapping or as shares: every band morphs between layouts, and the tiles are the legend, the switches and the readout.
```bash
npx shadcn@latest add https://hairlineui.com/r/area-chart.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/area-chart.md
```tsx
import { AreaChart } from "@/components/area-chart";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `series` | `AreaSeries[]` | – | Up to five, each { id, label, values }, coloured --chart-1 to --chart-5 in this order. |
| `labels` | `string[]` | – | Along the bottom, one per point; they thin out on narrow screens. |
| `titles` | `string[]` | – | Longer names for the readout, e.g. "Week of 28 September". |
| `layout / defaultLayout` | `"stacked" \| "overlap" \| "percent"` | `"stacked"` | Controlled or not. |
| `onLayoutChange` | `(layout) => void` | – | Called when the layout changes. |
| `title` | `string` | `"Orders"` | Above the total. |
| `caption` | `string` | – | Under the total, e.g. "in the last 12 weeks". |
| `format` | `Intl.NumberFormatOptions` | – | How the figures read. |
| `locales` | `string \| string[]` | – | For number formatting. |
## Trend chart (`trend-chart`)
One metric over time, the way an analytics screen needs it: the period before as a dashed line, an unfinished day drawn dashed, a readout that glides with the pointer, and drag to zoom.
```bash
npx shadcn@latest add https://hairlineui.com/r/trend-chart.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/trend-chart.md
```tsx
import { TrendChart } from "@/components/trend-chart";
setRange({ start, end })}
/>
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `buckets` | `number[]` | – | Bucket starts in epoch ms, oldest first. Same first bucket, count and interval as before means a morph; anything else is a zoom. |
| `values` | `number[]` | – | One value per bucket. |
| `previous` | `number[] \| null` | – | The period before, bucket for bucket. null hides the dashed line. |
| `interval` | `"hour" \| "day" \| "week" \| "month"` | `"day"` | How long one bucket is: sets the last bucket's end, the date words and the shortest span the brush takes. |
| `kind` | `"count" \| "percent" \| "duration"` | `"count"` | Counts (or money, with format); percentages from 0 to 100; or durations in seconds ("2m 41s", morphed rather than rolled). |
| `format` | `Intl.NumberFormatOptions` | `{ maximumFractionDigits: 0 }` | How counts are written in the readout and the table. The scale writes the same format compactly (1.2K, $4.5K). |
| `unit` | `string` | – | What one value is called, after the figure: "visitors". |
| `previousLabel` | `string` | `"the period before"` | How the readout names the dashed line. |
| `now` | `number` | `the clock at mount` | A last bucket that ends after this is unfinished and drawn dashed. |
| `onZoom` | `({ start, end }) => void` | – | Called with the span dragged across, in epoch ms, snapped to bucket edges. Without it the chart can't be brushed. |
| `note` | `(index) => string \| null` | – | A line under the figures in the readout for that bucket. |
| `onActiveChange` | `(index \| null) => void` | – | The bucket being read, to highlight the same bucket elsewhere. |
| `label` | `string` | – | Accessible name of the chart and caption of its table. |
| `locale` | `string` | – | For dates and numbers; the viewer's own by default. |
| `className` | `string` | – | On the root. The readout's figure has data-slot="figure" for your display font. |
## Combo chart (`combo-chart`)
Every day as a bar and the trend as a line on one scale: point at a day and the header becomes its readout while the line’s dot pings where it lands.
```bash
npx shadcn@latest add https://hairlineui.com/r/combo-chart.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/combo-chart.md
```tsx
import { ComboChart } from "@/components/combo-chart";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `days` | `ComboDay[]` | – | Oldest first, each { key, label, value, title? }. |
| `rolling` | `number` | `7` | The line is a rolling average over this many days (fewer at the start). |
| `average` | `number[]` | – | Your own line values instead, one per day. |
| `previous` | `number` | – | The total of the period before, for the change chip. |
| `series` | `[string, string]` | `["Orders", "7-day average"]` | The bars’ and the line’s names, in the legend and readout. |
| `title` | `string` | `"Orders"` | Above the total, and in the readout. |
| `format` | `Intl.NumberFormatOptions` | – | How figures read (whole numbers by default). |
| `locales` | `string \| string[]` | – | For number formatting. |
## Stage bars (`stage-bars`)
A funnel you can read at a glance: every stage as a bar against the first, the drop-off between them, and widths that ease when the period changes.
```bash
npx shadcn@latest add https://hairlineui.com/r/stage-bars.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`
- Full docs: https://hairlineui.com/ui/stage-bars.md
```tsx
import { StageBars } from "@/components/stage-bars";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `stages` | `Stage[]` | – | In order, each { id, label }. |
| `periods` | `StagePeriod[]` | – | Each { id, label, values }, one count per stage; more than one shows a switch. |
| `value / defaultValue` | `string` | `the first period` | The period on show, by id; controlled or not. |
| `onValueChange` | `(id: string) => void` | – | Called when the period changes. |
| `title` | `string` | `"Funnel"` | Above the headline. |
| `format` | `Intl.NumberFormatOptions` | – | How counts read. |
| `locales` | `string \| string[]` | – | For number formatting. |
## Activity rings (`activity-rings`)
Three daily goals as concentric rings beside a tight legend, with the week underneath: pick a day and the rings sweep to it.
```bash
npx shadcn@latest add https://hairlineui.com/r/activity-rings.json
```
- npm: none
- Hairline UI components it needs (the CLI installs them too): `number-roll`, `text-morph`
- Full docs: https://hairlineui.com/ui/activity-rings.md
```tsx
import { ActivityRings } from "@/components/activity-rings";
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `goals` | `RingGoal[]` | – | Up to three, outermost first: { id, label, goal, format? }. |
| `days` | `RingDay[]` | – | The week, oldest first: { key, label, title?, values } with null values for days to come. |
| `value / defaultValue` | `string` | `the last day with data` | The day on show, by key; controlled or not. |
| `onValueChange` | `(key: string) => void` | – | Called when the day changes. |
| `locales` | `string \| string[]` | – | For number formatting. |
## Agent playback (`agent-playback`)
Your AI product working, hands-off and on a loop: the hero demo built from the real components.
```bash
npx shadcn@latest add https://hairlineui.com/r/agent-playback.json
```
- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `prompt-composer`, `number-roll`, `thinking-trace`, `text-morph`, `web-research`, `tool-call`, `streaming-answer`
- Full docs: https://hairlineui.com/ui/agent-playback.md
```tsx
import { AgentPlayback, type PlaybackTurn } from "@/components/agent-playback";
const turns: PlaybackTurn[] = [
{
prompt: "Summarise this week's churn",
steps: [
{ type: "thinking", steps: ["Reading the events", "Grouping by plan"] },
{ type: "tool", name: "run_query", input: { table: "events", since: "7d" }, output: { rows: 1284 } },
{ type: "answer", content: "Churn fell to 2.1% this week, mostly on the Starter plan." },
],
},
];
```
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `turns` | `PlaybackTurn[]` | – | Each a prompt and its steps: thinking, search, tool and answer, in any order and number. |
| `speed` | `number` | `1` | Pace; 2 plays twice as fast. |
| `hold` | `number` | `4200` | How long a finished answer stays before the next prompt (ms). |
| `loop` | `boolean` | `true` | Start again after the last turn. |
| `paused` | `boolean` | `false` | Pause from outside, e.g. while a dialog is open. |
| `header / placeholder` | `ReactNode / string` | – | A title bar (your product’s name) and the composer’s placeholder. |
| `height` | `number` | `540` | Fixed, so the page around it never moves; the conversation grows up from the composer and fades at the top. |