npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zuilib/primitives

v0.3.0

Published

ZUI primitives — accessible React components on Base UI and Tailwind v4, plus the react-hook-form binding and toasts

Readme

@zuilib/primitives

Renamed from @zuilib/components (and before that @zuilib/core). The design tokens live in @zuilib/tokens; @zuilib/primitives/styles.css loads them for you. The former @zuilib/form and @zuilib/toast packages are folded in as the form, form-field, lib/use-autosave and toast subpaths.

React UI primitives for the ZUI design system. Built on Base UI and Tailwind CSS v4, with subpath exports for tree-shaking.

When to use

  • Building forms, settings panels, or any UI that needs accessible inputs and actions
  • Toast notifications (toast, on Sonner) and a react-hook-form binding (form, form-field) ship in the same package; each is its own subpath, so an app that imports neither pays for neither
  • The data grid, charts, AI components, Apps view/runtime layer and the text editor are separate packages on the same tokens

Prerequisites

| Package | Version | |---------|---------| | react | ^18.0.0 | | react-dom | ^18.0.0 | | react-hook-form | ^7.0.0 — optional peer, only for form / form-field |

Base UI, ZUI tokens, Floating UI, Sonner and the class-name utilities are regular dependencies and install with the package. Sonner is only loaded by the toast subpath.

The components are styled with Tailwind v4 utility classes. Either your app runs Tailwind v4 and generates them from @zuilib/primitives/styles.css, or you load the prebuilt @zuilib/primitives/zui.css (no Tailwind needed). See Setup.

Installation

Monorepo (this repo):

{ "dependencies": { "@zuilib/primitives": "workspace:*" } }

External app:

pnpm add @zuilib/primitives

React and React DOM remain peers so the package shares the application's React runtime. To evaluate an unpublished workspace build from tarballs (pnpm pack in packages/tokens and packages/primitives), install both tarballs because the unpublished primitives tarball refers to the unpublished token version:

{
  "dependencies": {
    "@zuilib/primitives": "file:./zuilib-primitives-0.2.0.tgz",
    "@zuilib/tokens": "file:./zuilib-tokens-0.2.0.tgz"
  }
}

Setup (required once per app)

Load exactly one of the four stylesheets. Each gives you the tokens, the base styles and every utility the components use; they differ in who runs Tailwind and whether Tailwind's preflight reset is included.

| Your app | Import | What it is | |---|---|---| | Tailwind v4, and you do not @import "tailwindcss" yourself | @zuilib/primitives/styles.css | All-in-one Tailwind source: @import "tailwindcss" (with preflight) + the component @source + tokens. Your pipeline compiles it | | Tailwind v4, and you already @import "tailwindcss" | @zuilib/primitives/tailwind.css | Tailwind source without Tailwind: the component @source + tokens (@zuilib/tokens/tailwind.css + tokens.css) + the slider's vendor CSS. Import it after your tailwindcss import | | No Tailwind | @zuilib/primitives/zui.css | Prebuilt, flat CSS with Tailwind's preflight reset. Nothing to process | | No Tailwind, own reset / base styles | @zuilib/primitives/zui-no-preflight.css | The same prebuilt CSS built from Tailwind's theme and utilities layers only: no preflight, so your reset stays in charge |

Importing styles.css twice or next to a second @import "tailwindcss" duplicates Tailwind; use tailwind.css when Tailwind is already there.

Tailwind app, one linestyles.css is a Tailwind source file. Import it from your app's CSS so your Tailwind pipeline (@tailwindcss/vite, @tailwindcss/postcss, the CLI) processes it; Tailwind's automatic source detection stays on, so the token utilities (bg-primary, rounded-md, …) work in your own markup with no extra @source line:

/* app.css */
@import "@zuilib/primitives/styles.css";

Tailwind app that already imports Tailwind (for its own source() / @theme / plugin setup):

/* app.css */
@import "tailwindcss";
@import "@zuilib/primitives/tailwind.css";

tailwind.css carries @source "../**/*.{js,ts,tsx}", resolved relative to the file, so the component JS under node_modules/@zuilib/primitives/dist is scanned even though Tailwind skips node_modules by default. tailwindcss is an optional peer dependency for both Tailwind paths (pnpm add -D tailwindcss @tailwindcss/vite).

App without Tailwindzui.css is the prebuilt, flat bundle; zui-no-preflight.css the same without the reset. Import one at the app entry:

// main.tsx
import '@zuilib/primitives/zui.css' // or '@zuilib/primitives/zui-no-preflight.css'

Importing styles.css or tailwind.css from JavaScript or a non-Tailwind bundler does not work: their @import "tailwindcss" / @source are only meaningful to Tailwind.

Dark mode: add or remove the dark class on <html> or a root wrapper.

Import rules

| Rule | Detail | |------|--------| | One subpath per component | import Button from '@zuilib/primitives/button' (a named export { Button } exists too) — no barrel import { Button } from '@zuilib/primitives' | | Styles are separate | Components do not auto-inject CSS; you must load one stylesheet: styles.css / tailwind.css (Tailwind app) or zui.css / zui-no-preflight.css (prebuilt). See Setup | | Utilities | import { cn } from '@zuilib/primitives/lib/cn' for class merging; import SpinnerIcon from '@zuilib/primitives/lib/spinner'; lib/focus, lib/sizes, lib/field-context for custom controls that should match |

Exports

| Subpath | Default export | Purpose | |---------|----------------|---------| | @zuilib/primitives/styles.css | — | Tailwind v4 source: Tailwind + tokens + @source for the components. Needs a Tailwind pipeline | | @zuilib/primitives/tailwind.css | — | Tailwind v4 source without @import "tailwindcss": tokens + @source for the components, for apps that import Tailwind themselves | | @zuilib/primitives/zui.css | — | Prebuilt CSS: tokens + the utilities the components use + preflight. No Tailwind needed | | @zuilib/primitives/zui-no-preflight.css | — | Prebuilt CSS without Tailwind's preflight reset, for apps with their own reset | | @zuilib/primitives/button | Button | Primary actions | | @zuilib/primitives/ai-button | AIButton | Button with sparkle / generating state | | @zuilib/primitives/input | Input | Text input | | @zuilib/primitives/textarea | Textarea | Multiline text | | @zuilib/primitives/textarea-field | TextareaField | Textarea with label, description, error, character count | | @zuilib/primitives/checkbox | Checkbox | Boolean / indeterminate toggle | | @zuilib/primitives/switch | Switch | Boolean toggle | | @zuilib/primitives/select | Select | Single- or multi-select dropdown (compound parts as statics) | | @zuilib/primitives/native-select | NativeSelect | Native <select> | | @zuilib/primitives/combobox | Combobox | Searchable single- or multi-select (compound parts as statics) | | @zuilib/primitives/radio-group | RadioGroup | Radio options, card or list (compound parts as statics) | | @zuilib/primitives/fieldset | Fieldset | Grouped fields with legend | | @zuilib/primitives/field | Field | Field wrapper (Base UI Field): wires label / description / control ids | | @zuilib/primitives/label | Label | Accessible label | | @zuilib/primitives/field-description | FieldDescription | Helper text | | @zuilib/primitives/field-error | FieldError | Validation / error text | | @zuilib/primitives/disclosure | Disclosure | Collapsible section | | @zuilib/primitives/stepper | Stepper | Multi-step progress | | @zuilib/primitives/slider | Slider | Native range input with label, value read-out and marks | | @zuilib/primitives/number-input | NumberInput | Input with stepper, clamping and number formatting | | @zuilib/primitives/pin-input | PinInput | One-time-code / PIN cells | | @zuilib/primitives/file-upload | FileUpload | Drop zone + file list | | @zuilib/primitives/date-range-input | DateRangeInput | Controlled or uncontrolled start/end date pair | | @zuilib/primitives/search-input | SearchInput | Search field with clear button and loading state | | @zuilib/primitives/card | Card | Surface with header / content / footer (compound parts as statics) | | @zuilib/primitives/container | Container | Centred max-width wrapper | | @zuilib/primitives/stack | Stack | Flex row / column with gap and separators | | @zuilib/primitives/separator | Separator | Horizontal / vertical rule, optional label | | @zuilib/primitives/heading | Heading | h1h6 on the type scale | | @zuilib/primitives/text | Text | Body text with size / weight / tone | | @zuilib/primitives/code | Code | Inline code or code block | | @zuilib/primitives/kbd | Kbd | Keyboard key / shortcut | | @zuilib/primitives/alert | Alert | Inline status message, dismissible (compound parts as statics) | | @zuilib/primitives/badge | Badge | Label / tag, removable | | @zuilib/primitives/avatar | Avatar | Image with initials fallback and status; Avatar.Group | | @zuilib/primitives/presence | Presence | Compact avatar stack for active collaborators | | @zuilib/primitives/skeleton | Skeleton | Loading placeholder | | @zuilib/primitives/spinner | Spinner | Loading indicator with live-region label | | @zuilib/primitives/progress | Progress | Determinate / indeterminate progress bar | | @zuilib/primitives/empty-state | EmptyState | Icon + title + description + actions | | @zuilib/primitives/tooltip | Tooltip | Hover / focus tooltip (floating-ui) | | @zuilib/primitives/dialog | Dialog | Modal dialog (compound parts as statics) | | @zuilib/primitives/command-palette | CommandPalette | ⌘K launcher: Dialog + Combobox, groups, recent items, keyboard shortcut | | @zuilib/primitives/drawer | Drawer | Side / top / bottom sheet (compound parts as statics) | | @zuilib/primitives/popover | Popover | Anchored floating panel (compound parts as statics) | | @zuilib/primitives/menu | Menu | Dropdown action menu (compound parts as statics) | | @zuilib/primitives/tabs | Tabs | Tab list + panels (compound parts as statics) | | @zuilib/primitives/accordion | Accordion | Single / multiple expandable items (compound parts as statics) | | @zuilib/primitives/table | Table | Styled table primitives (compound parts as statics), no TanStack; data grids live in the separate @zuilib/data-grid package | | @zuilib/primitives/resizable-panels | ResizablePanels | Keyboard- and pointer-resizable two-panel layout | | @zuilib/primitives/toast | Toaster | Sonner toaster on the tokens; toast (+ Toast, ToastOptions types) re-exported | | @zuilib/primitives/form | Form | react-hook-form FormProvider + <form> wired to handleSubmit (needs the react-hook-form peer) | | @zuilib/primitives/form-field | FormField | react-hook-form Controller inside a Field: error, ids and optional autosave as render props | | @zuilib/primitives/lib/cn | cn | clsx + tailwind-merge helper | | @zuilib/primitives/lib/spinner | SpinnerIcon | Indeterminate spinner icon (also a named export) | | @zuilib/primitives/lib/icons | — | ChevronDownIcon, CheckIcon, CloseIcon, SearchIcon, InfoIcon / SuccessIcon / WarningIcon / DangerIcon (+ statusIcons): the glyphs the components share | | @zuilib/primitives/lib/transitions | — | fadeTransitionClasses, scaleFadeTransitionClasses, slideTransitionClasses(): the data-[closed] enter / leave recipes for Base UI's transition prop | | @zuilib/primitives/lib/focus | — | focusRingClasses, focusRingInsetClasses, dataFocusRingClasses, dataFocusRingInsetClasses, focusOutlineInsetClasses, fieldFocusRingClasses: the shared focus-ring class strings | | @zuilib/primitives/lib/sizes | — | ControlSize, controlSizeClasses, iconSizeClasses, … the shared size maps; touchTargetClasses / touchHitAreaClasses for 44px touch targets | | @zuilib/primitives/lib/reset | — | nativeControlResetClasses: zeroes the user-agent styles of a native <button> (padding, border, background, font) so button-rendered parts look the same without Tailwind's preflight. Put it first in cn() | | @zuilib/primitives/lib/field-context | — | useField(), FieldContext, fieldStateAttributes() for custom controls inside a Field | | @zuilib/primitives/lib/form-variants | — | formVariantClasses, formValidityClasses (the field frame styles) | | @zuilib/primitives/lib/control-frame | — | splitControlProps() / splitHoverHandlers() for frame + native-element controls | | @zuilib/primitives/lib/popover-panel | — | popoverSurfaceClasses, popoverPanelClassName(), the anchor classes and the option-row classes Select / Combobox / Menu / Popover / Dialog share | | @zuilib/primitives/lib/labels | — | LabelsProvider, useLabels(), defaultLabels, Labels: every built-in string, translated once | | @zuilib/primitives/lib/telemetry | — | TelemetryProvider, useTelemetry(), TelemetryEvent: opt-in usage events | | @zuilib/primitives/lib/use-autosave | — | useAutosave(), AutosaveState, AutosaveStatus: the debounced save behind FormField's onAutosave |

Quick start

import '@zuilib/primitives/zui.css' // or `@import "@zuilib/primitives/styles.css"` in a Tailwind app's CSS
import Button from '@zuilib/primitives/button'
import Input from '@zuilib/primitives/input'

export function Example() {
  return (
    <form className="flex flex-col gap-4 max-w-sm">
      <Input placeholder="Name" fullWidth />
      <Button type="submit">Save</Button>
    </form>
  )
}

Component API

Shared conventions

Every component follows the same contract, so what you learn on one applies to the rest:

| Convention | Detail | |---|---| | variant / tone | variant is visual style only (solid \| outline \| ghost \| link on Button, solid \| subtle \| outline on Badge, …); tone is the semantic colour (neutral \| primary \| success \| warning \| danger \| info, a subset per component). The word destructive does not exist in this library: the dangerous tone is danger | | Controlled state | Every piece of controlled state ships the full triad x / defaultX / onXChange (open / defaultOpen / onOpenChange, value / defaultValue / onValueChange, checked / defaultChecked / onCheckedChange). There is no onClose / onDismiss: close events are onOpenChange(false) | | size | 'sm' \| 'md' \| 'lg' on every sized control (Button adds 'icon', Spinner and Avatar add 'xl', Table and Badge stop at 'md'). Heights and paddings come from --control-height-* / --control-padding-x-*; one density retheme moves every component | | invalid | Sets aria-invalid + data-invalid and the danger border / ring. Leave it unset inside a Field invalid to inherit it | | disabled | Leave it unset inside a Field disabled / Fieldset disabled to inherit it (Base UI only inherits when the prop is undefined) | | fullWidth | w-full on the root. Defaults to false on Input / Textarea / NativeSelect / Button and to true on Select / Combobox | | name / form | Native form participation. Select, Combobox, RadioGroup, Checkbox and Switch render hidden inputs for it | | anchor / portal | Select and Combobox: where the panel floats ('bottom start' default, false for an inline panel) and whether it is portalled to <body> (default true when anchored). portal={false} keeps the portal container inside the component root so subtree themes still apply | | className | Merged last onto the root. Controls that wrap a native element in a frame also take inputClassName / textareaClassName for the element itself | | Native reset | Every part that renders a native <button> (Button, Checkbox, Switch, Tab, Select / Combobox / Popover / Menu buttons, close and remove buttons, steppers) starts its cn() with nativeControlResetClasses from lib/reset, so the user-agent padding, border and font never leak through when Tailwind's preflight is absent | | Focus ring | Standalone controls (Button, Checkbox, Switch, Radio, Disclosure, Stepper): a 2px --ring ring offset by 2px of --background, keyboard focus only. Fields (Input, Textarea, NativeSelect, Select button, Combobox input): a 1px ring hugging the border, coloured by validity | | data-slot | Every root and every part ([data-slot="select-option"]), listed per component below. Visual state is data-state where Base UI does not already provide a data attribute | | Icons | The built-in glyphs (chevron, check, cross, search, the four status icons) come from lib/icons: currentColor, aria-hidden, 1em unless a size-* class is given. Sized beside text by the shared iconSizeClasses (size-4 / size-5 / size-6 for sm / md / lg) | | Overlays | Select, Combobox, Menu and Popover panels share the bg-popover surface from lib/popover-panel; Dialog and Drawer use the same surface with shadow-4. Enter / leave motion comes from lib/transitions (fade, scale + fade, slide) through Base UI's data-closed, off under prefers-reduced-motion | | Compound parts | Attached as statics (Select.Option) and also named exports (SelectOption) | | Strings | Every string a component renders on its own (Close, Loading, Toggle options, No results found., Digit n of N, …) is a prop with an English default read from LabelsProvider (lib/labels): translate once at the root, override per element with the prop | | Telemetry | Off by default. Under a TelemetryProvider (lib/telemetry) Dialog emits open / close, Menu.Item select, Tabs change, Combobox and CommandPalette select, and a Button with track="name" emits click. A track prop names the event on the others | | Density | Heights and paddings come from --control-height-* / --control-padding-x-*, so a density mode is a token override on a subtree ([data-zui-density="compact"] { --control-height-md: 2rem }), not a prop | | Print | Dialog, Drawer, Popover.Panel and Tooltip carry print:hidden; an elevated Card drops its shadow (print:shadow-none) | | Phones and touch | Mobile-first, desktop untouched. Below the sm breakpoint (640px) a Dialog is the viewport minus a 1rem gutter, a start / end Drawer is full-width, a top / bottom Drawer at most 85dvh, the CommandPalette a full-width sheet pinned to the top, md / lg Card and Accordion padding step down one token, and sm fields type at 16px so iOS does not zoom on focus. Floating panels (Select, Combobox, Menu, Popover, Tooltip) never exceed 100vw - 2rem and cap their height in dvh. On a coarse pointer (pointer-coarse:) tabs, menu items, list options and the Table sort button are at least 44px tall, and Button, the close / clear buttons, the Checkbox box, Radio indicator, Switch track and Slider thumb take a 44 × 44 hit area without changing size (touchTargetClasses / touchHitAreaClasses from lib/sizes for your own controls). Fixed UI (Drawer, the full Dialog, toasts) pads past env(safe-area-inset-*) | | Direction | Only logical utilities (ps- / pe- / start / end / text-start), enforced by scripts/check-logical.mjs; PinInput's arrow keys follow dir. Public direction values are logical too (Drawer side="start" | "end") |

Button — @zuilib/primitives/button

<Button size="md" loading={saving} leadingIcon={<PlusIcon />}>Save</Button>
<Button variant="solid" tone="danger">Delete</Button>
<Button as="a" href="/docs" variant="link" tone="primary">Docs</Button>

| Prop | Type | Default | |------|------|---------| | variant | 'solid' \| 'outline' \| 'ghost' \| 'link' | 'solid' — visual style only | | tone | 'primary' \| 'neutral' \| 'danger' | 'primary' for solid, 'neutral' for outline / ghost / link | | size | 'sm' \| 'md' \| 'lg' \| 'icon' | 'md'icon is a square the height of an md control | | fullWidth | boolean | false | | loading | boolean | false — shows a spinner, sets aria-busy, swallows clicks, keeps focus | | loadingLabel | string | 'Loading' (Labels.loading) — visually hidden, announced with the spinner | | track | string | — emits { component: 'button', action: 'click', name } to a TelemetryProvider; nothing without the prop | | leadingIcon / trailingIcon | ReactNode | — leadingIcon is replaced by the spinner while loading | | as | ElementType | 'button' — polymorphic: <Button as="a" href> types href from the anchor; a disabled non-button gets aria-disabled and leaves the tab order |

Extends Base UI Button props (onClick, disabled, type, …). Slots: button (with data-variant, data-tone, data-size, data-loading), button-spinner, button-leading-icon, button-trailing-icon, button-content (icon-size button while loading). SVG children without a size-* class are sized 1em.

AIButton — @zuilib/primitives/ai-button

Same as Button (including as), minus loading / loadingLabel / leadingIcon, plus:

| Prop | Type | Default | |------|------|---------| | generating | boolean | false — Button loading with the generating text announced | | generatingLabel | string | 'Generating' (Labels.generating) — visually hidden, announced with the spinner | | sparkle | boolean | true — sparkle icon before the label |

The root keeps data-slot="button" (every button rule applies) and adds data-ai-button and data-generating; the icon is ai-button-sparkle.

Input / Textarea — @zuilib/primitives/input, @zuilib/primitives/textarea

<Input variant="outline" invalid size="md" fullWidth unsaved leadingContent={<SearchIcon />} />
<Textarea resize="vertical" rows={4} buttonContent={<Button size="sm">Send</Button>} />

Both are Base UI controls rendered inside a styled frame: inside a Field they receive the field's id, aria-labelledby, aria-describedby, aria-invalid and disabled state automatically.

| Prop | Type | Default | |------|------|---------| | variant | 'outline' \| 'ghost' | 'outline' | | invalid | boolean | inherits Field invalidaria-invalid + data-invalid + danger styles | | size | 'sm' \| 'md' \| 'lg' | 'md' (Input only; Textarea uses the md padding) | | fullWidth | boolean | false | | unsaved | boolean | false — tints the field --input-unsaved and sets data-unsaved (pair with Label unsaved) | | leadingContent / trailingContent | ReactNode | Input only | | resize | 'none' \| 'vertical' \| 'horizontal' \| 'both' | 'vertical' (Textarea only) | | buttonContent | ReactNode | Textarea only — rendered bottom-right inside the frame | | className | string | merged onto the frame | | inputClassName / textareaClassName | string | merged onto the native element |

Slots: input (frame), input-control, input-leading, input-trailing; textarea (frame), textarea-control, textarea-actions. The frame and the native element both carry Base UI's data-focus / data-hover / data-disabled / data-invalid.

NativeSelect — @zuilib/primitives/native-select

Native <select> rendered through Base UI Field.Control, with field styling and a token-positioned chevron. Standard <option> children, value / onChange (the native DOM handler); the custom widget is Select.

| Prop | Type | Default | |------|------|---------| | variant | 'outline' \| 'ghost' | 'outline' | | invalid | boolean | inherits Field invalid | | size | 'sm' \| 'md' \| 'lg' | 'md' | | fullWidth | boolean | false |

Slot: native-select (with data-invalid).

TextareaField — @zuilib/primitives/textarea-field

Field + Label + FieldDescription + Textarea + FieldError in one, with an optional character counter. Spreads textarea HTML attributes ({...field} from react-hook-form works).

| Prop | Type | |------|------| | label, description, error | stringerror renders a FieldError (role="alert") and marks the control invalid | | showCharacterCount, maxLength | boolean, number — over the limit the counter gets data-state="over" and the control is invalid | | unsaved | boolean | | buttonContent | ReactNode — Textarea's action slot | | className / textareaClassName | root / native <textarea> |

Slots: textarea-field (root), textarea-field-label, textarea-field-description, textarea-field-footer, textarea-field-error, textarea-field-count, plus the Textarea slots.

Checkbox / Switch — @zuilib/primitives/checkbox, @zuilib/primitives/switch

<Checkbox checked={value} onCheckedChange={setValue} label="Accept" description="Required" />
<Switch defaultChecked label="Notifications" labelPosition="start" size="lg" />

| Prop | Type | Default | |------|------|---------| | checked / defaultChecked / onCheckedChange | boolean, boolean, (checked: boolean) => void | controlled or uncontrolled | | label, description | ReactNode | — with either, the control is wrapped in a Base UI Field so the label toggles it and the description is in aria-describedby | | labelPosition | 'end' \| 'start' | 'end' | | indeterminate | boolean | false (Checkbox only) — aria-checked="mixed", data-indeterminate | | invalid | boolean | inherits Field invalid | | disabled, name, value | | native form participation | | size | 'sm' \| 'md' \| 'lg' | 'md' | | className | string | merged onto the box / track (the element ref points at) |

Without label / description only the control renders, so it picks up an enclosing Field's label and description. Slots: checkbox, checkbox-indicator, checkbox-field, checkbox-text, checkbox-label, checkbox-description; switch, switch-thumb, switch-field, switch-text, switch-label, switch-description.

Select — @zuilib/primitives/select

The custom select widget (Base UI Select underneath; the native element is NativeSelect). Generic over the value type. Convenience mode takes options; compound mode takes children.

<Select value={role} onValueChange={setRole} options={[{ value: 'admin', label: 'Admin', icon: <ShieldIcon />, description: 'Full access' }]} />

<Select value={ids} onValueChange={setIds} multiple compareBy="id">
  <Select.Label>Assignees</Select.Label>
  <Select.Button>{ids.length} selected</Select.Button>
  <Select.Options>
    {users.map((u) => <Select.Option key={u.id} value={u}>{u.name}</Select.Option>)}
  </Select.Options>
</Select>

| Prop | Type | Default | |------|------|---------| | value / defaultValue / onValueChange | T, T, (value: T) => void | controlled or uncontrolled; arrays with multiple | | options | Array<{ value, label, disabled?, icon?, description? }> | — ignored when children are given | | placeholder | string | 'Select option' | | multiple | boolean | false — the button joins the selected labels with ", " | | compareBy | keyof T \| (a, b) => boolean | reference equality — for object values | | invalid | boolean | inherits Field invalid | | disabled, name, form | | | | size | 'sm' \| 'md' \| 'lg' | 'md' | | fullWidth | boolean | true | | anchor | Base UI anchor | false | 'bottom start' | | portal | boolean | true when anchored | | renderOption | (option, { focus, selected, disabled }) => ReactNode | — custom option body; the check mark is still drawn |

Statics / named exports: Select.Label (passiveLabel?), Select.Button (indicator?: ReactNode \| false, autoFocus?), Select.Options (anchor?, portal?, modal?, keepMounted?), Select.Option (value, disabled?, icon?, description?, children or (state) => ReactNode). Parts inherit size / invalid / disabled from the root. Inside a Field the button takes the field's Label / FieldDescription. The option data type is SelectOptionData<T>.

Slots: select (with data-size), select-label, select-button, select-value, select-value-icon, select-chevron, select-options, select-option, select-option-icon, select-option-content, select-option-description, select-option-check.

Combobox — @zuilib/primitives/combobox

Searchable select, generic over the value type and multiple. Convenience mode takes options; compound mode takes children.

<Combobox
  value={userId}
  onValueChange={setUserId}
  options={users.map((u) => ({ value: u.id, label: u.name }))}
  placeholder="Search users..."
  onQueryChange={setQuery}
  loading={isFetching}
/>

<Combobox value={tag} onValueChange={setTag} compareBy="id">
  <Combobox.Input displayValue={(t) => t?.name ?? ''} />
  <Combobox.Button />
  <Combobox.Options>
    {tags.map((t) => <Combobox.Option key={t.id} value={t}>{t.name}</Combobox.Option>)}
  </Combobox.Options>
</Combobox>

| Prop | Type | Default | |------|------|---------| | value / defaultValue / onValueChange | T \| null (or T[] with multiple) | controlled or uncontrolled | | options | Array<{ value, label, disabled? }> | — labels double as keys, keep them unique | | placeholder | string | 'Search...' (Labels.search) | | toggleLabel | string | 'Toggle options' (Labels.toggleOptions) — name of the chevron button | | track | string | — names the select telemetry event; falls back to name, then aria-label | | multiple | boolean | false | | compareBy | keyof T \| (a, b) => boolean | reference equality | | invalid | boolean | inherits Field invalid | | disabled, name, form, id, autoFocus, aria-label, aria-describedby | | id only outside a Field | | size | 'sm' \| 'md' \| 'lg' | 'md' | | fullWidth | boolean | true | | anchor | placement | { to, gap, offset, padding } | false | 'bottom start' | | portal | boolean | true when anchored | | openOnFocus | boolean | false — open the panel as soon as the input receives focus | | onQueryChange | (query: string) => void | — every keystroke, '' on close | | filter | false \| (option, query) => boolean | case-insensitive label match; false for server-side filtering | | loading / loadingMessage | boolean, ReactNode | false, 'Loading…' (Labels.loadingMessage) | | emptyMessage | ReactNode | 'No results found.' (Labels.noResults) | | renderOption | (option, { focus, selected, disabled }) => ReactNode | | | displayValue | (value) => string | option label (single) / '' (multiple) |

Statics / named exports: Combobox.Input (displayValue?, size?), Combobox.Button (toggleLabel?; children replace the chevron), Combobox.Options (anchor?, portal?, keepMounted?, keepHighlightOnPointerLeave?, modal?, transition?), Combobox.Option (value, disabled?, children or (state) => ReactNode).

Slots: combobox (with data-size), combobox-input, combobox-button, combobox-chevron, combobox-options, combobox-option, combobox-loading, combobox-empty, combobox-status (visually hidden live region).

RadioGroup — @zuilib/primitives/radio-group

A <fieldset role="radiogroup">; the label is its <legend>. Convenience mode takes options; compound mode takes RadioGroup.Option children.

<RadioGroup
  value={plan}
  onValueChange={setPlan}
  label="Plan"
  description="Billed monthly"
  options={[
    { value: 'free', label: 'Free', description: 'Basic', icon: <StarIcon /> },
    { value: 'pro', label: 'Pro' },
  ]}
/>

<RadioGroup value={plan} onValueChange={setPlan} layout="list" orientation="horizontal">
  <RadioGroup.Label>Plan</RadioGroup.Label>
  <RadioGroup.Option value="free" label="Free" />
  <RadioGroup.Option value="pro">
    <RadioGroup.Indicator /> custom content
  </RadioGroup.Option>
</RadioGroup>

| Prop | Type | Default | |------|------|---------| | value / defaultValue / onValueChange | T, T, (value: T) => void | controlled or uncontrolled | | options | Array<{ value, label, description?, icon?, disabled? }> | — the entry type is RadioGroupOptionData<T> | | label, description | ReactNode | wired to aria-labelledby / aria-describedby | | compareBy | keyof T \| (a, b) => boolean | reference equality | | layout | 'card' \| 'list' | 'card' — bordered clickable rows, or plain radios | | orientation | 'vertical' \| 'horizontal' | 'vertical' — also aria-orientation / data-orientation | | size | 'sm' \| 'md' \| 'lg' | 'md' | | invalid | boolean | inherits Field invalidaria-invalid on the group, data-invalid on every radio | | disabled, name, form, id | | |

RadioGroup.Option props: value, label?, description?, icon?, disabled?, autoFocus?, children? (replaces the default row), className?. Statics / named exports: RadioGroup.Option, RadioGroup.Indicator, RadioGroup.Label, RadioGroup.Description.

Inside a Field the group adopts the field's control id and appends the field's Label / FieldDescription / FieldError ids. Slots: radio-group (with data-layout, data-orientation, data-size), radio-group-label, radio-group-description, radio-group-option, radio-group-option-field, radio-group-indicator, radio-group-indicator-dot, radio-group-option-icon, radio-group-option-content, radio-group-option-label, radio-group-option-description.

Fieldset — @zuilib/primitives/fieldset

<Fieldset label="Account" description="Your login details" disabled={locked}>
  <Field>…</Field>
</Fieldset>

| Prop | Type | |------|------| | label | string — rendered as the <legend>, wired to aria-labelledby | | description | string — wired to aria-describedby | | disabled | boolean — inherited by every Field / control inside |

Slots: fieldset, fieldset-legend, fieldset-description, fieldset-content.

Form layout — field, label, field-description, field-error

Field is a Base UI Field: it generates the control id and wires for, aria-labelledby and aria-describedby between its parts, so no ids are needed (an explicit id / htmlFor still wins). Used standalone or via FormField (@zuilib/primitives/form-field):

<Field invalid={Boolean(fieldError)} disabled={saving}>
  <Label required unsaved={dirty}>Email</Label>
  <Input type="email" />
  <FieldDescription>We never share your email.</FieldDescription>
  <FieldError error={fieldError} />
</Field>

| Component | Props | Notes | |---|---|---| | Field | disabled?, invalid? | disabled disables every Base UI control inside (leave unset to inherit Fieldset disabled); invalid sets data-invalid on the field and its parts and is the default invalid of every control inside. useField() / FieldContext (lib/field-context) expose { disabled, invalid } to custom controls | | Label | required?, unsaved? | required adds a marker (label-required: asterisk + visually hidden "(required)"); unsaved an "Unsaved" badge (label-unsaved, --warning dot, --warning-text, text from Labels.unsaved) | | FieldDescription | — | in the control's aria-describedby | | FieldError | error?: FieldErrorLike | FieldErrorLike = { message?: string } \| string: a string renders as is, an object renders its message (a react-hook-form field error fits without any import), otherwise children; renders nothing without text. role="alert"; in the control's aria-describedby |

Outside a Field, Label, FieldDescription and FieldError are plain <label> / <p> elements: associate them with htmlFor / id / aria-describedby yourself. Slots: field, label, label-required, label-unsaved, field-description, field-error; the parts mirror the field's data-invalid / data-disabled.

Disclosure — @zuilib/primitives/disclosure

<Disclosure title="Advanced options" defaultOpen={false} panelClassName="space-y-4">
  <Input />
</Disclosure>

| Prop | Type | |------|------| | title | ReactNode | | open / defaultOpen / onOpenChange | boolean, boolean, (open: boolean) => void — controlled or uncontrolled | | className / buttonClassName / panelClassName | root / toggle / panel |

Slots: disclosure (with data-state="open" \| "closed"), disclosure-button, disclosure-title, disclosure-icon, disclosure-panel.

Stepper — @zuilib/primitives/stepper

<Stepper
  steps={[
    { id: '1', label: 'Details', description: 'Basic info' },
    { id: '2', label: 'Review' },
  ]}
  value={step}
  onValueChange={setStep}
  orientation="horizontal"
/>

| Prop | Type | Default | |------|------|---------| | steps | StepperStep[] (Array<{ id, label, description? }>) | required | | value / defaultValue / onValueChange | number, number, (value: number) => void | controlled or uncontrolled; zero-based. onValueChange makes completed steps buttons | | nonLinear | boolean | false — with onValueChange, current and upcoming steps are clickable too | | orientation | 'horizontal' \| 'vertical' | 'horizontal' |

A <nav aria-label="Progress">; the current step has aria-current="step". Slots: stepper (with data-orientation), stepper-list, stepper-step and stepper-body (with data-state="complete" \| "current" \| "upcoming"), stepper-connector, stepper-circle, stepper-check (the completed glyph), stepper-text, stepper-label, stepper-description.

SpinnerIcon — @zuilib/primitives/lib/spinner

The indeterminate spinner icon Button and Combobox use (SpinnerIcon), for your own loading states. Draws in currentColor, sizes at 1em, stops under prefers-reduced-motion, aria-hidden (announce the busy state on the host). Takes SVG attributes; slot spinner.

Card — @zuilib/primitives/card

<Card variant="elevated" padding="md">
  <Card.Header action={<Button size="icon" variant="ghost"><MoreIcon /></Button>}>
    <Card.Title>Billing</Card.Title>
    <Card.Description>Plan and invoices</Card.Description>
  </Card.Header>
  <Card.Content>…</Card.Content>
  <Card.Footer><Button>Save</Button></Card.Footer>
</Card>
<Card as="a" href="/docs" interactive>…</Card>
<Card interactive onClick={pick}>          {/* div with role="button", Enter / Space */}
  <Card.Header><Card.Title>Team</Card.Title></Card.Header>
</Card>

| Prop | Type | Default | |------|------|---------| | variant | 'elevated' \| 'outline' \| 'ghost' | 'elevated'bg-card + border + shadow-card; outline drops the shadow; ghost is transparent | | padding | 'none' \| 'sm' \| 'md' \| 'lg' | 'md' — sets --card-padding from --card-padding-sm/md/lg; the sections read it. Below sm, md and lg step down to the next token (--card-padding-sm / -md) | | interactive | boolean | false — the card is one control: hover / active styles, focus-visible ring, cursor-pointer. as="a" / Link for navigation, as="button" for an action; any other tag (the default div) gets role="button", tabIndex={0} and Enter / Space activation of onClick | | disabled | boolean | false — dims the card (data-disabled) and blocks onClick from pointer and keyboard; a native button gets disabled, anything else aria-disabled + tabIndex={-1}. A pass-through aria-disabled="true" behaves the same | | as | ElementType | 'div' — polymorphic like Button; as="button" gets type="button" |

An interactive / button / a card is a single control, so do not put another control inside it (Card.Header action, a Button in the footer): use a plain card and put the control in a part instead (Card.Header warns in development). Inside as="button" the parts render as spans (a native button only allows phrasing content), Card.Title included; inside as="a" they keep their block markup.

A mounted Card.Title names the card: it takes the generated id and the root gets aria-labelledby (after hydration, so it never points at nothing), which gives as="section" / as="article" landmarks and interactive cards a name from the title rather than from their whole text. Your own aria-label / aria-labelledby wins.

Statics / named exports: Card.Header (action?: ReactNode pinned to the end of the row), Card.Title (as?, default h3; span inside a button card), Card.Description (<p>; span inside a button card), Card.Content, Card.Footer. Used outside a Card the sections pad themselves at md. Slots: card (with data-variant, data-padding, data-interactive, data-disabled), card-header, card-header-text, card-header-action, card-title, card-description, card-content, card-footer.

Container / Stack — @zuilib/primitives/container, @zuilib/primitives/stack

<Container size="lg" as="main">…</Container>
<Stack direction="row" gap={4} align="center" justify="between" separator>…</Stack>

| Component | Prop | Type | Default | |---|------|------|---------| | Container | size | 'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' \| 'full' | 'lg'max-w-(--container-width-*) (full is max-w-none); gutter px-(--container-padding). Override a step on :root or a subtree, or pass a max-w-* in className for one element | | Container | as | ElementType | 'div' | | Stack | direction | 'row' \| 'column' | 'column' | | Stack | gap | 0 \| 1 \| 2 \| 3 \| 4 \| 6 \| 8 | 2 — multiples of --spacing | | Stack | align | 'start' \| 'center' \| 'end' \| 'stretch' \| 'baseline' | 'stretch' | | Stack | justify | 'start' \| 'center' \| 'end' \| 'between' \| 'around' \| 'evenly' | 'start' | | Stack | wrap | boolean | false | | Stack | separator | ReactNode | — true draws a --border hairline between consecutive children; any other node is rendered inside the separator element in place of the hairline. null / false children get none; Fragments are flattened, so their members are separated like direct children | | Stack | separatorAs | ElementType | 'li' inside as="ul" \| "ol" \| "menu" (a list may only contain list items), 'div' otherwise | | Stack | separatorDecorative | boolean | truerole="none" + aria-hidden; false makes each separator a role="separator" with aria-orientation, like Separator | | Stack | separatorClassName | string | — merged last onto every separator | | Stack | as | ElementType | 'div' |

wrap and separator are meant for columns: in a wrapping row the vertical rule is a flex item too, so it can land first or last on a line and only spans that line's height.

Slots: container (with data-size); stack (with data-direction, data-gap), stack-separator (with data-orientation, data-decorative).

Separator — @zuilib/primitives/separator

<Separator />
<Separator orientation="vertical" decorative className="h-6" />
<Separator>or</Separator>

| Prop | Type | Default | |------|------|---------| | orientation | 'horizontal' \| 'vertical' | 'horizontal' | | decorative | boolean | falserole="separator" + aria-orientation; true renders role="none" | | children | ReactNode | — a label centred between two lines, named via aria-labelledby ('', null, booleans draw a plain rule) | | lineClassName | string | merged onto the drawn line(s) in both modes — the root of a plain rule, both separator-line spans of a labelled one (bg-primary, h-0.5) | | labelClassName | string | merged onto the label |

role is not accepted: decorative is the only switch between role="separator" and role="none".

A vertical separator is h-full self-stretch, so it takes its height from a flex parent (<Stack direction="row">, flex items-center). Outside a flex parent it collapses to 0px; pass an explicit height (className="h-6") as in the example. The labelled vertical variant needs a definite height the same way, since its two flex-1 lines share the root's height.

Slots: separator (with data-orientation, data-decorative), separator-line, separator-label.

Heading / Text — @zuilib/primitives/heading, @zuilib/primitives/text

<Heading as="h1">Settings</Heading>
<Heading as="h3" size="md" weight="medium" truncate>…</Heading>
<Text muted size="sm">Helper copy</Text>
<Text as="label" htmlFor="name" weight="medium">Name</Text>

| Component | Prop | Type | Default | |---|------|------|---------| | Heading | as | 'h1' … 'h6' | 'h2' | | Heading | size | 'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' \| '3xl' | per level: h1 3xl, h2 2xl, h3 xl, h4 lg, h5 md, h6 sm (--text-*) | | Heading | weight | 'normal' \| 'medium' \| 'semibold' \| 'bold' | 'semibold' | | Heading | tracking | 'tighter' \| 'tight' \| 'normal' \| 'wide' \| 'wider' \| 'widest' | 'tight' at 2xl / 3xl; below that no class is set, so letter-spacing inherits (--tracking-*) | | Heading | truncate | boolean | false | | Text | as | 'p' \| 'span' \| 'div' \| 'label' | 'p'as="label" requires htmlFor | | Text | size | 'xs' \| 'sm' \| 'md' \| 'lg' | 'md' (text-base) | | Text | weight | 'normal' \| 'medium' \| 'semibold' \| 'bold' | 'normal' | | Text | tone | 'neutral' \| 'success' \| 'warning' \| 'danger' | 'neutral' — status tones use --success-text / --warning-text / --danger-text | | Text | muted | boolean | false — de-emphasised (--muted-foreground); orthogonal to tone and wins the colour | | Text | truncate / lineClamp | boolean / number | — lineClamp (a positive integer) wins over truncate |

Both map to the type scale only: size sets --text-* (font size + line height), weight sets --font-weight-*; nothing hardcodes a pixel size. A heading's as is its outline level and size its look, so the two are independent. Every Text tone meets WCAG AA (4.5:1) on --background in both themes; the plain --success / --warning / --danger tokens are surfaces and are not used as text colour. There is nothing dimmer than muted: on the light theme --muted-foreground is already at the AA floor.

Text as="label" is a bare <label> with the typography props; it does not join a Base UI Field, which is why htmlFor is required. Use Label for form labels that wire themselves to the control. truncate on an inline tag (span, label) adds inline-block max-w-full so there is a box to overflow; lineClamp ignores non-integers (-webkit-line-clamp takes an integer).

Slots: heading (with data-size, data-weight, data-truncate), text (with data-size, data-weight, data-tone, data-muted, data-truncate, data-line-clamp).

Code / Kbd — @zuilib/primitives/code, @zuilib/primitives/kbd

<Code language="ts">const x = 1</Code>
<Code block language="tsx" className="max-h-64">{source}</Code>
<Kbd>⌘</Kbd> <Kbd keys={['Ctrl', 'Shift', 'P']} />

| Component | Prop | Type | Default | |---|------|------|---------| | Code | block | boolean | false — inline <code>; true renders <pre><code> (padded, overflow-x-auto, tabIndex={0} with a keyboard focus ring so it scrolls from the keyboard) | | Code | language | string | — data-language on the root and language-<name> on the <code> (what Prism / highlight.js look for); no colouring itself | | Code | codeClassName | string | block only: merged onto the inner <code> | | Kbd | keys | string[] | — one styled <kbd> per key inside an outer <kbd>, joined by separator; a non-empty array takes precedence over children | | Kbd | separator | ReactNode | '+' — read by assistive tech so a chord announces as Ctrl+Shift+P |

Slots: code, code-block (the <pre>); kbd, kbd-group (the root when keys is given), kbd-separator.

Alert — @zuilib/primitives/alert

<Alert tone="warning" dismissible onOpenChange={() => setSeen(true)}>
  <Alert.Title>Unsaved changes</Alert.Title>
  <Alert.Description>Save before leaving.</Alert.Description>
  <Alert.Actions><Button size="sm">Save</Button></Alert.Actions>
</Alert>

| Prop | Type | Default | |------|------|---------| | tone | 'info' \| 'success' \| 'warning' \| 'danger' \| 'neutral' | 'info' — a 10% tint / 30% border of the tone hue under text-foreground; info tints from --primary (--info is a surface token, not a hue; retheme it via [data-slot="alert"][data-tone="info"]); neutral is bg-muted | | icon | ReactNode \| false | per-tone icon (success / warning glyphs use the --success-text / --warning-text contrast twins); false or null removes the icon column; larger custom icons are not clipped | | politeness | 'assertive' \| 'polite' \| 'off' | 'assertive' (role="alert"); polite is role="status" for info / success / neutral; an explicit role prop wins | | dismissible | boolean | false — close button; a click calls onOpenChange(false) and (uncontrolled) removes the alert. If keyboard focus is inside the alert when it goes, focus moves to the element it came from, else the next tabbable element after the alert (then the previous one, then a focusable ancestor) instead of dropping to <body> | | open / defaultOpen / onOpenChange | boolean, boolean, (open: boolean) => void | controlled or uncontrolled (defaultOpen defaults to true) — while controlled, the close button only calls onOpenChange(false) and the alert renders while open is true, so the parent can veto / defer the dismissal and show it again without a remount | | closeLabel | string | 'Close' (Labels.close) — composed with the Alert.Title text via aria-labelledby (“Close, Unsaved changes”), so several alerts' close buttons stay distinguishable; a custom id on the title opts out |

Statics / named exports: Alert.Title, Alert.Description, Alert.Actions. Slots: alert (with data-tone, data-politeness, data-dismissible), alert-icon, alert-content, alert-title, alert-description, alert-actions, alert-close, alert-close-icon.

Badge — @zuilib/primitives/badge

<Badge>New</Badge>
<Badge tone="warning" variant="subtle" dot shape="pill" size="sm">Pending</Badge>
<Badge tone="success" icon={<CheckIcon />} onRemove={() => remove(tag)}>Deployed</Badge>

| Prop | Type | Default | |------|------|---------| | variant | 'solid' \| 'subtle' \| 'outline' | 'solid' — visual style only: solid paints the tone as the surface, subtle is bg-<hue>/10 + text-<hue>-text (--primary-text, --danger-text, --success-text, --warning-text: the hue made legible as small text; neutral tints --muted at /60), outline draws a border in the tone with no fill (neutral: border-border + text-foreground) | | tone | 'neutral' \| 'primary' \| 'success' \| 'warning' \| 'danger' \| 'info' | 'primary'info reads the --primary hue (as Alert does; --info is a surface token), distinct only through data-tone | | size | 'sm' \| 'md' | 'md' — paddings are --spacing multiples, height comes from --text-xs / --text-sm line-height | | shape | 'rounded' \| 'pill' | 'rounded' | | dot | boolean | false — status dot before the content, bg-current | | icon | ReactNode | — aria-hidden, sized 1em | | onRemove | (event) => void | — renders a remove button (type="button", 24px hit box, focusRingClasses); the click does not bubble to the badge's onClick | | removeLabel | string | Remove <children> when the children are text (string, number, or an array of those), else 'Remove' |

A <span>; onRemove adds the only control. Set max-w-* in className to truncate the content with an ellipsis. Slots: badge (with data-variant, data-tone, data-size, data-shape, data-removable), badge-dot, badge-icon, badge-content, badge-remove, badge-remove-icon.

Avatar — @zuilib/primitives/avatar

<Avatar src={user.avatarUrl} name="Ada Lovelace" status="online" />
<Avatar.Group max={3} size="sm">
  {users.map((u) => <Avatar key={u.id} name={u.name} src={u.avatarUrl} />)}
</Avatar.Group>

| Prop | Type | Default | |------|------|---------| | src | string | — the fallback shows until it loads; on error it stays. A failed URL is not retried until src changes (or the avatar is re-mounted with a new key) | | name | string | — source of the initials ("Ada Lovelace"AL, first code point of the first and last word) and the default alt | | alt | string | name — the accessible name (role="img"); '' makes the avatar decorative (aria-hidden, status included). With neither, and no aria-label / aria-labelledby, it is decorative too | | fallback | ReactNode | initials, else a person icon | | size | 'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' | 'md'sm / md / lg are --control-height-*; inherits Avatar.Group | | shape | 'circle' \| 'square' | 'circle'; inherits Avatar.Group. Any radius in className reaches the image too (rounded-[inherit]) | | status | 'online' \| 'offline' \| 'busy' \| 'away' | — dot on the bottom-end corner, appended to the label and shown as the dot's title. Each status is a shape as well as a colour: online a solid --success disc, away a --warning crescent, busy a --danger disc with a --danger-foreground bar, offline a hollow ring (--muted fill, --muted-foreground outline) | | statusLabel | string | capitalised status | | imgProps | Omit<ImgHTMLAttributes, 'src' \| 'alt' \| 'onLoad' \| 'onError'> | — spread on the <img> (loading, srcSet, crossOrigin, referrerPolicy, …) |

Avatar.Group (role="group"): size?, shape?, max? (the rest collapse into a +N avatar), overflowLabel?: (hidden, names) => string (default "N more: <hidden names>"; the +N avatar also carries the hidden names as its title). Children must be Avatar elements; Fragments around them are unwrapped so max counts avatars. The ring that separates overlapping avatars (and cuts out the status dot) is --avatar-ring, falling back to --background: set --avatar-ring: var(--card) on a card to blend it in. getInitials(name) is a named export. Slots: avatar (with data-size, data-shape, data-status, data-state="loading" \| "loaded" \| "error" \| "fallback"), avatar-image, avatar-initials, avatar-fallback, avatar-icon, avatar-status (with data-status), avatar-group; the overflow avatar is avatar + data-overflow.

Skeleton / Spinner — @zuilib/primitives/skeleton, @zuilib/primitives/spinner

<Skeleton width={240} height={32} />
<Skeleton shape="circle" width={48} />
<Skeleton shape="text" lines={3} animation="wave" label="Loading comments" />
<Spinner size="lg" label="Loading results" className="text-primary" />

| Component | Prop | Type | Default | |---|------|------|---------| | Skeleton | shape | 'text' \| 'rectangle' \| 'circle' | 'rectangle'text renders lines bars 1em tall | | Skeleton | width / height | number \| string | rectangle / text w-full, circle size-(--control-height-md); numbers are px. For text, width sizes the stack and height each line; a circle given one of them uses it for both | | Skeleton | lines | number | 1 — the last of several bars is w-3/5 | | Skeleton | animation | 'pulse' \| 'wave' \| 'none' | 'pulse'wave is --animate-skeleton-wave sweeping a foreground/8 sheen; both stop under prefers-reduced-motion | | Skeleton | label | string | — visually hidden; makes the root role="status" (no aria-busy, which would defer the label; put that on the host), otherwise it is aria-hidden | | Spinner | size | 'sm' \| 'md' \| 'lg' \| 'xl' | 'md' — sizes the root (size-4 / 5 / 6 / 8); a size-* in className replaces it and the ring follows | | Spinner | label | string | 'Loading' — visually hidden; '' when the host already names the busy state |

Spinner is a <span role="status"> around the lib/spinner icon (draws in currentColor; a static ring under prefers-reduced-motion). For both a labelled Skeleton and Spinner, screen readers announce changes inside a live region that is already mounted, not one inserted together with its text: keep the region in the document and toggle its label (or its visibility), or let the host carry the state (aria-busy, or Button loading). Slots: skeleton (with data-shape, data-animation), skeleton-line, skeleton-label; spinner (with data-size), spinner-icon, spinner-label.

Progress — @zuilib/primitives/progress

<Progress value={42} aria-label="Upload" showValue />
<Progress aria-labelledby="sync-heading" />  {/* indeterminate */}

| Prop | Type | Default | |------|------|---------| | value | number \| null | — null / undefined is indeterminate; clamped to [0, max] | | max | number | 100 — must be finite and positive, otherwise 100 | | size | 'sm' \| 'md' \| 'lg' | 'md' — track h-1 / h-2 / h-3 | | tone | 'primary' \| 'success' \| 'warning' \| 'danger' | 'primary' | | showValue | boolean | false — the formatted value beside the bar (determinate only) | | formatValue | (value, max) => string | whole percentage; also aria-valuetext | | aria-label / aria-labelledby | string | one of the two is required (typed; a development warning when neither reaches the DOM) |

role="progressbar" with aria-valuemin/max/now/valuetext, aria-busy while indeterminate (--animate-progress-indeterminate, a pulse under prefers-reduced-motion). Direction-aware: under dir="rtl" the track is mirrored, so the fill anchors to the right edge and the indeterminate sweep runs right-to-left. Slots: progress (with data-state="determinate" \| "indeterminate", data-tone, data-size), progress-track, progress-indicator, progress-label.

EmptyState — @zuilib/primitives/empty-state

<EmptyState icon={<InboxIcon />} title="No messages" description="Messages you receive show up here." actions={<Button>Compose</Button>} />

<EmptyState size="lg">
  <EmptyState.Icon><InboxIcon /></EmptyState.Icon>
  <EmptyState.Title as="h2">Nothing here</EmptyState.Title>
  <EmptyState.Description>…</EmptyState.Description>
  <EmptyState.Actions>…</EmptyState.Actions>
</EmptyState>

| Prop | Type | Default | |------|------|---------| | icon, title, description, actions | ReactNode | — the prop parts; children render after them. The prop title is a <div>; compose EmptyState.Title as="h2" for a heading in the outline | | size | 'sm' \| 'md' \| 'lg' | 'md' — scales gap, padding, icon disc and type through context |

Statics / named exports: EmptyState.Icon (aria-hidden muted disc), EmptyState.Title (a <div> by default; as="h2" for a heading), EmptyState.Description, EmptyState.Actions (centred flex-wrap row). Slots: empty-state (with data-size), empty-state-icon, empty-state-title, empty-state-description, empty-state-actions.

Tooltip — @zuilib/primitives/tooltip

<Tooltip content="Delete" placement="bottom" arrow>
  <Button size="icon" variant="ghost" aria-label="Delete"><TrashIcon /></Button>
</Tooltip>
<Tooltip content="…">{(props) => <button type="button" {...props}>hover me</button>}</Tooltip>

Positioned with @floating-ui/react (flip, shift, autoUpdate). The child is cloned with the trigger props merged (its own ref, handlers and aria-describedby are composed, not overwritten), or a render function receives them.

The trigger must be keyboard focusable, or keyboard and screen-reader users can never open the tooltip: use a button or a link. A plain <span> / <div> / <svg> child gets tabIndex={0} automatically; a custom component that mounts a non-focusable node logs a warning in development.

| Prop | Type | Default | |------|------|---------| | content | ReactNode | required | | placement | floating-ui Placement | 'top' | | delay | number \| { open?, close? } | { open: 200, close: 0 } (hover only; keyboard focus opens at once) | | offset | number | 1.5 spacing units (--spacing × 1.5 = 6px; the arrow height is added automatically) | | arrow | boolean | false | | disabled | boolean | false — never opens; closes an open tooltip | | open / defaultOpen / onOpenChange | | controlled or uncontrolled | | portal | boolean | true — only honoured together with anchor={false}; use anchor={false} to keep the panel inline so subtree token overrides reach it | | className / arrowClassName / panelProps | | panel (the ref target) / arrow / extra panel attributes |

Opens on mouse hover and keyboard focus (touch taps do not open it); moving the pointer onto the tooltip keeps it open (WCAG 1.4.13). Closes on Escape (the key is not swallowed: a surrounding Dialog closes on the same press), on pressing the trigger, and on an outside press. role="tooltip" and aria-describedby on the trigger while open. The surface colour is the --tooltip token (--foreground by default) and the arrow fills with it, so className="[--tooltip:var(--popover)] text-popover-foreground" recolours both. Slots: tooltip (with data-state="open", data-placement, data-side), tooltip-arrow; the trigger gets data-state="open" \| "closed".

Dialog — @zuilib/primitives/dialog

<Dialog open={open} onOpenChange={setOpen} size="md">
  <Dialog.Panel>
    <Dialog.Header>
      <Dialog.Title>Delete project</Dialog.Title>
      <Dialog.Description>This cannot be undone.</Dialog.Description>
      <Dialog.Close />
    </Dialog.Header>
    <Dialog.Body>…</Dialog.Body>
    <Dialog.Footer>
      <Dialog.Close as={Fragment}><Button variant="outline">Cancel</Button></Dialog.Close>
      <Button tone="danger">Delete</Button>
    </Dialog.Footer>
  </Dialog.Panel>
</Dialog>

Base UI Dialog (portalled, focus-trapped, scroll-locked) with a backdrop and a centred panel; both transition on data-[closed].

| Prop | Type | Default | |------|------|---------| | open / defaultOpen / onOpenChange | boolean, boolean, (open: boolean) => void | controlled or uncontrolled — same contract as Drawer: onOpenChange(false) runs on Escape / backdrop click (while dismissible) and from Dialog.Close; a controlled dialog flips open in response | | size | 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full' | 'md' — panel max-width from --dialog-width-sm / -md / -lg / -xl (24 / 32 / 42 / 56rem); below sm every size is the viewport minus a 1rem gutter; full fills the viewport and pads past the safe areas | | scrollBehavior | 'inside' \| 'outside' | 'inside' — the panel is capped at the viewport and Dialog.Body scrolls; outside scrolls the page-level container | | dismissible | boolean | truefalse ignores Escape and backdrop clicks (Dialog.Close still works) | | initialFocus | MutableRefObject<HTMLElement \| null> | — overrides autoFocus with an explicit element | | autoFocus | boolean | true — focuses the data-autofocus element (<Dialog.Close autoFocus />, any Base UI button with autoFocus), else the dialog root; false focuses the first focusable element instead | | role | 'dialog' \| 'alertdialog' | 'dialog' | | className / containerClassName / backdropClassName | | root / centring layer / backdrop | | track | string | — names the open / close telemetry events (emitted whenever a provider is present) |

Statics / named exports: Dialog.Panel (bg-popover, shadow-4; padding from --dialog-padding, default --spacing * 6, which Dialog.Body reads too so its scrollbar stays at the panel edge — retheme the padding through that property rather than p-*), Dialog.Title (<h2>, aria-labelledby; as="h1" to fit the outline), Dialog.Description (aria-describedby), Dialog.Header / Dialog.Body / Dialog.Footer (actions in DOM order, stacked below sm, so put the primary action last), Dialog.Close (label?, default 'Close' from Labels.close; without children an icon button pinned top-right; with children a plain button; as={Fragment} hands the close behaviour to the single child, e.g. a Button). Slots: dialog (with data-size, data-scroll-behavior, data-dismissible, and data-open), dialog-backdrop, dialog-container, dialog-panel (with data-size), dialog-header, dialog-title, dialog-description, dialog-body, dialog-footer, dialog-close, dialog-close-icon. The root is print:hidden; its modal contents mount in a Base UI portal on the client.

CommandPalette — @zuilib/primitives/command-palette

<CommandPalette
  open={open}
  onOpenChange={setOpen}
  shortcut="mod+k"
  items={[
    { id: 'new', label: 'New file', shortcut: '⌘N', keywords: ['create'], group: 'file', onSelect: createFile },
    { id: