Getting started
Install
pnpm add @zuilib/components react react-dom @headlessui/react
| Peer | Version |
|---|---|
react, react-dom | ^18.0.0 |
@headlessui/react | ^2.2.0 |
tailwindcss | ^4.0.0, optional: only for the two Tailwind entries below |
@zuilib/tokens is a dependency of the package and is loaded by every
stylesheet entry; you do not install it separately.
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/components/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/components/tailwind.css | Tailwind source without Tailwind: the component @source + tokens + the slider's vendor CSS. Import it after your tailwindcss import |
| No Tailwind | @zuilib/components/zui.css | Prebuilt, flat CSS with Tailwind's preflight reset. Nothing to process |
| No Tailwind, own reset / base styles | @zuilib/components/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/components/styles.css";
Tailwind app that already imports Tailwind
For apps with their own source(), @theme or plugin setup:
/* app.css */
@import "tailwindcss";
@import "@zuilib/components/tailwind.css";
tailwind.css carries @source "../**/*.{js,ts,tsx}", resolved relative
to the file, so the component JS under node_modules/@zuilib/components/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/components/zui.css' // or '@zuilib/components/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/components/button'
import Input from '@zuilib/components/input'
import Listbox from '@zuilib/components/listbox'
<Listbox value={value} onChange={setValue}>
<Listbox.Button>{value?.label ?? 'Pick one'}</Listbox.Button>
<Listbox.Options>
{options.map((o) => <Listbox.Option key={o.id} value={o}>{o.label}</Listbox.Option>)}
</Listbox.Options>
</Listbox>
Compound parts are attached as statics (Listbox.Option) and also exported
by name (ListboxOption). Shared helpers live under lib/:
| Subpath | Exports |
|---|---|
@zuilib/components/lib/cn | cn(): clsx + tailwind-merge, the way the components merge className |
@zuilib/components/lib/icons | The glyphs the components share (ChevronDownIcon, CheckIcon, XIcon, SearchIcon, status icons) |
@zuilib/components/lib/spinner | Spinner |
@zuilib/components/lib/focus | The focus-ring class strings |
@zuilib/components/lib/sizes | ControlSize and the shared size maps |
@zuilib/components/lib/form-item-context | useFormItem() for custom controls inside a FormItem |
@zuilib/components/lib/transitions | The data-closed enter / leave recipes for Headless UI's transition |
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/components/zui.css'
import Button from '@zuilib/components/button'
import Description from '@zuilib/components/description'
import FormItem from '@zuilib/components/form-item'
import Input from '@zuilib/components/input'
import Label from '@zuilib/components/label'
import Message from '@zuilib/components/message'
export function SignIn() {
return (
<form className="flex max-w-sm flex-col gap-4">
<FormItem>
<Label required>Email</Label>
<Input type="email" required fullWidth />
<Description>Work address, please.</Description>
</FormItem>
<FormItem invalid>
<Label>Password</Label>
<Input type="password" fullWidth />
<Message>At least 12 characters.</Message>
</FormItem>
<Button type="submit">Sign in</Button>
</form>
)
}
FormItem gives the control its id, aria-labelledby, aria-describedby,
aria-invalid and disabled state. See FormItem.