Theme
Chunks UI uses a CSS variable-based design token system following the shadcn/ui naming convention. Colors are defined in the OKLCH color space for perceptual uniformity (--ring is an OKLab mix of two of them).
Setup
Import the theme in the CSS file that imports Tailwind:
@import "tailwindcss";
@import "chunks-ui/theme.css";This single file provides all CSS variables for light mode, dark mode, and Tailwind v4 @theme inline tokens. It also includes an @source for the package, so Tailwind generates the classes the components use.
Color System
Semantic Colors (OKLCH)
These values are the same in light and dark mode. Each foreground has at least 4.5:1 contrast (WCAG 2 AA) with its fill.
| Token | Value | Purpose |
|---|---|---|
--primary | oklch(0.56 0.2165 257.21) | Brand blue |
--primary-foreground | oklch(0.985 0 0) | Text on primary |
--success | oklch(75.14% 0.1514 166.5) | Positive actions |
--success-foreground | oklch(0.145 0 0) | Text on success |
--warning | oklch(77.97% 0.1665 72.45) | Caution states |
--warning-foreground | oklch(0.145 0 0) | Text on warning |
--destructive | oklch(0.582 0.2249 25.88) | Errors, deletions |
--destructive-foreground | oklch(0.985 0 0) | Text on destructive |
Coloured Text
Text in a semantic colour needs 4.5:1 contrast with the background. Some fills do not have it: success text on white is about 2:1. So coloured text uses its own colours: primary-text, success-text, warning-text and destructive-text, for example text-destructive-text. The outlined and text variants of Button and IconButton, Chip, Field.Error, the current day in Calendar and the hover colour of ClearButton use them.
Each colour keeps the hue and chroma of its fill and changes only the lightness, with CSS relative colour syntax:
| Mode | Lightness | Example |
|---|---|---|
| Light | min(l, 0.51): at most 0.51 | oklch(from var(--primary) min(l, 0.51) c h) |
| Dark | max(l, 0.63): at least 0.63 | oklch(from var(--primary) max(l, 0.63) c h) |
With these limits the default colours keep 4.5:1 on the background and on the /10 tint that Chip uses. The --colored-text-lightness variable holds the rule for each mode.
Only .dark redefines --colored-text-lightness. A light-styled subtree inside .dark should set --colored-text-lightness: min(l, 0.51) itself, and a dark surface without .dark should set max(l, 0.63).
The limits are tuned for the default fills. Very saturated teal or cyan custom fills (hue about 185 to 190, chroma 0.15 or more) can land below 4.5:1, so set --primary-text (or the matching --X-text) for them.
The colour is computed on the element that uses it. If you set --primary on :root or on a nested element, the text shade inside follows it. To use a different shade, set --primary-text, --success-text, --warning-text or --destructive-text. These tokens have no default value.
.brand-section {
--primary: oklch(0.55 0.2 293); /* fills, and the text shade derived from them */
--primary-text: oklch(0.45 0.2 293); /* optional: a shade of your choice */
--ring: color-mix(in oklab, var(--primary) 60%, var(--background)); /* the ring does not follow on its own */
}The border of a control needs 3:1 contrast, and the success and warning fills have about 2:1 on white. So the outlined success and warning variants of Button and IconButton use border-success-border and border-warning-border. In light mode these cap the fill’s lightness at 0.64 (success) and 0.67 (warning), which gives 3:1 on white, and a darker fill keeps its lightness. In dark mode they are the fill. --success-border-lightness and --warning-border-lightness hold the rule for each mode; a light subtree inside .dark sets them to min(l, 0.64) and min(l, 0.67) itself. To use a different shade, set --success-border or --warning-border.
Relative colour syntax needs Chrome 119+, Safari 18+ or Firefox 128+. Older browsers show this text, and these borders, in the inherited text colour.
Surface Colors
| Token | Light | Dark |
|---|---|---|
--background | oklch(1 0 0) | oklch(0.145 0 0) |
--foreground | oklch(0.145 0 0) | oklch(0.985 0 0) |
--card | oklch(1 0 0) | oklch(0.145 0 0) |
--card-foreground | oklch(0.145 0 0) | oklch(0.985 0 0) |
--popover | oklch(1 0 0) | oklch(0.145 0 0) |
--popover-foreground | oklch(0.145 0 0) | oklch(0.985 0 0) |
Neutral Palette
| Token | Light | Dark |
|---|---|---|
--secondary | oklch(0.97 0 0) | oklch(0.269 0 0) |
--secondary-foreground | oklch(0.205 0 0) | oklch(0.985 0 0) |
--muted | oklch(0.97 0 0) | oklch(0.269 0 0) |
--muted-foreground | oklch(0.53 0 0) | oklch(0.708 0 0) |
--accent | oklch(0.97 0 0) | oklch(0.269 0 0) |
--accent-foreground | oklch(0.205 0 0) | oklch(0.985 0 0) |
Utility Tokens
| Token | Light | Dark |
|---|---|---|
--border | oklch(0.922 0 0) | oklch(0.269 0 0) |
--input | oklch(0.922 0 0) | oklch(0.269 0 0) |
--ring | color-mix(in oklab, var(--primary) 60%, var(--background)) | same as light |
--radius | 0.625rem | 0.625rem |
--touch-target-size (optional) | 44px (fallback, not defined) | 44px (fallback, not defined) |
Focus Ring
Controls draw their keyboard focus ring in --ring, and so do the Popover, Menu and Dialog popups when they take focus themselves. By default it is 60% --primary and 40% --background, a softer shade of the primary: lighter in light mode, darker in dark mode. The mix is in OKLab, which has no hue to interpolate, so the ring keeps the primary’s hue. Set --ring to use a colour of your choice.
The ring is 2px wide and sits 2px outside the edge of the part, so it reaches 4px past it. Tabs.Content, whose container clips at the edge, draws it inside instead; so does Drawer.Popup, so that the border on its open side stays in view. To draw a part’s ring flush with its edge, pass focus-visible:outline-offset-0 in its className; on Combobox.Control, NumberField.Group and Slider.Thumb, which draw the ring while the control inside has focus, it is has-[:focus-visible]:outline-offset-0. On an Input with adornments, pass a function className: a string goes to the wrapper, not the input. Radio.Item and the Calendar buttons take no className for the control; to build a flush-ring radio, make the item from Radio.Root and Radio.Indicator.
A custom property resolves its var() on the element that declares it, and its children inherit the result. theme.css declares --ring on :root and .dark, so a --primary or --background you set on :root changes the ring too. A nested element that sets --primary or --background keeps the ring of its parent: declare --ring on that element as well, as in the .brand-section example above.
Tailwind Usage
Variables are mapped to Tailwind utilities via @theme inline:
@theme inline {
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-success: var(--success);
--color-destructive: var(--destructive);
--color-primary-text: var(
--primary-text,
oklch(from var(--primary) var(--colored-text-lightness) c h)
);
/* ... */
}This means you can use them directly in Tailwind classes:
<div className="bg-primary text-primary-foreground" />
<div className="bg-destructive text-destructive-foreground" />
<div className="border-border text-muted-foreground" />
<p className="text-destructive-text">Something went wrong.</p>Dark Mode
Dark mode is toggled by adding the .dark class to the <html> element (same convention as shadcn/ui). The same variable names are redefined with dark values:
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.56 0.2165 257.21);
--colored-text-lightness: max(l, 0.63);
/* ... */
}Components automatically adapt — no extra props needed.
Typography
| Token | Value |
|---|---|
--font-sans | "Manrope", sans-serif |
--font-mono | "Fira Code", monospace |
--font-heading | not defined: titles inherit their font until you set it |
Use in Tailwind: font-sans, font-mono, font-heading.
Manrope and Fira Code are not shipped with the package. Load them in your app, or the browser falls back to its generic sans-serif and monospace. Tailwind’s preflight applies --font-sans to the page, so importing the theme changes the page font. To keep your own font, set the token after the import:
@import "tailwindcss";
@import "chunks-ui/theme.css";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}The Title parts of Card, Dialog, Drawer, Popover and Empty use font-heading. Set --font-heading on :root or any element to style the titles inside.
Easing Curves
| Token | Value | Use case |
|---|---|---|
--ease-fluid | cubic-bezier(0.3, 0, 0, 1) | General transitions |
--ease-snappy | cubic-bezier(0.2, 0, 0, 1) | Quick, responsive motion |
Use in Tailwind: ease-fluid, ease-snappy.
Most components that animate with a CSS transition use the micro-interactions class from theme.css. It transitions every property for 300ms on Tailwind’s default curve, --default-transition-timing-function (cubic-bezier(0.4, 0, 0.2, 1) unless your theme sets another), except an outline focus ring: outline and outline-offset change at once, so a keyboard focus ring shows in its final colour, width and offset. A ring drawn with a box-shadow (ring-* utilities) still fades. A duration-* utility next to the class replaces all of the class’s durations, and a delay-* utility delays every entry. On a bare element a duration-* utility brings the ring fade back, and a delay-* utility delays the ring. The parts of the library avoid that, as described below. With reduced motion the class turns the transition off, and no transition-* utility turns it back on. A few parts have their own transitions instead: the Accordion panel and chevron, the Progress indicator, and the fade-in of the ScrollArea scrollbar track. These turn their own transitions off with reduced motion, so a determinate Progress bar jumps to each new value. The indeterminate Progress pulse keeps running, because it is an essential status indicator. The ScrollArea thumb uses the class.
The class sits in the components layer, so timing utilities in className override it on parts that keep their CSS transition, for example <Button className="duration-150 ease-snappy">. Parts that Motion drives, such as Tabs.Indicator, the Switch thumb and popups that open with a Motion spring, drop the class while Motion runs. Do not put timing utilities on the Switch thumb: a duration-* class on its own makes the browser transition all properties, on top of the values Motion writes. A timing utility on Tabs.Indicator retimes its fade with a disabled tab (and its CSS slide); give Tabs.Tab the same one so they match.
Every part that draws an outline focus ring has its own list of transitioned properties, set with [transition-property:…], and the list has no outline. These are the Button family, ClearButton, the Pagination links, Breadcrumb.Link, Tabs (tab and panels), ToggleGroup items, Checkbox, Radio, Switch, ThemeToggle, Input, Textarea, Select, Combobox, NumberField, Slider thumb, Accordion and Collapsible triggers, the DatePicker trigger, the Calendar buttons, the Toast buttons, the Dialog body, and the Popover, Menu and Dialog popups while Motion runs (these without opacity, transform, translate, scale and rotate). The CSS fallback of the Popover, Menu, Dialog, Tooltip, Select, Combobox and Drawer popups keep their own lists too. So a keyboard focus ring shows at once, also with a duration-* or delay-* utility, which applies to the listed properties only: hover and press fades take your timing. Tailwind sorts transition-shadow, transition-all, transition-colors and transition-[…] before the list, so on these parts such a utility in className changes neither the properties nor, on parts with a duration-300, the duration. transition-none still turns the transition off. To animate another property, set the whole list yourself, for example [transition-property:color,background-color,border-color,opacity,scale,box-shadow]: tailwind-merge then replaces the library’s list, and the one duration-300 stays, so every property in your list fades for the same time. A duration-* utility replaces that 300ms. A list that contains outline or all brings the ring fade back.
The list approximates all without the outline: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, translate, scale, rotate, filter, backdrop-filter, width, height and border-radius. Parts that used micro-interactions now animate this list instead of all, so margin, padding, gap and other layout properties change at once; [transition-property:…] animates others.
Most of these share that list and carry one duration-300 when they fade at all. Button, IconButton, ClearButton and the Pagination links list only color, background-color, border-color, opacity and scale. Parts that have no transition of their own, such as Select, Combobox, NumberField, the Accordion and Collapsible triggers, the DatePicker trigger, Tabs.Panel and the Dialog body, have the list without a duration, so nothing animates until you add a duration-* or transition-* utility (150ms by default; the listed properties only). These parts have no micro-interactions class, so when you add one of those utilities, add motion-reduce:transition-none too. Textarea and Slider.Thumb list only color, background-color and border-color, with duration-150. If you set [transition-property:a,b] next to the bare micro-interactions class on your own element, add a duration such as duration-300 too: the class has three durations (0.3s, 0s, 0s) that repeat over your list, and the second property would change at once.
Component Sizing
| Token | Value |
|---|---|
--spacing-ui-height | 35px |
--spacing-ui-icon-height | calc(var(--spacing-ui-height) * 0.5) |
Z-Index Layers
The z-index system provides ordered layers to prevent stacking conflicts:
| Token | Value | Tailwind class | Use case |
|---|---|---|---|
--z-index-base | 0 | z-base | Default content |
--z-index-dropdowns | 200 | z-dropdowns | Select, Combobox, Menu, Popover and DatePicker popups |
--z-index-tooltips | 400 | z-tooltips | Tooltips |
--z-index-overlays | 500 | z-overlays | Not used by components. Free for your own overlays above popups and below drawers |
--z-index-drawers | 600 | z-drawers | Drawer.Portal |
--z-index-modals | 700 | z-modals | Dialog.Portal |
--z-index-notifications | 800 | z-notifications | Not used by components. Free for your own banners and notices |
--z-index-toasts | 810 | z-toasts | Toast.Viewport |
--z-index-cursor | 900 | z-cursor | NumberField.ScrubAreaCursor, which stands in for the pointer while scrubbing |
Dialog.Portal and Drawer.Portal put their layer on the portal element, which is a stacking context. Their popup and backdrop stack inside it. Popups, dialogs and drawers opened from inside a dialog or drawer render into its portal, so they stack above it whatever their own layer. The scrub cursor of a NumberField there stacks in that layer instead of z-cursor, so a Toast at the root (z-toasts) paints over it during a scrub. The portal of a Popover, Combobox, Menu, Select or Tooltip is not a stacking context, so there the cursor stays at z-cursor, unless that popup is itself inside a Dialog or Drawer. To move a dialog or drawer to another layer, set className on its Portal, for example <Dialog.Portal className="z-notifications">.
While a modal popup is open (Menu and Select are modal by default, Popover and Combobox with modal), Base UI lays an invisible backdrop over the page, so a press outside the popup closes it and reaches nothing. The backdrop takes the popup’s z-dropdowns layer, so it also covers page elements below that layer (200), such as a sticky z-10 header. Elements at or above the layer still take the press. A positioner moved to another layer by its className, such as <Select.Positioner className="z-50">, or by an inline z-index,, or with a ! in any class (z-10!), leaves the backdrop with no z-index: page elements with a z-index then take presses while the popup is open.
Border Radius
| Token | Value |
|---|---|
--radius-sm | calc(var(--radius) - 4px) |
--radius-md | calc(var(--radius) - 2px) |
--radius-lg | var(--radius) (0.625rem) |
--radius-xl | calc(var(--radius) + min(var(--radius), 4px)) |
Chart Colors
Five chart colors are provided for data visualization, compatible with shadcn/ui charting:
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);Customization
To override tokens, redefine the CSS variables after importing the theme:
@import "chunks-ui/theme.css";
:root {
--primary: oklch(70% 0.2 150);
--radius: 0.5rem;
}All components will pick up the new values automatically.