Skip to main content

Getting started

Install

pnpm add @zuilib/primitives
PeerVersion
react, react-dom^18.0.0
tailwindcss^4.0.0, optional: only for the two Tailwind entries below

Headless UI and @zuilib/tokens are package dependencies and are loaded automatically; you do not install them separately. react-hook-form is an optional peer used only by the Form integration.

Load one stylesheet

The components are written in Tailwind v4 utility classes that map to the tokens. Load exactly one of these four entries. 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 appImportWhat it is
Tailwind v4, and you do not @import "tailwindcss" yourself@zuilib/primitives/styles.cssAll-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.cssTailwind source without Tailwind: the component @source + tokens + the slider's vendor CSS. Import it after your tailwindcss import
No Tailwind@zuilib/primitives/zui.cssPrebuilt, flat CSS with Tailwind's preflight reset. Nothing to process
No Tailwind, own reset / base styles@zuilib/primitives/zui-no-preflight.cssThe same prebuilt CSS 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 line

styles.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. That also lets you use the token utilities (bg-primary, rounded-md, text-muted-foreground, …) in your own markup.

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

Tailwind app that already imports Tailwind

For apps with their own source(), @theme or 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.

App without Tailwind

zui.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" and @source are only meaningful to Tailwind.

Import components

One subpath per component; the default export is the component. There is no barrel, so a bundle carries only what it imports.

import Button from '@zuilib/primitives/button'
import Input from '@zuilib/primitives/input'
import Select from '@zuilib/primitives/select'

<Select value={value} onValueChange={setValue}>
<Select.Button>{value?.label ?? 'Pick one'}</Select.Button>
<Select.Options>
{options.map((o) => <Select.Option key={o.id} value={o}>{o.label}</Select.Option>)}
</Select.Options>
</Select>

Every root component is also a named export (import { Select } from '@zuilib/primitives/select'). Compound parts are attached as statics (Select.Option) and also exported by name (SelectOption). Shared helpers live under lib/:

SubpathExports
@zuilib/primitives/lib/cncn(): clsx + tailwind-merge, the way the components merge className
@zuilib/primitives/lib/iconsThe glyphs the components share (ChevronDownIcon, CheckIcon, CloseIcon, SearchIcon, status icons)
@zuilib/primitives/lib/spinnerSpinnerIcon
@zuilib/primitives/lib/focusThe focus-ring class strings
@zuilib/primitives/lib/sizesControlSize and the shared size maps
@zuilib/primitives/lib/field-contextuseField() for custom controls inside a Field
@zuilib/primitives/lib/transitionsThe data-closed enter / leave recipes for Headless UI's transition
@zuilib/primitives/lib/labelsLabelsProvider / useLabels(): the built-in strings, translated once (see below)
@zuilib/primitives/lib/telemetryTelemetryProvider / useTelemetry(): opt-in usage events (see below)

Dark mode

The tokens switch on a dark class. Add it to <html> for the whole app or to any wrapper for a dark region; the components read the variables and need nothing else. color-scheme is set with it, so native controls and scrollbars follow.

document.documentElement.classList.toggle('dark', prefersDark)
<section class="dark">…a dark panel on a light page…</section>

React Server Components

Every component file begins with 'use client', which the build keeps in the published JS. Import a component from a server component as usual; it becomes a client boundary on its own. Nothing in the package reads the DOM at import time, so server rendering is safe; overlays (Dialog, Drawer, Menu, Popover) portal to <body> on the client.

A first form

import '@zuilib/primitives/zui.css'
import Button from '@zuilib/primitives/button'
import Field from '@zuilib/primitives/field'
import FieldDescription from '@zuilib/primitives/field-description'
import FieldError from '@zuilib/primitives/field-error'
import Input from '@zuilib/primitives/input'
import Label from '@zuilib/primitives/label'

export function SignIn() {
return (
<form className="flex max-w-sm flex-col gap-4">
<Field>
<Label required>Email</Label>
<Input type="email" required fullWidth />
<FieldDescription>Work address, please.</FieldDescription>
</Field>
<Field invalid>
<Label>Password</Label>
<Input type="password" fullWidth />
<FieldError>At least 12 characters.</FieldError>
</Field>
<Button type="submit">Sign in</Button>
</form>
)
}

Field gives the control its id, aria-labelledby, aria-describedby, aria-invalid and disabled state. See Field.

Internationalisation

Every string a component renders on its own (the name of an icon button, a placeholder, an empty-list message) has an English default and a prop that overrides it (Dialog.Close label, Combobox toggleLabel, Button loadingLabel, …). To translate them once instead of on every element, wrap the app in LabelsProvider from @zuilib/primitives/lib/labels. A component reads the provider's string and lets its own prop win. Providers nest; an inner one overrides only the keys it sets. labels is compared entry by entry, so an inline object is fine; define function values (removeItem, pinCell) outside the component that renders the provider so they keep their identity.

import { LabelsProvider } from '@zuilib/primitives/lib/labels'

const removeItem = (name) => `Retirer ${name}`
const pinCell = (index, length) => `Case ${index + 1} sur ${length}`

<LabelsProvider
labels={{
close: 'Fermer',
loading: 'Chargement',
noResults: 'Aucun résultat.',
removeItem,
pinCell,
}}
>
<App />
</LabelsProvider>
KeyDefaultRead by
closeCloseDialog.Close, Drawer.Close, Alert
loadingLoadingButton loading, Spinner, SearchInput loading
loadingMessageLoading…Combobox, CommandPalette
noResultsNo results found.Combobox, CommandPalette
searchSearch...Combobox placeholder
toggleOptionsToggle optionsCombobox.Button
selectOptionSelect optionSelect placeholder
remove, removeItem(name)Remove, Remove {name}Badge, FileUpload
clearClearSearchInput
increment, decrementIncrement, DecrementNumberInput
progressProgressStepper
chooseFile, chooseFiles, dragAndDrop, noFilesSelected, filesSelected(count)Choose file, Choose files, or drag and drop, No files selected, {n} files selectedFileUpload
pinCell(index, length, type)Digit n of N / Character n of NPinInput
commandPalette, commandPalettePlaceholder, recentCommand palette, Type a command or search…, RecentCommandPalette

useLabels() returns the resolved set for your own components. Text you pass as children (option labels, titles) is never touched.

Telemetry

Product analytics is opt-in and off by default. Wrap the app (or a region) in TelemetryProvider from @zuilib/primitives/lib/telemetry and every event below it reaches onEvent; without a provider nothing is built or emitted beyond one context read.

import { TelemetryProvider } from '@zuilib/primitives/lib/telemetry'

<TelemetryProvider onEvent={(event) => analytics.track(`${event.component}.${event.action}`, event)}>
<App />
</TelemetryProvider>
ComponentEventWhen
Button{ component: 'button', action: 'click', name }Only with a track="name" prop
Dialog{ component: 'dialog', action: 'open' | 'close', name? }open changes (and once on a mount that starts open); track names it
Menu{ component: 'menu', action: 'select', name?, value? }An item is activated; name is the Menu track, value the item's track or its text child
Tabs{ component: 'tabs', action: 'change', name?, value: index }The selected tab changes
Combobox{ component: 'combobox', action: 'select', name?, value }A value is chosen; name falls back to name, then aria-label
CommandPalette{ component: 'command-palette', action: 'select', name?, value: item.id }A command runs

useTelemetry() returns { track, enabled } for your own components; track is a no-op without a provider.

Density and print

Every sized control reads its box from --control-height-sm/md/lg and --control-padding-x-*, so a density mode is a token override on any subtree and needs no component prop:

[data-zui-density="compact"] {
--control-height-sm: 1.75rem;
--control-height-md: 2rem;
--control-height-lg: 2.5rem;
}
<section data-zui-density="compact">…every button and field is shorter…</section>

In print, overlays are dropped (Dialog, Drawer, Popover.Panel and Tooltip carry print:hidden) and an elevated Card loses its shadow (print:shadow-none).

Next

  • Theming: make it yours with a few CSS variables.
  • Registry: copy single components in as source with the zui CLI.
  • CommandPalette: the ⌘K launcher built on Dialog and Combobox.
  • Tokens: the full variable contract.