Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
Docs navigation

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

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.