Utilities
Chunks UI exports the helpers its components use to merge class names: cn for plain class lists and cnState for Base UI parts whose className can be a function of their state.
import { cn, cnState, type ClassInput, type StateClassName } from "chunks-ui";In a React Server Component, import them from chunks-ui/utils.
chunks-ui/theme.css also defines a CSS utility, touch-target, that makes the hit area of a small control larger.
Server components
The chunks-ui bundle starts with "use client", because its components need state, effects and Base UI. In a React Server Component (for example, a Next.js App Router page), each export of chunks-ui is a client reference. You can render top-level components such as <Button> directly, but compound parts such as <Tabs.Root> need a client component around them, and you cannot call the functions. A call such as buttonVariants(...) throws:
Attempted to call buttonVariants() from the server but buttonVariants is on the client.Import the helpers from chunks-ui/utils. This entry has no "use client" and no React code, so its functions run on the server:
import { buttonVariants, cn } from "chunks-ui/utils";
export function ButtonDocsLink() {
return (
<a href="/components/button" className={cn(buttonVariants({ variant: "outlined" }), "rounded-full")}>
Button docs
</a>
);
}This page is a server component, and it renders the link below with that code:
chunks-ui/utils exports:
cnandcnState, with theirClassInputandStateClassNametypes;buttonVariants,chipVariants,iconButtonVariantsandinputVariants(which carries the text size ofInput, 16px belowmdand on a touch screen, else 14px: see Text Size);springs, the Motion spring presets;mergeLabels, which merges alabelsprop over a component’s defaults;sanitizeLayout, which makes a savedResizablelayout safe to hand to a group, with theResizableLayoutandResizablePanelConfigtypes;- the English label defaults,
DEFAULT_*_LABELS, and their<Component>Labelstypes (see Labels); - the
ELEMENT_*constants and theElement*types, such asElementColor.
chunks-ui exports the same names with the same behavior. In a client component, you can import them from either entry.
cn
cn joins class names with clsx , then resolves Tailwind conflicts with tailwind-merge . When two classes set the same property, the last one wins:
cn("px-4 py-2", "px-6"); // "py-2 px-6"Use it to let a className prop override your defaults:
import { cn } from "chunks-ui/utils";
import type { ComponentProps } from "react";
export function Panel({ className, ...props }: ComponentProps<"section">) {
return <section className={cn("rounded-lg border border-border p-4", className)} {...props} />;
}<Panel className="p-6" /> renders with rounded-lg border border-border p-6. Panel has no "use client", so it imports cn from chunks-ui/utils, and it works in server and client components.
What it accepts
Strings, numbers, arrays (readonly ones and as const tuples too) and dictionaries of class names to conditions. Booleans, null, undefined and 0 are skipped, so conditions can go inline:
cn("text-sm", isActive && "font-semibold", ["rounded-md", { "opacity-50": disabled }]);
// isActive true, disabled false: "text-sm font-semibold rounded-md"Functions are a type error. clsx ignores them, so a function would never produce a class. For a className that may be a function of state, use cnState.
Theme tokens
cn knows the tokens from chunks-ui/theme.css, so an override of a token class replaces it instead of adding a second one:
| Token | Classes | Example |
|---|---|---|
| Z-index layers | z-base, z-dropdowns, z-tooltips, z-overlays, z-drawers, z-modals, z-notifications, z-toasts, z-cursor | cn("z-dropdowns", "z-50") → "z-50" |
| Component sizing | ui-height, ui-icon-height in any spacing utility (h-, size-, min-h-, p-…) | cn("h-ui-height", "h-8") → "h-8" |
| Heading font | font-heading | cn("font-heading", "font-mono") → "font-mono" |
| Easing curves | ease-fluid, ease-snappy | cn("ease-fluid", "ease-in") → "ease-in" |
The z-index, sizing and easing values are on the Theme page. font-heading reads --font-heading, which has no default, so titles inherit their font until you set it.
cnState
Base UI parts accept className as a string or as a function of the part’s state. cnState is cn for those parts: pass your classes first and the consumer’s className last.
- A string (or
undefined) merges likecnand returns a string. - A function returns a new function of the state that merges your classes with its result.
Use it when you wrap a Base UI part yourself:
import { Toggle } from "@base-ui/react/toggle";
import { cnState } from "chunks-ui";
import type { ComponentProps } from "react";
export function FavoriteToggle({ className, ...props }: ComponentProps<typeof Toggle>) {
return (
<Toggle
className={cnState(
"inline-flex size-9 items-center justify-center rounded-md text-muted-foreground",
"data-pressed:text-primary-text",
className,
)}
{...props}
/>
);
}Consumers of FavoriteToggle can then pass either form, and both keep your base classes:
<FavoriteToggle aria-label="Favorite" className="size-8" />
<FavoriteToggle
aria-label="Favorite"
className={(state) => (state.pressed ? "bg-primary/10" : undefined)}
/>cn(base, className) does not compile here, because cn takes no functions. In cnState only the last argument may be one.
className functions on components
The parts that wrap a Base UI part take a className function and call it with that part’s state. Its result merges with the part’s own classes:
import { Tabs } from "chunks-ui";
<Tabs.Tab value="activity" className={(state) => (state.active ? "font-semibold" : undefined)}>
Activity
</Tabs.Tab>;Most of that state is also on the element as data-* attributes, so a plain string often does the same job: className="data-active:font-semibold".
These put className on an element that has no Base UI state:
- Plain HTML components, such as
Card,Empty,SkeletonandTable, take a string. Tabs.Content,Tabs.Contents,Tabs.AnimateandDatePickerrender or wrap a plain<div>, so they take a string.Radio.Itemputs it on its<label>, so it takes a string. Style the radio itself withRadio.Root.InputwithstartAdornment,endAdornment,clearableoronClear(andInputCopy, which always has an end adornment) puts a string on the wrapper that positions the adornments, and a function on the input inside it:
import { Input } from "chunks-ui";
import { Search } from "lucide-react";
<Input
aria-label="Search"
startAdornment={<Search className="size-4" />}
className={(state) => (state.focused ? "border-primary" : undefined)}
/>;mergeLabels
mergeLabels merges a labels prop over a component’s English defaults (see Labels), as the components do. A key set to undefined keeps the default, unlike a spread, which would overwrite it:
import { DEFAULT_CHIP_LABELS, mergeLabels } from "chunks-ui/utils";
const labels = mergeLabels(DEFAULT_CHIP_LABELS, { remove: undefined }); // { remove: "Remove" }Types
| Type | Description |
|---|---|
ClassInput | What cn accepts: a string, number, bigint, boolean or nullish value, an array of ClassInput, or a dictionary of class names to conditions. clsx’s ClassValue without functions. |
StateClassName<State> | A Base UI part’s className: string | ((state: State) => string | undefined) | undefined. The last argument of cnState. |
touch-target
touch-target makes the hit area of a small control at least 44×44px, or the size you set. The area is centred on the control and takes no space, so the control keeps its size and the layout does not change. A press anywhere in the area goes to the control. It is opt-in: no component applies it, so add it to a control that is smaller than 44px and that people tap, for example an IconButton (32px), a ClearButton (24px) or a Checkbox.Root (16px):
<IconButton aria-label="Close" className="touch-target">
<XIcon />
</IconButton>
<ClearButton className="touch-target" />
<Checkbox.Root aria-label="Accept terms" className="touch-target">
<Checkbox.Indicator />
</Checkbox.Root>The class comes with chunks-ui/theme.css. It works with variants too (Tailwind 4.1 or later for these two):
pointer-coarse:touch-targetmakes the area larger only when the primary pointer is coarse, for example on a phone or tablet.any-pointer-coarse:touch-targetmakes it larger when any pointer is coarse. Use it for hybrid devices, such as a laptop with a touch screen, where the primary pointer is the mouse or trackpad but people also tap.
Use media-style variants (pointer-coarse:, any-pointer-coarse:, min-[…]:); state variants such as hover: create the area only in that state.
Area size
The --touch-target-size token sets the smallest size of the area on each axis. It is not defined by default: without it the area is 44px, the size that WCAG 2.5.5 (Target Size, Enhanced) asks for. Set it on any scope (an element, :root, or @theme) to change it for the controls inside, with an arbitrary property or in CSS:
<div className="[--touch-target-size:48px]">
<IconButton aria-label="Close" className="touch-target">
<XIcon />
</IconButton>
</div>.toolbar {
--touch-target-size: 48px;
}A control that is larger than the token on an axis keeps its own size on that axis.
The area is the control’s ::after pseudo-element, which has position: absolute. That has these effects:
- The control gets
position: relative, so the area is centred on it. This has zero specificity, so a position utility (absolute,fixed,sticky, also with a variant such asmd:), an inline style or unlayered CSS wins, and the area is still centred. - CSS in
@layer baseor@layer componentsdoes not win over thatposition: relative. - Do not combine
touch-targetwithstatic(or unlayeredposition: static): the area then sizes to the nearest positioned ancestor and covers it. - Absolutely positioned children of the control are now positioned relative to the control.
- Your
after:classes change the area. Do not use them on the same element. - Some elements have no
::after, for example<input>,<textarea>and<img>. On these elements the area does not grow. - Overflow cuts the area. If the control or one of its parents has
overflow: hidden, the area stops at that edge. In a scroll container, an area that goes past the container’s padding makes the content scroll further. Give the container padding of at least half the difference between the area size (44px by default) and the control size. - The area covers the elements around the control, except elements that paint above it (later positioned or transformed elements, or a higher z-index). Where it covers a neighbour, a press goes to the control, also on the neighbour’s own box. Keep the centres of neighbouring controls at least the area size (44px by default) apart. If two areas overlap, the press goes to the control that comes later in the DOM.
- A disabled control does not get presses. The area inherits
pointer-eventsfrom the control, so a control withpointer-events: none, such as a disabledIconButton, gets no presses anywhere in the area. A pointer in the area hovers an enabled control, so its hover styles apply; a disabled control’s stay off. A disabledCheckbox.Root,Switch.RootorClearButtonkeeps its pointer events (it shows a not-allowed cursor), so its area takes the press and the control ignores it: a disabled native button swallows the click. - A control with interactive descendants loses them. The area paints and hit-tests above the control’s own non-positioned children, so a press on, for example, the remove button of a
ChipwithonRemovegoes to theChip. Usetouch-targetonly on a control with no interactive descendants.
PortalContainerProvider
Popups (Select, Combobox, Menu, Popover, DatePicker, Dialog, Drawer, Tooltip) and the NumberField.ScrubAreaCursor render into document.body. PortalContainerProvider renders them into the element you pass as value instead, for example an element that scopes theme variables, so they get the same tokens. Popups opened directly in the content of a Dialog, Drawer, Popover, Combobox, Menu, Select or Tooltip, and the scrub cursor of a NumberField there, render inside that popup’s own portal, so they end up in the same element.
Hold the element in state with a callback ref. A useRef().current is null on the first render, and setting it does not re-render. While value is null, popups are held back; once the element exists they render inside it. The scrub cursor is not held back: it exists only during a scrub, so it renders in <body> until the element exists.
import { Button, Popover, PortalContainerProvider } from "chunks-ui";
import { useState } from "react";
export function ScopedPopover() {
const [el, setEl] = useState<HTMLElement | null>(null);
return (
<div ref={setEl} className="dark">
<PortalContainerProvider value={el}>
<Popover.Root>
<Popover.Trigger render={<Button />}>Open</Popover.Trigger>
<Popover.Content>Rendered inside the scoped element.</Popover.Content>
</Popover.Root>
</PortalContainerProvider>
</div>
);
}usePortalContainer() returns the current container as HTMLElement | null | undefined: undefined means no provider, null means not mounted yet. Inside Popover, Combobox, Menu, Select, Tooltip, Dialog or Drawer content it is that popup’s own portal element.
Container limits
Inside the element, the popups are positioned as they are in <body>, unless the element or an ancestor inside <body> has a position other than static or one of these properties: transform, translate, rotate, scale, perspective, filter, backdrop-filter, transform-style: preserve-3d, a contain with layout or paint (as content and strict have), content-visibility: auto, or a will-change that names one of them. The element in the example has neither. A box that has one limits the popups, and so do some other properties of the element and its ancestors:
- The box must not move while a trigger stays put.
Combobox,DatePicker,Menu,PopoverandTooltip, and aSelectthat opens below its trigger, haveposition: absolute, and Base UI places them against the nearest such box. It places them again on a scroll, on a window resize, when the trigger or the popup resizes, and when the trigger moves, but not when the box moves. A box after content that changes height moves with that content, and an open popup moves with it, away from a trigger above that content or in a fixed header. - The box must be at least as wide as those popups. A popup that sizes to its content, such as a
Menu, shrinks to the width of a narrower box, down to its minimum width, and its text wraps. - The box and the boxes around it must not clip. An
overflowother thanvisible, acontainwithpaint, orcontent-visibility: autoon the box, or on a box around it, cuts off the popups placed against the box at its edges. Aclip-pathon the element or an ancestor cuts off every popup at its edges, the fixed parts too, and so does amaskin Chromium and Firefox. - Parts with
position: fixedneed a box with none of the listed properties. These areDialog,Drawer, aSelectaligned over its trigger (opened by touch, or with too little room to align, it opens below the trigger withposition: absolute), the scrub cursor, and the backdrops, including the invisible one that Base UI puts behind a modal popup (Dialog,Drawer,MenuandSelectare modal by default). They are placed against the viewport, and a listed property makes them relative to the box: a dialog centres in it, a drawer pins to its edge, a backdrop covers only the box, so a press outside it reaches the page, and the select and the scrub cursor, which Base UI places with viewport coordinates, are offset from the trigger and the pointer. Apositionalone does not move them, thoughfixedandstickyform a stacking context. - The popups stack inside the nearest stacking context around the element. A
z-indexon a positioned element or a flex or grid item,position: fixedorsticky, anopacitybelow 1,isolation: isolate, amix-blend-mode, amask, aclip-pathor one of the listed properties forms one. Thez-indexlayers of the popups and the fixed parts count only inside it: page content that paints above that context covers them and takes their presses, such as asticky z-10header beside arelative z-0<main>that holds the element. - The popups inherit from the element.
pointer-events: noneon it or an ancestor stops presses on them,inertalso stops focus, andvisibility: hiddenhides them.
To avoid these limits, render an element with no position, none of the listed properties and no stacking context straight into <body>, for example with createPortal, and give it the theme class or variables of the region its popups open from.
The popups’ own portal elements, where nested popups and scrub cursors render, have no listed property and do not clip. Those of Dialog and Drawer are fixed inset-x-0 top-0: a box that stays put and is as wide as the viewport, and a stacking context on purpose, so what opens from a dialog stacks above it (see Z-Index Layers). The others have no position.