Getting started
Install
pnpm add @zuilib/primitives
| Peer | Version |
|---|---|
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 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 + 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 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/:
| Subpath | Exports |
|---|---|
@zuilib/primitives/lib/cn | cn(): clsx + tailwind-merge, the way the components merge className |
@zuilib/primitives/lib/icons | The glyphs the components share (ChevronDownIcon, CheckIcon, CloseIcon, SearchIcon, status icons) |
@zuilib/primitives/lib/spinner | SpinnerIcon |
@zuilib/primitives/lib/focus | The focus-ring class strings |
@zuilib/primitives/lib/sizes | ControlSize and the shared size maps |
@zuilib/primitives/lib/field-context | useField() for custom controls inside a Field |
@zuilib/primitives/lib/transitions | The data-closed enter / leave recipes for Headless UI's transition |
@zuilib/primitives/lib/labels | LabelsProvider / useLabels(): the built-in strings, translated once (see below) |
@zuilib/primitives/lib/telemetry | TelemetryProvider / 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>
| Key | Default | Read by |
|---|---|---|
close | Close | Dialog.Close, Drawer.Close, Alert |
loading | Loading | Button loading, Spinner, SearchInput loading |
loadingMessage | Loading… | Combobox, CommandPalette |
noResults | No results found. | Combobox, CommandPalette |
search | Search... | Combobox placeholder |
toggleOptions | Toggle options | Combobox.Button |
selectOption | Select option | Select placeholder |
remove, removeItem(name) | Remove, Remove {name} | Badge, FileUpload |
clear | Clear | SearchInput |
increment, decrement | Increment, Decrement | NumberInput |
progress | Progress | Stepper |
chooseFile, chooseFiles, dragAndDrop, noFilesSelected, filesSelected(count) | Choose file, Choose files, or drag and drop, No files selected, {n} files selected | FileUpload |
pinCell(index, length, type) | Digit n of N / Character n of N | PinInput |
commandPalette, commandPalettePlaceholder, recent | Command palette, Type a command or search…, Recent | CommandPalette |
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>
| Component | Event | When |
|---|---|---|
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
zuiCLI. - CommandPalette: the ⌘K launcher built on Dialog and Combobox.
- Tokens: the full variable contract.