Styling
How a component is written, and how you restyle one from outside.
The recipe
Every component follows this shape. Deviating from it is how a design system rots.
"use client"; // only if this file uses hooks or listeners
import { ark } from "@ark-ui/react/factory";
import { tv, type VariantProps } from "tailwind-variants";
import { cn } from "../lib/cn.js";
export const fooVariants = tv({
base: "…token-backed utilities…",
variants: { variant: {}, size: {} },
defaultVariants: { variant: "default", size: "md" },
});
export interface FooProps
extends React.ComponentProps<typeof ark.button>,
VariantProps<typeof fooVariants> {}
export const Foo = (props: FooProps) => {
const { variant = "default", size = "md", className, slot, ...rest } = props;
return (
<ark.button
className={cn(fooVariants({ variant, size }), className)}
{...rest}
data-slot={slot ?? "foo"}
/>
);
};React 19, not 18
The React peer is >=19, where ref is an ordinary prop. Use React.ComponentProps<…>,
which already includes it, and a plain function component. forwardRef still works but is
redundant, and ComponentPropsWithoutRef silently drops ref.
Restyling from outside
Three escape hatches, in order of preference.
Change a token
The whole system re-skins. --radius-field: 0 squares every button and input; a new --primary restains
every brand surface. Nothing about the components changes.
Amber Hall
Amber Hall
Target a data-slot
Every element you might want to reach carries data-slot="<component>-<part>". It is a real
contract, not decoration — our own recipes depend on it:
[data-slot="popover-content"] {
border-radius: 0;
}This is what you get instead of guessing at generated class names.
As the recipe draws it
Under [data-slot="badge"]
A part owns its slot, written after your props, so a stray data-slot in what you spread can
never delete one our recipes select on. To rename one deliberately, pass slot:
<TabsContent slot="preview-pane" value="preview">Every part takes it, and it is React's own slot attribute rather than a prop we invented.
Pass className
Merged with tailwind-merge, so your utility wins over the recipe's rather than fighting it
on specificity.
The last one spells bg-base-a5rather than a percentage: an alpha step is solved against each mode's own ramp, so it is right in light and dark, while bg-base/5 lands on a different step in each. in light is in dark for the same number.
Rules
Themeable or structural is the line, and it is about re-themeability rather than class-name
purity. Anything a token or a theme axis could change — colour, radius, typography, the spacing
scale, borders, shadows, animation — belongs in the tv() recipe, including when it is
conditional: a ternary assembling border-e border-border in the function body is the same
violation as an inline style, wearing a different hat. Pure box model no theme touches — flex,
min-w-0, shrink-0, absolute inset-0, overflow-hidden — is fine inline, and a variant-less
tv() is a string with extra steps. The test: if a consumer re-skinned the library through tokens,
would they expect this to change?
- Never inline
stylefor variant appearance. A one-off computed structural style (a width derived from drag state) is fine; a hard-coded constant is not —minWidth: 200belongs inmin-w-[200px], where a class can still override it. A colour that is data is the sanctioned exception — the one a user picked, or a theme's own — which is whySwatchexists rather than a<span style>per call site. - Only token-backed utilities. No raw hex, no raw palette classes. On a status fill the ink is
text-destructive-contentand its siblings, nevertext-white— see Theming for why that is a third token and not-foreground. - Focus rings are
outline-none focus-visible:ring-[3px] focus-visible:ring-ring— solid, neverring-ring/NN. A diluted ring measured 1.29:1 on the page, and WCAG 1.4.11 names a focus indicator first.bg-fieldis the related case:border-inputoutlines a control andbg-fieldfills it, and the fill is a recess — a dilution where one recedes, the page itself where none does — so it never takes a/NNeither. An opacity dilutes a solid toward transparent and lands wherever the thing underneath puts it. - A pressable target owes its 24×24 floor in pixels, not in
rem. WCAG 2.5.8 states CSS pixels, and everyremhere resolves against a root font-size the density axis moves — 16px default, 14px compact, 18px comfortable. Somin-h-6is 24px at default and 21px at compact: the failure dressed as the fix. Writemin-h-[24px]. It is the one size in a recipe that must not scale, because the bar it answers to does not — and it applies just as much to aclassNamethat overrides one. - Exactly one
<main>per page, andShellMainowns it. Nestable containers use<section>.SidebarInsetis a neutral offset<div>that carries no landmark — theShellMainyou place inside it does. - Files are kebab-case.
Compound components
Parts are exported flat, not as namespaces:
import { Dialog, DialogContent, DialogTitle } from "@kanzo-tech/ui";Not Dialog.Content. Flat exports tree-shake per part and match Shark.
Which name a part gets follows from what it wraps. An Ark machine takes the bare name — Accordion,
Field, Pagination, Table, InputGroup — and its parts are base plus part: AccordionItem,
FieldLabel. A compound of our own takes *Root, because the bare name would name a concept
rather than an element: ShellRoot, SectionRoot, ChartRoot.
There is a harder reason than two dialects to avoid the namespace, and Preferences learned it: an
Object.assign namespace does not survive the RSC client boundary. Once the module becomes a client
reference, Preferences.Density reads back as undefined and React throws "Element type is
invalid". A dot-notation namespace is allowed only beside the flat names, never instead of them.
The rules for writing a part rather than using one — ark.* everywhere, who owns a data-slot, why
a layout tree may not be a prop — are on Conventions.