Kanzo UI
Getting started

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.

packages/ui/src/simples/foo.tsx
"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

Chartered

Amber Hall

Chartered

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. a4 in light is a3 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 style for variant appearance. A one-off computed structural style (a width derived from drag state) is fine; a hard-coded constant is not — minWidth: 200 belongs in min-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 why Swatch exists 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-content and its siblings, never text-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, never ring-ring/NN. A diluted ring measured 1.29:1 on the page, and WCAG 1.4.11 names a focus indicator first. bg-field is the related case: border-input outlines a control and bg-field fills it, and the fill is a recess — a dilution where one recedes, the page itself where none does — so it never takes a /NN either. 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 every rem here resolves against a root font-size the density axis moves — 16px default, 14px compact, 18px comfortable. So min-h-6 is 24px at default and 21px at compact: the failure dressed as the fix. Write min-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 a className that overrides one.
  • Exactly one <main> per page, and ShellMain owns it. Nestable containers use <section>. SidebarInset is a neutral offset <div> that carries no landmark — the ShellMain you 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.

On this page