Skip to content
Anvil UI

MIT · Next.js 16 · React 19 · Tailwind v4

Accessible React primitives, styled from CSS variables.

The part you probably came for: date, range and time pickers written by hand, with no calendar library underneath. No react-day-picker, no date-fns, no dayjs. Radix supplies popover positioning and focus management; the calendar grid, the keyboard model and the range logic are plain React you can read in one sitting.

Install

# run this page locally
pnpm install
pnpm dev          # http://localhost:3200

# or take one component into your own app
cp src/components/ui/DatePicker.tsx  ../your-app/src/components/ui/

There is no npm package, and that is deliberate. Copy the files you want — every component imports nothing but React, one Radix package and a four-line cn(). Only the overlays, the pickers and Select reach for Radix at all: the form controls, Button, Link, Icon and both motion helpers have no dependency whatsoever.

Theming

Every component reads these tokens and hard-codes no colour. Rebind the variables and the whole kit follows — including this page, which has no dark: variant anywhere on it. Use the switch above.

  • ink
  • ink-2
  • ink-3
  • surface
  • card
  • line
  • accent
  • accent-tint
  • danger

Dates and time

Three fields, one shape of data: ISO calendar strings in, ISO calendar strings out. Never a Date object, never UTC, never a timezone silently applied to something that is just a day on a wall calendar. Parsing anchors at midday local, so a DST jump at midnight cannot roll a day over.

DatePicker

Radix Popover

A month grid drawn by hand: 42 cells, a roving tabindex, and month arithmetic that is about forty lines of string helpers. Open it and arrow around — every key in the ARIA grid pattern is wired.

Past dates are blocked with min. Try PageDown, then Shift+PageDown.

invalid

disabled

value""

tsx
import { DatePicker } from "@/components/ui/DatePicker";

// "" or an ISO calendar date, "YYYY-MM-DD". Never a Date.
const [date, setDate] = useState("");

<DatePicker
  id="appointment"
  value={date}
  onChange={setDate}
  min="2026-01-01"
/>

DateRangePicker

Radix Popover

Two clicks: the first anchors the start, the second closes the range. While it is half-open the days under the pointer are previewed — and so are the days under the keyboard, because a hover-only affordance tells a keyboard user nothing. Two month grids by default, months={1} for the compact one.

Pick a start, then move around before committing to the end.

One grid, same range, same state — for a sidebar or a filter bar.

start / end"" / ""

tsx
import { DateRangePicker } from "@/components/ui/DateRangePicker";

const [range, setRange] = useState({ from: "", to: "" });

// Two month grids by default — a range is far easier
// to place when the month it ends in is already on
// screen. They stack below the sm breakpoint.
<DateRangePicker
  startValue={range.from}
  endValue={range.to}
  onChange={(from, to) => setRange({ from, to })}
  ariaLabel="Stay"
/>

// One grid, for a sidebar or a narrow filter bar.
<DateRangePicker months={1} … />

TimePicker

No dependencies

Not even Radix. A button with role=combobox and a listbox of generated slots, pointed at by aria-activedescendant so focus never leaves the trigger. Type 14 with it open and it jumps.

Both fields hold the same state. The clock is presentation — the value never is.

value""

tsx
import { TimePicker } from "@/components/ui/TimePicker";

// Always 24-hour "HH:MM", whatever the display clock says.
const [time, setTime] = useState("");

<TimePicker
  value={time}
  onChange={setTime}
  fromMin={9 * 60}   // 09:00
  toMin={18 * 60}    // 18:00
  stepMin={15}
/>

The calendar keyboard model

Both calendars are ARIA grids with a roving tabindex: exactly one cell is ever tabbable, and the visible month is derived from the focused day so the two can never disagree. Open a picker above and try these.

±1 day, mirrored automatically when the grid renders RTL
±1 week
PageUpPageDown
±1 month
ShiftPageUp
±1 year
HomeEnd
first and last day of the focused week
EnterSpace
select — native button activation, not re-implemented
Esc
close, with focus restored to the trigger

Days outside the allowed range use aria-disabled rather than the disabled attribute: a natively disabled cell cannot take focus, which dead-ends a roving grid.

Selection

Three components that all look like “a list appears”, and are not interchangeable. A Select holds a value. A Combobox holds text that a list happens to filter. A menu holds actions and holds no value at all — assistive tech announces the difference, so getting it wrong is not only a matter of taste.

Select

Radix Select

The native <select> replacement, with the label trap closed: the trigger shows the item's text, never the raw value — including before the list has ever been opened.

pill trigger, label from an items map

role / density"" / "cosy"

tsx
import {
  Select, SelectTrigger, SelectValue,
  SelectContent, SelectItem,
} from "@/components/ui/Select";

const [role, setRole] = useState("");

<Select value={role} onValueChange={setRole}>
  <SelectTrigger id="role">
    {/* Leave it childless: Radix portals the item's
        LABEL in here, so the trigger never shows a raw
        value. */}
    <SelectValue placeholder="Choose a role" />
  </SelectTrigger>
  <SelectContent>
    <SelectItem value="owner">Owner</SelectItem>
    <SelectItem value="editor">Editor</SelectItem>
  </SelectContent>
</Select>

Combobox

Radix Popover

A real input that filters as you type and still accepts anything you type. Free entry is the point — type a name nobody has and the “Add …” row makes that path visible instead of leaving you guessing.

Try “li”, then try a name that is not in the list.

text / picked"" / ""

tsx
import { Combobox } from "@/components/ui/Combobox";

const [client, setClient] = useState("");

<Combobox
  value={client}
  onChange={setClient}
  options={CLIENTS}              // { id, name, email? }
  onSelectOption={(o) => setEmail(o.email ?? "")}
  placeholder="Search clients…"
  ariaLabel="Client"
/>

Actions

A button DOES something; a link GOES somewhere. Space activates one and Enter the other, only one of them can be middle-clicked into a new tab, and assistive tech announces them differently — so the kit ships a real <button>, an anchor wearing the same clothes, and an inline link for prose, rather than one component with a prop that decides which it secretly is.

Button

No dependencies

Four intents, three sizes, and a loading state that keeps the label mounted so the button does not resize under the pointer mid-click. No asChild — when the thing navigates, ButtonLink renders a real anchor instead of teaching a button to lie.

variant

size

loading and disabled

Press Save: it disables itself while busy, and the label stays in the accessible name instead of being swapped for the word “Loading”.

ButtonLink — same clothes, real anchor

Middle-click either one: they open in a new tab, because they are links. The buttons above cannot, and no amount of styling makes them.

tsx
import { Button, ButtonLink } from "@/components/ui/Button";

// type defaults to "button", not "submit": a bare
// button dropped into a form should not post it.
<Button onClick={save}>Save changes</Button>

<Button variant="secondary" size="sm">Cancel</Button>
<Button variant="danger" onClick={remove}>Delete</Button>

// Keeps the label mounted and the width steady, and
// announces itself with aria-busy.
<Button loading>Saving…</Button>

// Navigates? Then it is an anchor, not a button.
<ButtonLink href="/pricing" variant="secondary">
  See pricing
</ButtonLink>

Overlays

Everything that portals to the end of the document, all sharing one z-index on purpose: portals mount in the order they open, so the last thing opened paints on top by DOM order — which is exactly what you want when a select opens inside a modal.

Dialog

Radix Dialog

Focus trap, focus restore, Escape, scroll lock and inert background — all from Radix. What the wrapper adds is the surface, a titled header, a bottom-sheet variant, and a guard so a Select opening inside the modal does not dismiss it.

Open it and press Tab repeatedly: focus cycles inside the panel and comes back to the trigger on Escape.

project name"Quarterly report"

tsx
import {
  Dialog, DialogTrigger, DialogContent, DialogClose,
} from "@/components/ui/Dialog";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";

const [open, setOpen] = useState(false);

<Dialog open={open} onOpenChange={setOpen}>
  {/* asChild hands the trigger's behaviour to your
      own button rather than nesting a second one. */}
  <DialogTrigger asChild>
    <Button variant="secondary">Rename</Button>
  </DialogTrigger>

  {/* title IS the accessible name — do not add your
      own heading. Omit description and the dialog
      simply has none. */}
  <DialogContent
    title="Rename project"
    description="Everyone on the team sees the new name."
  >
    <Input label="Project name" value={draft} onChange={…} />
    <DialogClose asChild>
      <Button variant="secondary">Cancel</Button>
    </DialogClose>
  </DialogContent>
</Dialog>

ConfirmDialog

Composed on Dialog

The destructive-action pattern, decided once: role=alertdialog, and focus lands on Cancel rather than on the close X — so a reflex Enter cancels instead of sitting on the irreversible path.

Open either one and press Enter without touching the mouse.

records3

tsx
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { Button } from "@/components/ui/Button";

const [open, setOpen] = useState(false);

<Button variant="danger" onClick={() => setOpen(true)}>
  Delete
</Button>

<ConfirmDialog
  open={open}
  onOpenChange={setOpen}
  destructive
  title="Delete 3 records?"
  description="This cannot be undone."
  labels={{ confirm: "Delete" }}
  onConfirm={() => remove()}
/>

Tooltip

Radix Tooltip

Opens on hover AND on keyboard focus, closes on Escape — the two things a title attribute or a CSS-only :hover label will never do for you.

Tab into the rail instead of hovering. Each side is different: top, bottom, right, left.

tsx
import { Tooltip, TooltipProvider } from "@/components/ui/Tooltip";

// A lone Tooltip provides for itself; wrap a GROUP so
// moving between them skips the delay.
<TooltipProvider>
  <Tooltip label="Calendar" side="top">
    {/* A tooltip is a description, not a name —
        an icon button still needs its own label. */}
    <button aria-label="Calendar">…</button>
  </Tooltip>
</TooltipProvider>

Form controls and feedback

Text fields, a checkbox and a radio group that are all still real native controls underneath, sharing one piece of label / description / error wiring — Field, which is also the thing to reach for when a control renders its own trigger. Then toasts that follow the theme without being told about it, and an icon set short enough to read in full.

Input

No dependencies

A text field on the kit's tokens, with label, description and error wired through Field. Extends the native props, so type, name and autoComplete behave as usual — and the error is announced, not just coloured.

Only ever used for the receipt.

/ month

leading and trailing render inside the border, so the focus ring wraps the lot.

Type three characters into the email field without an @: the border turns danger-coloured AND a role=alert message appears, wired to the input with aria-describedby. The last field is stuck invalid so both can be seen at once.

email""

tsx
import { Input } from "@/components/ui/Input";

const [email, setEmail] = useState("");
const bad = email.length > 3 && !email.includes("@");

<Input
  label="Work email"
  type="email"
  required
  value={email}
  onChange={(e) => setEmail(e.target.value)}
  description="Only ever used for the receipt."
  // A node, not a boolean: the message and the
  // invalid state cannot drift apart.
  error={bad ? "That address is missing an @." : undefined}
/>

// Slots sit INSIDE the box. leading/trailing, not
// prefix/suffix — prefix is a real HTML attribute
// typed string, so the name is already taken.
<Input label="Budget" leading="$" trailing="/ month" />

Textarea

No dependencies

The multi-line twin of Input. Auto-growth is opt-in, measured in a layout effect so a field that opens with a value in it is already the right height on the first paint rather than snapping after it — and maxRows is the ceiling that hands scrolling back to the element.

Press Enter a few times: it grows to six rows and then scrolls instead.

No autoGrow, so the browser's own resize handle stays.

The left field starts two lines tall with two lines already in it — that is the layout effect doing its job. Reload the page and watch for a jump: there is none.

rows in notes2

tsx
import { Textarea } from "@/components/ui/Textarea";

// Grows with the content, then stops and scrolls.
// Without maxRows a pasted essay pushes the submit
// button off the screen.
<Textarea
  label="Notes"
  autoGrow
  maxRows={6}
  rows={2}          // the FLOOR it grows from
  value={notes}
  onChange={(e) => setNotes(e.target.value)}
/>

// Off by default: a plain resizable field.
<Textarea label="Notes" rows={3} />

Checkbox

No dependencies

A real <input type=checkbox>, kept in the DOM so Space, form participation and the indeterminate property all still work. Only the box is drawn by the kit, because accent-color ignores every other token.

Tab to the header box: the focus ring is the kit’s global one, moved onto the drawn box because the real input is visually hidden.

checked rows1 of 3

tsx
import { Checkbox } from "@/components/ui/Checkbox";

const [rows, setRows] = useState([false, true, false]);
const all  = rows.every(Boolean);
const some = rows.some(Boolean);

// indeterminate is a DOM property, not an attribute:
// the component writes it through a ref so assistive
// tech announces "mixed".
<Checkbox
  label="Select all"
  checked={all}
  indeterminate={some && !all}
  onChange={(next) => setRows(rows.map(() => next))}
/>

RadioGroup

No dependencies

Native inputs sharing a name, so the browser gives you the radio keyboard model for free: arrows move between options and only the checked one is a tab stop, which is what makes Tab skip the whole group in one press. Only the circle is drawn by the kit.

Notification cadence

Vertical is the default — one option per line, easy to scan.

Billing period

orientation=horizontal, for two or three short options.

Tab into either group and use the arrow keys — the disabled option is skipped, and Tab leaves the whole group rather than stepping through every option. A group has no “uncheck”: model the empty state as its own option, or use checkboxes.

cadence / billing"daily" / "yearly"

tsx
import { RadioGroup, Radio } from "@/components/ui/Radio";

const [cadence, setCadence] = useState("daily");

// name is required — it is what makes the inputs one
// group, and what gives you arrow-key navigation and
// a single tab stop for free.
<RadioGroup
  name="cadence"
  value={cadence}
  onChange={setCadence}
  label="Notification cadence"
  description="Applies to every calendar you own."
>
  <Radio value="instant" label="As it happens" />
  <Radio value="daily"   label="Daily digest" />
  <Radio value="weekly"  label="Weekly summary" />
  {/* One option out, rather than the whole group —
      RadioGroup takes a disabled prop too. */}
  <Radio value="never"   label="Never" disabled />
</RadioGroup>

<RadioGroup … orientation="horizontal" />

Field

No dependencies

The label, description and error wiring under Input, Textarea and RadioGroup, usable on its own. Children is a function that hands you the id, aria-describedby, aria-invalid and required to spread — because a control that renders its own trigger cannot be reached by a plain <label htmlFor>.

Every slot on the booking page is shown in this zone.

Press Save with nothing chosen: the trigger picks up aria-invalid and the message replaces the description rather than stacking under it — one describedby, so a screen reader reads the error instead of both.

timezone""

tsx
import { Field } from "@/components/ui/Field";

// Input, Textarea and RadioGroup already sit on this.
// Reach for it directly when the control renders its
// own trigger — a Select, a Combobox, a DatePicker —
// which cannot take a plain <label htmlFor>.
<Field
  label="Timezone"
  description="Slots are shown in this zone."
  error={touched && !zone ? "Pick a timezone." : undefined}
>
  {/* Children is a FUNCTION, so the ids cannot be
      half-wired: spread what it hands you onto the
      focusable element. */}
  {(control) => (
    <Select value={zone} onValueChange={setZone}>
      <SelectTrigger {...control}>
        <SelectValue placeholder="Choose" />
      </SelectTrigger>
      …
    </Select>
  )}
</Field>

Toaster

sonner

sonner repainted in kit tokens, so one mounted Toaster is correct in light and dark with no theme prop. Switch the theme at the top of the page while a toast is up.

sonner injects its stylesheet unlayered, which beats every Tailwind utility no matter how specific — so every override in Toaster.tsx carries an !. Without it the theming is decoration.

tsx
// Mount once, near the root of the app:
import { Toaster } from "@/components/ui/Toaster";
<Toaster />

// Fire from anywhere:
import { toast } from "sonner";

toast("Draft saved");
toast.success("Booking confirmed");
toast.error("Card declined");
toast("Invite sent", {
  action: { label: "Undo", onClick: undo },
});

Icon

No dependencies

Twelve icons on a 24×24 grid, stroked with currentColor. No icon font, no sprite, no network request — and no token references, because the colour is whatever text colour it sits in.

  • alert
  • cal
  • check
  • chevD
  • chevL
  • chevR
  • chevU
  • clock
  • minus
  • plus
  • search
  • x

Colour comes from the parent, so an icon needs no theming of its own.

tsx
import { Icon } from "@/components/ui/Icon";

// Decorative by default: aria-hidden, inheriting
// currentColor and its own optical size.
<Icon name="search" />

// Meaningful on its own? Give it a name and it
// becomes role="img" with a <title>.
<Icon name="alert" title="3 conflicts" size={18} />

Motion

Scroll reveals that survive server rendering. Both components paint their hidden state on the first render — identical on the server and in the browser — and only reveal from an observer created in an effect. Deriving that first render from matchMedia instead is the mismatch that type-checks, lints, passes its tests, and then breaks in a browser.

Reveal

No dependencies

An IntersectionObserver and two CSS classes. No animation runtime is shipped to the browser, the hidden state is opacity only so the content stays focusable and in the accessibility tree, and globals.css force-shows anything marked data-reveal under reduced motion or with JavaScript off.

Remounts the block below.

Fades and lifts on entry.

delay 0ms

Same thing, later.

delay 160ms

tsx
import { Reveal } from "@/components/ui/Reveal";

// delay and duration are MILLISECONDS, not the
// seconds a motion library would take.
<Reveal as="section" delay={80}>
  <h2>Everything ships in the box</h2>
</Reveal>

Stagger

No dependencies

The same observer, shared. Nine cards in a grid should not mean nine observers, and they should start together relative to the grid rather than each on its own crossing — so the container is what gets watched and the items sequence on transition-delay.

A StaggerItem rendered outside a Stagger starts visible — content is never stranded because a container was refactored away.

Solo

$0

One calendar

Studio

$18

Five calendars

Agency

$49

Unlimited

tsx
import { Stagger, StaggerItem } from "@/components/ui/Stagger";

// One observer on the container; the items sequence
// with transition-delay. Indices are filled in for
// direct children, so this takes no props at all.
<Stagger className="grid gap-4 sm:grid-cols-3" gap={90}>
  {plans.map((plan) => (
    <StaggerItem key={plan.id}>
      <Card plan={plan} />
    </StaggerItem>
  ))}
</Stagger>