Customization
The kit does not force its look. Pick the lowest level that gets you what you want; each level below is more work and gives more control.
- Variables: remap tokens with CSS custom properties. No component code changes.
classNamesper part: restyle one element of a component.- Variant maps: reuse or extend the classes behind
variant,toneandsize. - Headless primitives: keep the behavior and accessibility, write all the markup and styles yourself.
1. Override CSS variables
Section titled “1. Override CSS variables”Components only read semantic tokens (bg, surface, ink, accent, --radius-card, --control-height, …). Override them on any element and its subtree follows, in light and dark:
/* app/globals.css, after the kit imports */:root { --accent: #6d28d9; --radius-card: 6px; --font-sans: "Inter", system-ui, sans-serif;}
/* a different value in dark mode (follows prefers-color-scheme and [data-theme]) */@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --accent: #a78bfa; }}:root[data-theme="dark"] { --accent: #a78bfa;}Density and touch sizing work the same way. data-density="touch" (or compact, comfortable, or auto to follow the pointer type) rescales --control-height, --input-height and the paddings.
Specificity matters: the kit sets density, accent and style values on attribute selectors such as :root[data-density="touch"], which beat a plain :root or [data-density="touch"] rule. Match the kit’s selector (or higher) and load your CSS after the kit’s:
/* loaded after the kit's styles */:root[data-density="touch"] { --control-height: 48px;}Two caveats:
data-accent: when an<html data-accent="menta">(or any accent preset) is set, the preset’s:root[data-accent="menta"]rule beats a plain:root { --accent }. Override with the same selector (:root[data-accent="menta"] { --accent: ... }) or drop the attribute and use your own value only.data-stylepresets set--radius-*through--radius-*-overridevariables. To change a radius under a preset, set the override, for example:root[data-style] { --radius-card-override: 6px; }; a plain--radius-cardis replaced by the preset.
For a whole new look, copy tokens/default.json, edit it and run npx ui-theme build my-tokens.json --out src/theme. See Theming and presets for presets, precedence and the token file shape.
Font sizes
Section titled “Font sizes”Dense UI text uses a size-only scale: text-fs-100, text-fs-110, text-fs-120, text-fs-130, text-fs-140, text-fs-150, text-fs-160, text-fs-200, text-fs-240, text-fs-440 (the number is the pixel size times 10). They set font-size only, so weight and line height stay with the component. Redefine them in your own @theme block to rescale every component:
@theme inline { --text-fs-120: 13px;}2. classNames per part
Section titled “2. classNames per part”Components made of more than one styled element accept the same two props:
classNametargets the root element, as before. It is merged last, so it beatsclassNames.rooton a conflicting utility (order: kit <classNames.root<className).classNames={{ part: "..." }}targets any other part. The part names are exported as a type per component (ToasterPart,DialogContentPart,FieldPart, …), so your editor completes them.
Classes are merged with cn, so on a conflicting utility yours wins over the kit’s (classNames.<part> beats the kit; className beats classNames.root):
<Switch classNames={{ track: "bg-slate-300 group-data-[state=checked]:bg-emerald-500", thumb: "rounded-md", }}/>
<Banner tone="warning" title="Heads up" classNames={{ title: "uppercase tracking-wide" }}> ...</Banner>Rules that hold across the kit:
- Single-part components forward
className, the rest of the DOM props and theref. - Compound components (
Card,CardHeader,Tabs,TabsTrigger,DropdownMenuItem, …) takeclassNameon each piece instead of aclassNamesobject. - Portaled parts (dialog scrim, close button, toast list) are reached through
classNamestoo, since they live outside the root. slideClassNameonCarousel(from earlier releases) still sets the default slide width, andclassNames.slideadds to it.DialogContentandDrawerContentuseclassNames.overlayandclassNames.close;classNametargets the content element (classNames.root).
Part names
Section titled “Part names”The same part keeps the same name across components:
| Part | Meaning |
|---|---|
root |
The outermost element, the same node className targets (for DialogContent and DrawerContent, the content panel) |
title |
Heading text (Banner, Toaster, Dropzone, FloatingToc …) |
description |
Secondary text below a title |
label |
Short text beside a control or marker |
icon |
A leading or decorative icon |
dot |
The coloured dot (Led, StatusDot, StatusPill) |
header |
The clickable header row of ToolCall, or the heading row of CodeBlock |
body |
The content area |
overlay, close |
Scrim and close button of overlays |
action |
An action slot |
Command.Group headings are rendered by the primitive; style them from the group with a child selector, for example classNames={{ group: "[&_[data-ui-command-group-heading]]:uppercase" }} on CommandPalette.
3. Variant maps
Section titled “3. Variant maps”The classes behind a variant are plain exported objects, so you can read them, extend them or reuse them on other elements:
import { Button, buttonClasses, buttonVariantClasses } from "@jcsoftdev/ui/atoms/button";
// the kit's look on a router link, without wrapping it in <Button><Link className={buttonClasses({ variant: "primary", size: "sm" })} to="/new"> New</Link>;
// a custom variant next to the built-in onesconst variants = { ...buttonVariantClasses, brand: "bg-fuchsia-600 text-white hover:bg-fuchsia-700",};<Button className={variants.brand}>Brand</Button>;Also exported: buttonSizeClasses, chipToneClasses (Chip), eventToneClasses (EventBlock), dayChipToneClasses (DayChip). For any other variant or tone you can always pass className: it is merged last, so it overrides the variant’s classes. The maps are typed Readonly<Record<...>>; spread them into a new object to extend them.
4. Custom tokens in your own class merging
Section titled “4. Custom tokens in your own class merging”cn knows the kit’s theme tokens (text-meta, h-control, rounded-card, …). If you add your own, create an instance that knows them too, so text-hero and text-lg conflict instead of both being kept:
import { createCn, extendCn, cn as kitCn } from "@jcsoftdev/ui/utils";
export const cn = createCn({ theme: { text: ["hero"], spacing: ["gutter"], radius: ["blob"], shadow: ["glow"] },});
// or start from the kit's configurationexport const cn2 = extendCn(kitCn, { theme: { text: ["hero"] } });Use that instance in your own components and wrappers. The kit’s internal merge always uses the default cn; a custom-token class you pass through className or classNames still applies, but is not de-duplicated against the kit’s own class for the same property. When that matters, override the CSS variable (level 1) instead.
5. Headless primitives
Section titled “5. Headless primitives”When classes are not enough, build on the same behavior without the kit’s markup: focus management, dismissal, roving focus, positioning and ARIA are in the unstyled primitives (dialog, popover, menu, command and more) that the styled components are made of. They are published under @jcsoftdev/ui/primitives; see Headless primitives for the full list, a minimal unstyled example of each, data attributes and CSS variables.