Skip to content

Customization

Open .md

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.

  1. Variables: remap tokens with CSS custom properties. No component code changes.
  2. classNames per part: restyle one element of a component.
  3. Variant maps: reuse or extend the classes behind variant, tone and size.
  4. Headless primitives: keep the behavior and accessibility, write all the markup and styles yourself.

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-style presets set --radius-* through --radius-*-override variables. To change a radius under a preset, set the override, for example :root[data-style] { --radius-card-override: 6px; }; a plain --radius-card is 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.

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;
}

Components made of more than one styled element accept the same two props:

  • className targets the root element, as before. It is merged last, so it beats classNames.root on 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 the ref.
  • Compound components (Card, CardHeader, Tabs, TabsTrigger, DropdownMenuItem, …) take className on each piece instead of a classNames object.
  • Portaled parts (dialog scrim, close button, toast list) are reached through classNames too, since they live outside the root.
  • slideClassName on Carousel (from earlier releases) still sets the default slide width, and classNames.slide adds to it. DialogContent and DrawerContent use classNames.overlay and classNames.close; className targets the content element (classNames.root).

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.

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 ones
const 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 configuration
export 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.

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.