Skip to Content
Utilities

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:

  • cn and cnState, with their ClassInput and StateClassName types;
  • buttonVariants, chipVariants, iconButtonVariants and inputVariants (which carries the text size of Input, 16px below md and on a touch screen, else 14px: see Text Size);
  • springs, the Motion spring presets;
  • mergeLabels, which merges a labels prop over a component’s defaults;
  • sanitizeLayout, which makes a saved Resizable layout safe to hand to a group, with the ResizableLayout and ResizablePanelConfig types;
  • the English label defaults, DEFAULT_*_LABELS, and their <Component>Labels types (see Labels);
  • the ELEMENT_* constants and the Element* types, such as ElementColor.

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:

TokenClassesExample
Z-index layersz-base, z-dropdowns, z-tooltips, z-overlays, z-drawers, z-modals, z-notifications, z-toasts, z-cursorcn("z-dropdowns", "z-50") → "z-50"
Component sizingui-height, ui-icon-height in any spacing utility (h-, size-, min-h-, p-…)cn("h-ui-height", "h-8") → "h-8"
Heading fontfont-headingcn("font-heading", "font-mono") → "font-mono"
Easing curvesease-fluid, ease-snappycn("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 like cn and 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, Skeleton and Table, take a string.
  • Tabs.Content, Tabs.Contents, Tabs.Animate and DatePicker render or wrap a plain <div>, so they take a string.
  • Radio.Item puts it on its <label>, so it takes a string. Style the radio itself with Radio.Root.
  • Input with startAdornment, endAdornment, clearable or onClear (and InputCopy, 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

TypeDescription
ClassInputWhat 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-target makes the area larger only when the primary pointer is coarse, for example on a phone or tablet.
  • any-pointer-coarse:touch-target makes 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 as md:), an inline style or unlayered CSS wins, and the area is still centred.
  • CSS in @layer base or @layer components does not win over that position: relative.
  • Do not combine touch-target with static (or unlayered position: 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-events from the control, so a control with pointer-events: none, such as a disabled IconButton, 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 disabled Checkbox.Root, Switch.Root or ClearButton keeps 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 Chip with onRemove goes to the Chip. Use touch-target only 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, Popover and Tooltip, and a Select that opens below its trigger, have position: 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 overflow other than visible, a contain with paint, or content-visibility: auto on the box, or on a box around it, cuts off the popups placed against the box at its edges. A clip-path on the element or an ancestor cuts off every popup at its edges, the fixed parts too, and so does a mask in Chromium and Firefox.
  • Parts with position: fixed need a box with none of the listed properties. These are Dialog, Drawer, a Select aligned over its trigger (opened by touch, or with too little room to align, it opens below the trigger with position: absolute), the scrub cursor, and the backdrops, including the invisible one that Base UI puts behind a modal popup (Dialog, Drawer, Menu and Select are 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. A position alone does not move them, though fixed and sticky form a stacking context.
  • The popups stack inside the nearest stacking context around the element. A z-index on a positioned element or a flex or grid item, position: fixed or sticky, an opacity below 1, isolation: isolate, a mix-blend-mode, a mask, a clip-path or one of the listed properties forms one. The z-index layers 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 a sticky z-10 header beside a relative z-0 <main> that holds the element.
  • The popups inherit from the element. pointer-events: none on it or an ancestor stops presses on them, inert also stops focus, and visibility: hidden hides 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.

Last updated on