Docs
Introduction
What Zazz is, and where to start.
Zazz is a UI kit for plain HTML. You write markup, and three things in that markup carry every styling decision: an identity (data-ui="button") says what an element is, a preset (data-button-variant="primary") picks a named look, and style utilities in style (--px: 6; --w--md: fit-content) carry one-off values. Underneath are design tokens, cascade layers, and a handful of light-DOM custom elements for behavior. There is no build step and no class names.
<div
style="
--display: flex;
--flex-wrap: wrap;
--items: center;
--gap: 4;
"
>
<button data-ui="button" type="button">Default</button>
<button data-ui="button" data-button-variant="primary" type="button">Primary</button>
<button data-ui="button" data-button-variant="muted" type="button">Muted</button>
<button data-ui="button" data-button-variant="ghost" type="button">Ghost</button>
<button data-ui="button" data-button-variant="outline" type="button">Outline</button>
<button data-ui="button" data-button-variant="info" type="button">Info</button>
<button data-ui="button" data-button-variant="success" type="button">Success</button>
<button data-ui="button" data-button-variant="warning" type="button">Warning</button>
<button data-ui="button" data-button-variant="destructive" type="button">Destructive</button>
<button data-ui="button" data-button-variant="link" type="button">Link</button>
</div>/**
* button.css — Button ([data-ui~="button"])
*
* @layer variables, components
* @requires _layers.css, _variables.css, _reset.css, _properties.css (--_*-resolved, --_focus-shadow), fields.css (--ui-field-* metrics), kbd.css (--ui-kbd-*)
* @uses color-mix(), oklch(from …) — variant hover/active tints
* @uses text-box — vertical trim (patchy browser support)
* @tokens --ui-button-* (@layer variables; hooks are named after the utilities they back; metrics default to the --ui-field-* family)
* @consumedby toggle.css, select.css, button-group.css, autocomplete.css, combobox.css,
* command.css, lightbox.css, menu.css, navigation-menu.css, popover.css
*
* Every utility-backed declaration reads one chain, highest first:
* var(--_<utility>-resolved, var(--_button-<utility>, var(--ui-button-<utility>)))
* The resolver is set by the utilities layer from the element's `style` gate
* (inline utilities and their tiers), the private by a variant or size on this
* element, and the hook by :root, a subtree, or an inline hook.
*/
@layer variables {
:root {
/* metrics follow --ui-field-* so buttons match adjacent fields; remap a
--ui-button-* token to diverge. Colors stay independent: a button is not a
field surface, and its border doesn't go primary on hover. */
--ui-button-ring-color: var(--ui-field-ring-color);
--ui-button-font-family: var(--font-family-body);
--ui-button-font-size: var(--font-size-sm);
--ui-button-font-weight: var(--font-weight-strong);
--ui-button-leading: 1;
--ui-button-bg: var(--color-background);
--ui-button-bg--hover: oklch(from var(--color-background) calc(l - 0.025) c h);
--ui-button-bg--active: oklch(from var(--color-background) calc(l - 0.05) c h);
--ui-button-text: var(--color-foreground);
--ui-button-text--hover: var(--color-foreground);
--ui-button-text--active: var(--color-foreground);
--ui-button-border-width: var(--ui-field-border-width);
--ui-button-border-style: var(--ui-field-border-style);
--ui-button-border-color: var(--ui-field-border-color);
--ui-button-border-color--hover: var(--ui-field-border-color);
--ui-button-border-color--active: var(--ui-field-border-color);
/* dual-mode: a scale number or a length */
--ui-button-gap: var(--spacing);
--ui-button-px: var(--ui-field-px);
--ui-button-min-h: var(--ui-field-h);
--ui-button-rounded: var(--ui-field-rounded);
--ui-button-icon-size: var(--ui-field-icon-size);
/* never `none`: the focus ring layers into this box-shadow list */
--ui-button-shadow: 0 0 transparent;
}
}
/* Variant privates: non-inheriting so a variant never cascades into a nested
button. Unset, they are guaranteed-invalid and the chain falls to the hook. */
@property --_button-ring-color {
syntax: "*";
inherits: false;
}
@property --_button-font-family {
syntax: "*";
inherits: false;
}
@property --_button-font-size {
syntax: "*";
inherits: false;
}
@property --_button-font-weight {
syntax: "*";
inherits: false;
}
@property --_button-leading {
syntax: "*";
inherits: false;
}
@property --_button-bg {
syntax: "*";
inherits: false;
}
@property --_button-bg--hover {
syntax: "*";
inherits: false;
}
@property --_button-bg--active {
syntax: "*";
inherits: false;
}
@property --_button-text {
syntax: "*";
inherits: false;
}
@property --_button-text--hover {
syntax: "*";
inherits: false;
}
@property --_button-text--active {
syntax: "*";
inherits: false;
}
@property --_button-border-width {
syntax: "*";
inherits: false;
}
@property --_button-border-style {
syntax: "*";
inherits: false;
}
@property --_button-border-color {
syntax: "*";
inherits: false;
}
@property --_button-border-color--hover {
syntax: "*";
inherits: false;
}
@property --_button-border-color--active {
syntax: "*";
inherits: false;
}
@property --_button-gap {
syntax: "*";
inherits: false;
}
@property --_button-px {
syntax: "*";
inherits: false;
}
@property --_button-min-h {
syntax: "*";
inherits: false;
}
@property --_button-rounded {
syntax: "*";
inherits: false;
}
@property --_button-icon-size {
syntax: "*";
inherits: false;
}
@property --_button-shadow {
syntax: "*";
inherits: false;
}
@layer ui.components {
:where([data-ui~="button"]) {
font-family: var(
--_font-family-resolved,
var(--_button-font-family, var(--ui-button-font-family))
);
font-size: var(--_font-size-resolved, var(--_button-font-size, var(--ui-button-font-size)));
font-weight: var(
--_font-weight-resolved,
var(--_button-font-weight, var(--ui-button-font-weight))
);
line-height: var(--_leading-resolved, var(--_button-leading, var(--ui-button-leading)));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
--_gap-len: var(--_gap-resolved, var(--_button-gap, var(--ui-button-gap)));
--_gap-num: var(--_gap-resolved, var(--_button-gap, var(--ui-button-gap)));
column-gap: calc(var(--_gap-len) + var(--_gap-num) * var(--spacing));
--_min-h-len: var(--_min-h-resolved, var(--_button-min-h, var(--ui-button-min-h)));
--_min-h-num: var(--_min-h-resolved, var(--_button-min-h, var(--ui-button-min-h)));
min-block-size: calc(var(--_min-h-len) + var(--_min-h-num) * var(--spacing));
min-inline-size: max-content;
--_px-len: var(--_px-resolved, var(--_button-px, var(--ui-button-px)));
--_px-num: var(--_px-resolved, var(--_button-px, var(--ui-button-px)));
padding-inline: calc(var(--_px-len) + var(--_px-num) * var(--spacing));
border-width: var(
--_border-width-resolved,
var(--_button-border-width, var(--ui-button-border-width))
);
border-style: var(
--_border-style-resolved,
var(--_button-border-style, var(--ui-button-border-style))
);
border-color: var(
--_border-color-resolved,
var(--_button-border-color, var(--ui-button-border-color))
);
border-radius: var(--_rounded-resolved, var(--_button-rounded, var(--ui-button-rounded)));
color: var(--_text-resolved, var(--_button-text, var(--ui-button-text)));
background-color: var(--_bg-resolved, var(--_button-bg, var(--ui-button-bg)));
background-clip: padding-box;
white-space-collapse: collapse;
/* The ring is published to --_focus-shadow so the utilities layer's
box-shadow emission keeps it above a --shadow or --ring utility; the ring
widths grow from 0 on :focus-visible so the ring eases in. */
--_ring-offset-width: 0px;
--_ring-width: 0px;
--_focus-in: max(0, -1 * sign(var(--_focus-ring-w, 1px)));
--_ring-fill: color-mix(
in oklch,
var(--_ring-color-resolved, var(--_button-ring-color, var(--ui-button-ring-color)))
var(--ring-opacity),
transparent
);
--_focus-shadow:
0 0 0 calc(var(--_ring-offset-width) * (1 - var(--_focus-in))) var(--ring-offset-shadow-color),
0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * (1 - var(--_focus-in)))
var(--_ring-fill, var(--ring-shadow-color)),
inset 0 0 0 calc(var(--_ring-offset-width) * var(--_focus-in)) var(--ring-offset-shadow-color),
inset 0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * var(--_focus-in))
var(--_ring-fill, var(--ring-shadow-color));
box-shadow:
var(--_focus-shadow, 0 0 #0000),
var(--_shadow-resolved, var(--_button-shadow, var(--ui-button-shadow)));
cursor: default;
text-decoration: none;
/* transparent twin of the ring for forced-colors / high-contrast modes */
outline: abs(var(--_focus-ring-w, var(--outline-width))) var(--outline-style) transparent;
outline-offset: calc(
var(--outline-offset) - var(--_focus-in) *
(2 * var(--outline-offset) + abs(var(--_focus-ring-w, var(--outline-width))))
);
transition: var(--default-transition);
user-select: none;
text-box: trim-both ex alphabetic;
}
a[href] :where([data-ui~="button"]):not(:disabled, [aria-disabled="true"]),
a[href]:where([data-ui~="button"]):not(:disabled, [aria-disabled="true"]) {
cursor: pointer;
}
/* ===========================================================================
BUTTON STATES
=========================================================================== */
@media (hover: hover) {
:where([data-ui~="button"]):hover:not(:disabled, [aria-disabled="true"]) {
color: var(--_text-resolved, var(--_button-text--hover, var(--ui-button-text--hover)));
background-color: var(--_bg-resolved, var(--_button-bg--hover, var(--ui-button-bg--hover)));
border-color: var(
--_border-color-resolved,
var(--_button-border-color--hover, var(--ui-button-border-color--hover))
);
}
}
:where([data-ui~="button"]):active:not(:disabled, [aria-disabled="true"]) {
color: var(--_text-resolved, var(--_button-text--active, var(--ui-button-text--active)));
background-color: var(--_bg-resolved, var(--_button-bg--active, var(--ui-button-bg--active)));
border-color: var(
--_border-color-resolved,
var(--_button-border-color--active, var(--ui-button-border-color--active))
);
}
:where([data-ui~="button"]):focus-visible:not(:disabled, [aria-disabled="true"]) {
--_ring-offset-width: var(--ring-offset-width);
--_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
outline-color: transparent;
}
/* before the variants at the same specificity: a disabled primary keeps its color; only the default surface goes muted */
:where([data-ui~="button"]:is(:disabled, [aria-disabled="true"])) {
--_button-text: var(--color-muted-foreground);
--_button-bg: var(--color-muted);
--_button-shadow: 0 0 transparent;
--decoration-line: none;
cursor: default;
opacity: 0.5;
outline: none;
}
:where([data-ui~="button"])[aria-busy="true"] {
cursor: progress;
}
/* ===========================================================================
BUTTON ICONS
=========================================================================== */
:where([data-ui~="button"]) > svg {
inline-size: var(--ui-button-icon-size);
block-size: var(--ui-button-icon-size);
pointer-events: none;
justify-content: center;
align-items: center;
display: flex;
padding: 0;
}
/* ===========================================================================
BUTTON VARIANTS — data-button-variant writes the privates
=========================================================================== */
:where([data-ui~="button"][data-button-variant="outline"]) {
--_button-bg: transparent;
--_button-shadow: 0 0 transparent;
}
:where([data-ui~="button"][data-button-variant="primary"]) {
--_button-bg: var(--color-primary);
--_button-bg--hover: oklch(from var(--color-primary) l c h / 0.9);
--_button-bg--active: oklch(from var(--color-primary) l c h / 0.8);
--_button-text: var(--color-primary-foreground);
--_button-text--hover: var(--color-primary-foreground);
--_button-text--active: var(--color-primary-foreground);
--_button-border-color: transparent;
--_button-border-color--hover: transparent;
--_button-border-color--active: transparent;
--_button-shadow: 0 0 transparent;
}
:where([data-ui~="button"][data-button-variant="secondary"]) {
--_button-bg: var(--color-secondary);
--_button-bg--hover: oklch(from var(--color-secondary) l c h / 0.9);
--_button-bg--active: oklch(from var(--color-secondary) l c h / 0.8);
--_button-text: var(--color-secondary-foreground);
--_button-text--hover: var(--color-secondary-foreground);
--_button-text--active: var(--color-secondary-foreground);
--_button-border-color: transparent;
--_button-border-color--hover: transparent;
--_button-border-color--active: transparent;
--_button-shadow: 0 0 transparent;
}
:where([data-ui~="button"][data-button-variant="tertiary"]) {
--_button-bg: var(--color-tertiary);
--_button-bg--hover: oklch(from var(--color-tertiary) l c h / 0.9);
--_button-bg--active: oklch(from var(--color-tertiary) l c h / 0.8);
--_button-text: var(--color-tertiary-foreground);
--_button-text--hover: var(--color-tertiary-foreground);
--_button-text--active: var(--color-tertiary-foreground);
--_button-border-color: transparent;
--_button-border-color--hover: transparent;
--_button-border-color--active: transparent;
--_button-shadow: 0 0 transparent;
}
:where([data-ui~="button"][data-button-variant="muted"]) {
--_button-bg: var(--color-muted);
--_button-bg--hover: oklch(from var(--color-muted) l c h / calc(alpha - 0.02));
--_button-bg--active: var(--color-muted);
--_button-text: var(--color-foreground);
--_button-text--hover: var(--color-foreground);
--_button-text--active: var(--color-foreground);
--_button-border-color: transparent;
--_button-border-color--hover: transparent;
--_button-border-color--active: transparent;
--_button-shadow: 0 0 transparent;
}
:where([data-ui~="button"][data-button-variant="ghost"]) {
--_button-bg: transparent;
--_button-bg--hover: var(--color-muted);
--_button-bg--active: oklch(from var(--color-muted) l c h / calc(alpha - 0.02));
--_button-text: var(--color-foreground);
--_button-text--hover: var(--color-foreground);
--_button-text--active: var(--color-foreground);
--_button-border-color: transparent;
--_button-border-color--hover: transparent;
--_button-border-color--active: transparent;
--_button-shadow: 0 0 transparent;
}
/* Status variants (info, success, warning, destructive) share one soft
design: the status color at 10% alpha (20% in dark) as the surface, and
the status hue as the text with its lightness clamped (at most 0.45 in
light, at least 0.82 in dark) so it holds 4.5:1 on the tint and on the
hover and active steps; status-contrast.browser.test.ts checks it. No border. One rule derives the states from the
surface; each variant names only its colors. */
:where(
[data-ui~="button"]:is(
[data-button-variant="info"],
[data-button-variant="success"],
[data-button-variant="warning"],
[data-button-variant="destructive"]
)
) {
--_button-bg--hover: oklch(from var(--_button-bg) l c h / calc(alpha + 0.1));
--_button-bg--active: oklch(from var(--_button-bg) l c h / calc(alpha + 0.15));
--_button-text--hover: var(--_button-text);
--_button-text--active: var(--_button-text);
--_button-border-color: transparent;
--_button-border-color--hover: transparent;
--_button-border-color--active: transparent;
--_button-shadow: 0 0 transparent;
}
:where([data-ui~="button"][data-button-variant="info"]) {
--_button-ring-color: var(--color-info);
--_button-bg: light-dark(
oklch(from var(--color-info) l c h / 0.1),
oklch(from var(--color-info) l c h / 0.2)
);
--_button-text: light-dark(
oklch(from var(--color-info) min(l, 0.45) c h),
oklch(from var(--color-info) max(l, 0.82) c h)
);
}
:where([data-ui~="button"][data-button-variant="success"]) {
--_button-ring-color: var(--color-success);
--_button-bg: light-dark(
oklch(from var(--color-success) l c h / 0.1),
oklch(from var(--color-success) l c h / 0.2)
);
--_button-text: light-dark(
oklch(from var(--color-success) min(l, 0.45) c h),
oklch(from var(--color-success) max(l, 0.82) c h)
);
}
:where([data-ui~="button"][data-button-variant="warning"]) {
--_button-ring-color: var(--color-warning);
--_button-bg: light-dark(
oklch(from var(--color-warning) l c h / 0.1),
oklch(from var(--color-warning) l c h / 0.2)
);
--_button-text: light-dark(
oklch(from var(--color-warning) min(l, 0.45) c h),
oklch(from var(--color-warning) max(l, 0.82) c h)
);
}
:where([data-ui~="button"][data-button-variant="destructive"]) {
--_button-ring-color: var(--color-destructive);
--_button-bg: light-dark(
oklch(from var(--color-destructive) l c h / 0.1),
oklch(from var(--color-destructive) l c h / 0.2)
);
--_button-text: light-dark(
oklch(from var(--color-destructive) min(l, 0.45) c h),
oklch(from var(--color-destructive) max(l, 0.82) c h)
);
}
:where([data-ui~="button"][data-button-variant="link"]) {
--_button-bg: transparent;
--_button-bg--hover: transparent;
--_button-bg--active: transparent;
--_button-text: var(--color-foreground);
--_button-text--hover: oklch(from var(--color-primary) l c h / 0.6);
--_button-text--active: oklch(from var(--color-primary) l c h / 0.5);
--_button-border-style: none;
--_button-min-h: 0;
--_button-px: 0;
--_button-rounded: 0;
--_button-shadow: 0 0 transparent;
}
/* ===========================================================================
BUTTON SIZES — data-button-size
=========================================================================== */
:where([data-ui~="button"][data-button-size="sm"]) {
--_button-min-h: calc(var(--spacing) * 6);
--_button-px: calc(var(--spacing) * 2);
--_button-rounded: var(--radius-sm);
--_button-font-size: var(--font-size-xs);
}
:where(
:is(ui-button-group, [data-ui~="button-group"]):has([data-ui~="button"][data-button-size="sm"])
) {
--_button-group-rounded: var(--radius-sm);
}
:where([data-ui~="button"][data-button-size="icon"]) {
block-size: calc(var(--_min-h-len) + var(--_min-h-num) * var(--spacing));
inline-size: calc(var(--_min-h-len) + var(--_min-h-num) * var(--spacing));
padding: 0;
aspect-ratio: 1 / 1;
}
:where([data-ui~="button"][data-button-size="icon-sm"]) {
--_button-min-h: calc(var(--spacing) * 6);
--_button-px: calc(var(--spacing) * 2);
--_button-rounded: var(--radius-sm);
/* the hook, not a private: it must inherit to reach the icon */
--ui-button-icon-size: calc(var(--spacing) * 3.5);
block-size: calc(var(--_min-h-len) + var(--_min-h-num) * var(--spacing));
inline-size: calc(var(--_min-h-len) + var(--_min-h-num) * var(--spacing));
padding: 0;
aspect-ratio: 1 / 1;
}
/* ===========================================================================
BUTTON KBD
=========================================================================== */
/* Descendant (not child) so keys wrapped in a ui-kbd-group still resize */
:where([data-ui~="button"]) :where(kbd) {
--ui-kbd-min-w: calc(var(--spacing) * 4.5);
--ui-kbd-min-h: calc(var(--spacing) * 4.5);
--ui-kbd-icon-size: calc(var(--spacing) * 3);
}
:where([data-ui~="button"]):has(
> :where(kbd, ui-kbd-group, [data-ui~="kbd-group"]):last-child:not(:only-child)
) {
padding-inline-end: calc(var(--spacing) * 1.5);
}
:where([data-ui~="button"]):has(
> :where(kbd, ui-kbd-group, [data-ui~="kbd-group"]):first-child:not(:only-child)
) {
padding-inline-start: calc(var(--spacing) * 1.5);
}
:where([data-ui~="button"][data-button-size="sm"]):has(
> :where(kbd, ui-kbd-group, [data-ui~="kbd-group"]):last-child:not(:only-child)
) {
padding-inline-end: calc(var(--spacing) * 0.5);
}
:where([data-ui~="button"][data-button-size="sm"]):has(
> :where(kbd, ui-kbd-group, [data-ui~="kbd-group"]):first-child:not(:only-child)
) {
padding-inline-start: calc(var(--spacing) * 0.5);
}
}What's here
- Quick start: a page in five steps, from one
<head>snippet to a themed layout. - Core concepts: the three channels, hooks, switches, layers, and how breakpoints read containers.
- Migrating a legacy app: adopting Zazz page by page next to an existing stylesheet.
- Installation: the CLI, the CDN, or npm with a bundler.
- Customization and theming: tokens, hooks, dark mode, and brand blocks.
- Editor and linting: the VS Code extension, the ESLint plugin, and
<ui-debug>. - LLM resources: what to hand an AI assistant so it writes correct Zazz.
- API reference: every utility, switch, primitive, and script, generated from the kit.
Why it looks like this
Tailwind showed that values belong in markup. Zazz keeps that, but moves the values from class names into custom properties, so the stylesheet is a fixed size no matter how many pages you ship, and an element's own style attribute is the single place its values live. shadcn and Base UI showed that components can be copied, owned, and themed through variables rather than imported as a black box. Zazz does the same with a CLI that vendors primitives into your project and merges updates later.
Everything runs on modern web platform features: cascade layers, @property, container queries, light-dark(), the Popover API, native <dialog>, invoker commands, and anchor positioning. The browser support floor is the latest two versions of Chrome, Firefox, and Safari; the one polyfill is for interest invokers.