# Introduction (/docs) **A variables-driven design framework that survives real projects.** Most UI libraries look right until a client asks for something custom. Then you're fighting overrides, wrapping components in wrappers, or bolting on a second system that doesn't match the first. Zazz works differently. It's built on native web standards: tokens, cascade layers, and modern browser APIs. No build step, no framework lock-in, no specificity wars, just markup that works wherever you ship code. It's built around four commitments: * **Professionalism:** Works across changing tools and frameworks without breaking. * **Precision:** Variables control everything, so consistency doesn't depend on discipline. * **Polish:** Looks great by default, ready to present without extra design passes. * **Portability:** Built on standards that map cleanly to any stack. ## Professionalism [#professionalism] Zazz is designed for teams that switch between design tools, frameworks, and client requirements constantly. The system works in Figma, translates to any development framework, and doesn't require you to rebuild when your toolchain changes next quarter. * **Tool-agnostic:** The same tokens and patterns work whether you're in Figma, code, or both. * **Framework-agnostic:** No React dependency, no Vue plugin, no build step. It runs on CSS and native HTML APIs. * **Complete:** Every component handles light mode, dark mode, accessibility, and keyboard interaction without extra work. ## Precision [#precision] Every spacing value, color, radius, and type size in Zazz resolves from a variable. You never hardcode a pixel value or hex color. This means changing one token updates every component that references it, and consistency happens automatically instead of through code review. * **Semantic tokens first:** Reach for role tokens (`--primary`, `--muted-foreground`, `--gap-md`) before anything specific. * **Scoped overrides:** Customize globally on `:root`, per-component, or per-instance inline. No source editing needed. * **Predictable variants:** `data-variant`, `data-size`, `data-side`. The default is always the absence of the attribute. *If you need every button in your project to be pill-shaped, set `--button-radius: var(--radius-full)` once. Done.* ## Polish [#polish] Zazz ships with carefully chosen defaults so your UI looks finished before you've made a single design decision. Components work well together as a system because they share the same token vocabulary and spacing scale. * **Presentable by default:** Clean, modern defaults that hold up in client presentations. * **Unified system:** Components built from the same tokens naturally look like they belong together. * **Dark mode included:** Role tokens handle light/dark automatically. No manual `.dark` overrides. *You can hand a Zazz prototype to a stakeholder without apologizing for the styling.* ## Portability [#portability] Zazz doesn't bet on a framework that might not exist in two years. It uses CSS custom properties, native HTML APIs (Popover, ``, Invoker Commands, anchor positioning), and cascade layers. The result is a system that works today in plain HTML and adapts to whatever framework you adopt next. * **Zero build step:** Load the styles, write the markup, ship. * **Standards-based:** If the browser supports it natively, Zazz uses that instead of JavaScript. * **Figma parity:** Variables in the Figma library map 1:1 to CSS tokens in code. *Switch from Next.js to Astro? From Wordpress to Webflow? Your components still work. The markup is the same.* # Accordion (/docs/components/accordion) The accordion is a `.accordion` wrapping native `
` and `` elements. Open/close state, keyboard support, and the height transition (via `::details-content`) are handled by the browser. ## Default [#default] There are no data-\* attributes to configure. Author each panel as a `
` with a ``, an inline chevron `` that rotates on open, and a `
` body. Multiple panels can be open at once by default; give a group of `
` the same `name` to make them mutually exclusive. Theming runs through tokens: `--accordion-summary-padding-block`, `--accordion-icon-size`, and `--accordion-icon-transform-open` (the open-state chevron rotation). # Avatar (/docs/components/avatar) Avatars are pure composition with no dedicated class. Stack an image over a text fallback with `grid grid-area-pile`, clip to a circle with `rounded-full overflow-clip`, and size with `size-*`. The image sits on top; the fallback (initials on `bg-muted` / `text-muted-foreground`) shows when it fails to load. For an initials-only avatar, drop the `` and keep just the `
`. ## Default [#default] # Badge (/docs/components/badge) Badges use the `.badge` class. Pick a color with `data-variant`; the **default** variant is the absence of the attribute. Use `data-size="icon"` for a square, icon-only chip. A badge is often a static ``, but put it on a ` ``` | `data-embla` | Role | | --------------- | ------------------------------------------------ | | `viewport` | The visible window (required) | | `container` | The flex track of slides | | `slide` | Each slide; set its width with `basis-*` | | `prev` / `next` | Navigation buttons (optional) | | `dots` / `dot` | Dot pagination container and template (optional) | | `thumbs` | Linked thumbnail carousel container (optional) | ## Setup [#setup] The docs preview loads the scripts for you. To use a carousel in your own page, include the Embla CDN bundles, the shared utilities, and the init script: ```html ``` Carousels auto-initialize on `DOMContentLoaded`. ## Options [#options] Every Embla core option goes on `` as a `data-embla-*` attribute. Names convert from kebab-case to camelCase and values are type-coerced, so `data-embla-loop="true"` becomes `{ loop: true }`. ```html ``` | Attribute | Effect | | --------------------- | --------------------------------- | | `data-embla-loop` | `"true"` wraps around at the ends | | `data-embla-align` | `start`, `center`, or `end` | | `data-embla-keyboard` | `"false"` opts out of arrow keys | ### Plugins [#plugins] Enable a plugin with a bare attribute and configure it with prefixed options. Each plugin needs its CDN bundle from the setup above. ```html ``` | Plugin | Attribute prefix | CDN bundle | | ----------- | ------------------------- | ---------------------------- | | Autoplay | `data-embla-autoplay-*` | `embla-carousel-autoplay` | | Auto Scroll | `data-embla-autoscroll-*` | `embla-carousel-auto-scroll` | | Class Names | `data-embla-classnames-*` | `embla-carousel-class-names` | ## Dot navigation [#dot-navigation] Add a `dots` container with a single template `dot`. The script clones the template once per slide and tracks the active one with `.is-active`. ```html
``` ## Thumbnail navigation [#thumbnail-navigation] Nest a `data-embla="thumbs"` container inside the same `` to get a synced secondary carousel. Thumb options take the `data-embla-thumbs-*` prefix and default to `containScroll: "keepSnaps"` and `dragFree: true`. ```html
Full image 1
Full image 2
Thumb 1
Thumb 2
``` ## Keyboard navigation [#keyboard-navigation] Arrow keys (`←` / `→`) scroll the active carousel: one inside an open ``, or the one holding the focused element. Keys are ignored when focus sits in a form field or contenteditable region. Turn it off per carousel with `data-embla-keyboard="false"`. ## Dialog and lightbox [#dialog-and-lightbox] A carousel can't measure its viewport until its `` is open, so Zazz defers it: a `MutationObserver` watches each dialog's `open` attribute and initializes any carousel inside once it opens. Set `data-embla-start` on the trigger to open at a specific slide. ```html ``` The [lightbox](/docs/components/lightbox) builds on this: the same Embla roles plus a fullscreen `` and a thumb strip. When slides are clickable (a lightbox trigger with `commandfor`, say), a drag is told apart from a click, and clicks are suppressed for a short window after a drag so a swipe never fires an accidental tap. ## Re-initialization [#re-initialization] After an SPA-style navigation or any dynamic content insertion, re-scan for new carousels: ```js EmblaInit.init(newContentElement); ``` The [navigation script](/docs/getting-started/page-transitions) calls this for you after swapping `
`. `init()` skips carousels it has already wired (`data-embla-init`) and defers any inside a closed dialog. The lower-level `data-embla="root"` wrapper still works for hand-built Embla markup, but prefer `` for components and any markup that may be inserted or removed at runtime. # Checkbox (/docs/components/checkbox) The checkbox is a restyled native ``, fully accessible, with the Zazz check treatment. Bind it with `checked`, `name`, and `value` as usual. ## Default [#default] # Dialog (/docs/components/dialog) Dialogs use a native ``. Open one from any button with `command="show-modal"` and `commandfor=""`, and close with `command="close"`. Add `closedby="any"` for light-dismiss. Size with `data-size`. ## Default [#default] ## With a form [#with-a-form] ## Alert [#alert] ## API [#api] | Attribute | Where | Values | | ------------ | ---------- | -------------------------------- | | `command` | trigger | `show-modal`, `close` | | `commandfor` | trigger | id of the `` | | `data-size` | `.dialog` | `article`, `container`, `screen` | | `closedby` | `` | `any`, `closerequest`, `none` | # Dropdown (/docs/components/dropdown) A dropdown is a `.dropdown__popover` (`popover="auto"`) opened by a trigger with `popovertarget`. Menu items are ghost buttons. Place the panel with `data-side` and `data-align`; it light-dismisses natively. ## Default [#default] ## API [#api] | Attribute | Where | Values | | --------------- | -------------------- | -------------------------------- | | `popovertarget` | trigger | id of the `.dropdown__popover` | | `data-side` | `.dropdown__popover` | `top`, `bottom`, `left`, `right` | | `data-align` | `.dropdown__popover` | `start`, `center`, `end` | # Input group (/docs/components/input-group) An `.input-group` wraps an `.input` with `.input-group__addon` elements. Place an addon with `data-align`: inline (leading or trailing) or block (above or below). ## Default [#default] ## Password group [#password-group] The password-group example wraps the field in ``, which adds the show/hide toggle behavior and exposes the component script in the JS tab. ## API [#api] | Attribute | Where | Values | | ------------ | --------------------- | -------------------------------------------------------- | | `data-align` | `.input-group__addon` | `inline-start`, `inline-end`, `block-start`, `block-end` | # Input (/docs/components/input) Inputs use the `.input` class and share the `--field-*` token family. Validation surfaces via `:user-invalid` (after blur or submit, never while typing). Pair with a `.field` wrapper for the label / hint / error layout. ## Default [#default] ## Leading icon [#leading-icon] ## Trailing icon [#trailing-icon] # Lightbox (/docs/components/lightbox) The lightbox pairs an inline `` gallery with a fullscreen `` carousel and thumb strip, all wrapped in ``. Slides open the dialog via `command="show-modal"`, so opening and closing still comes from HTML. The web component only coordinates state: it opens the dialog carousel at the gallery's current slide, focuses the viewport for keyboard navigation, and syncs the inline gallery to the last viewed slide when the dialog closes. The elements use light DOM, so the `.lightbox` classes and `data-embla-*` attributes style and configure the same markup you author. The preview loads the required scripts automatically; open the JS tab to inspect the custom elements. ## Default [#default] ## API [#api] | Markup | Where | Purpose | | ---------------------------------------------------------------- | -------- | -------------------------------------------------------- | | `` | root | Coordinates the inline gallery and dialog carousel | | `` | gallery | Inline carousel; owns drag, thumbs, and stage navigation | | `` | root | Fullscreen modal surface | | `` | dialog | Fullscreen carousel; initialized when the dialog opens | | `command="show-modal"` / `command="close"` | triggers | Opens and closes the dialog through Invoker Commands | | `data-embla="viewport"` / `"container"` / `"slide"` / `"thumbs"` | children | Embla roles for stage, slides, and thumb carousel | Put Embla options (`data-embla-loop`, `data-embla-classnames`, etc.) on each ``. # Mobile menu (/docs/components/mobile-menu) The mobile menu composes a native `` with nested `
` accordions. Open it with `command="show-modal"` and `commandfor=""`, close with `command="close"` or light-dismiss via `closedby="any"`. No JavaScript needed. Internally it uses `.dialog[data-size="screen"]` for full-viewport coverage and `data-animation="slide-right"` for the entrance transition. ## Default [#default] ## Structure [#structure] ```html
Section
``` ## Tokens [#tokens] | Token | Default | | ----------------------------------------------------- | ----------------------------- | | `--mobile-menu-background` | `var(--background)` | | `--mobile-menu-foreground` | `var(--foreground)` | | `--mobile-menu-backdrop` | `var(--faded-foreground)` | | `--mobile-menu-header-padding-inline` | `var(--gap-md) var(--gap-xs)` | | `--mobile-menu-header-padding-block` | `var(--gap-xs)` | | `--mobile-menu-body-padding-inline` | `var(--gap-sm)` | | `--mobile-menu-body-padding-block` | `var(--gap-md)` | | `--mobile-menu-footer-padding` | `var(--gap-sm)` | | `--mobile-menu-footer-gap` | `var(--gap-xs)` | | `--mobile-menu-nested-accordion-padding-inline-start` | `var(--gap-sm)` | ## API [#api] | Attribute | Where | Values | | ---------------- | -------------- | ----------------------------- | | `command` | trigger | `show-modal`, `close` | | `commandfor` | trigger | id of the `` | | `data-size` | `.mobile-menu` | `screen` | | `data-animation` | `.mobile-menu` | `slide-right` | | `closedby` | `` | `any`, `closerequest`, `none` | ## Composition notes [#composition-notes] * Add `.dark` to the dialog for a dark-themed menu regardless of page theme. * The body uses a nested `.accordion` with native `
` for expand/collapse sections. * Nested accordions (accordion inside accordion) automatically indent via `--mobile-menu-nested-accordion-padding-inline-start`. * The header and footer are `position: sticky` so they remain visible while the body scrolls. * Use `.button[data-variant="ghost"]` for nav links inside accordion panels. # Navigation menu (/docs/components/navigation-menu) The navigation menu pairs triggers (`popovertarget`) with `.navigation-menu__popover` panels. A `.navigation-menu__list` holds each `.navigation-menu__item`, whose `.navigation-menu__trigger` opens a panel. Panels wrap a `.navigation-menu__viewport` grid of `.navigation-menu__link` rows (title + description, filling with muted on hover), can nest flyout submenus via `.navigation-menu__submenu` / `.navigation-menu__submenu-trigger`, and animate in. A "Featured" callout is not a dedicated part — it is plain utilities, e.g. `bg-muted hover:opacity-75 rounded-sm p-sm` with a `.text-eyebrow` label. ## Default [#default] The sign-in dialog in this example uses `` for the password field's show/hide toggle; open the JS tab to inspect that enhancement. ## API [#api] | Attribute | Where | Values | | ---------------- | --------------------------- | ----------------------------- | | `popovertarget` | trigger | id of the panel | | `data-align` | `.navigation-menu__popover` | `center`, `end` | | `data-variant` | `.navigation-menu__popover` | `submenu` | | `data-size` | `.navigation-menu__popover` | `root`, `container`, `screen` | | `data-animation` | `.navigation-menu__popover` | `slide-down` | # Password group (/docs/components/password-group) The `.password-group` is an input group whose trailing addon is a ghost button that toggles the field's visibility. Wrap it in `` to add the show/hide behavior while keeping the field usable as a normal masked password input when JavaScript is unavailable. The toggle button keeps `aria-pressed` and `aria-label` in sync, and the icon swap is driven by CSS from that pressed state. The preview loads the required script automatically; open the JS tab to inspect the custom element. ## Default [#default] ## API [#api] | Attribute | Where | Purpose | | ------------------------- | ------------------ | ------------------------------------------------------- | | `` | wrapper | Finds the password input and toggle button | | `.password-group` | label | Field surface and addon layout | | `.password-group__toggle` | button | Button that flips the input between password/text | | `label-show` | `` | Optional accessible label while the password is hidden | | `label-hide` | `` | Optional accessible label while the password is visible | # Prose (/docs/components/prose) Wrap any block of rich HTML in `.prose` to get headings, paragraphs, lists, figures, and vertical rhythm without per-element classes. Ideal for CMS or Markdown-rendered content. ## Default [#default] # Radio (/docs/components/radio) Radios apply the `.radio` treatment to native `` inputs. Group them with a shared `name` (and a `.radio-group` wrapper) so only one can be selected at a time. ## Default [#default] # Reveal (/docs/components/reveal) Reveal is a lightweight animation system for scroll-based viewport entry effects. It uses `IntersectionObserver` to trigger CSS transitions when elements scroll into view. ## How it works [#how-it-works] 1. Add `data-reveal` or `data-reveal-each` to your HTML. 2. The `Reveal` class discovers those elements, sets CSS custom properties, and observes them. 3. When an element enters the viewport, it receives the `.in-viewport` class. 4. CSS transitions (defined in `_reveal.css`) animate from the hidden state to visible. No keyframes, no JavaScript animation loops, just CSS transitions triggered by a class toggle. ## Setup [#setup] `reveal.js` ships inside the single [`main.js`](/docs/getting-started/head#script-load-order) module; its styles are part of `main.css`, which `@import`s `ui/_reveal.css`. The normal setup is one link to `main.css` plus the module: ```html ``` If you link files individually, the reveal styles live at `./zazz/styles/ui/_reveal.css`. Reveal auto-initializes once the DOM is parsed. No manual setup needed for standard pages. ## Single elements [#single-elements] Animate individual elements with `data-reveal`: ```html
Slides up into view
Fades in
Slides in from the right
``` ### Available animations [#available-animations] | Value | Effect | | ------------- | ----------------------------- | | `slide-up` | Translate up from below | | `slide-down` | Translate down from above | | `slide-left` | Translate left from the right | | `slide-right` | Translate right from the left | | `fade` | Opacity only | | `grow` | Scale up from smaller | | `shrink` | Scale down from larger | ## Stagger groups [#stagger-groups] Animate direct children with sequential delays using `data-reveal-each`: ```html
Item 1 — appears first
Item 2 — 100ms later
Item 3 — 200ms later
``` Each child gets an incrementing delay calculated as `baseWait + (step × index)`. ### Reversed order [#reversed-order] Animate children from last to first: ```html
Appears last
Appears second
Appears first
``` ## Combining with hover and other transitions [#combining-with-hover-and-other-transitions] Reveal animates by controlling the element's CSS transition: the duration, the stagger delay, the timing function, and which properties move (`opacity` and `transform`). That means it owns `transition-*` on whatever element it's set on. So don't add a `transition` (or `transition-all`) utility to the same element you put `data-reveal` on. Both want to set `transition-*`, and the utility resets the delay reveal calculated, so the stagger collapses and everything fires at once. When an element needs its own transition too — a `hover:` border, say — wrap it. Put the reveal on an outer `div` and keep the `transition` on the inner element. Each one controls its own transitions, and they stop stepping on each other: ```html
``` ```html Card ``` ## Configuration attributes [#configuration-attributes] All attributes are optional. Set them on the element with `data-reveal` or `data-reveal-each`: | Attribute | Default | Description | | ----------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `data-reveal-duration` | `--reveal-global-duration` | Animation duration. Defaults to the global transition duration (`--default-transition-duration`); JS fallback `400ms` | | `data-reveal-wait` | `0` | Base delay before animation starts (ms) | | `data-reveal-step` | `80` | Delay between staggered children (ms) | | `data-reveal-ease` | `--reveal-global-ease` | Timing function. Defaults to `--default-transition-timing-function`; JS fallback `cubic-bezier(0.4, 0, 0.2, 1)` | | `data-reveal-distance` | `1rem` | Translation distance for slide animations | | `data-reveal-scale` | — | Custom scale value for grow/shrink | | `data-reveal-margin` | `0px` | IntersectionObserver `rootMargin` | | `data-reveal-threshold` | `0.2` | Visibility threshold (0–1) to trigger | | `data-reveal-order` | — | Set to `"reversed"` for reverse stagger | ## Global configuration [#global-configuration] Override defaults by passing a config object: ```js const reveal = new Reveal({ config: { duration: 600, ease: "ease-in-out", threshold: 0.3, margin: "100px", step: 100, }, }); ``` Or set global CSS custom properties on `:root`: ```css :root { --reveal-global-duration: 600ms; --reveal-global-ease: ease-in-out; --reveal-global-distance: 2rem; --reveal-global-grow: 0.97; /* scale for grow */ --reveal-global-shrink: 1.03; /* scale for shrink */ } ``` | Token | Default | Description | | -------------------------- | ------------------------------------------- | --------------------------- | | `--reveal-global-duration` | `var(--default-transition-duration)` | Animation duration | | `--reveal-global-ease` | `var(--default-transition-timing-function)` | Timing function | | `--reveal-global-wait` | `0ms` | Base delay before animation | | `--reveal-global-distance` | `1rem` | Slide translation distance | | `--reveal-global-grow` | `0.97` | Scale for `grow` | | `--reveal-global-shrink` | `1.03` | Scale for `shrink` | ## Manual control [#manual-control] ### Disable auto-initialization [#disable-auto-initialization] ```js Reveal.disableAutoInit(); const reveal = new Reveal({ config: { /* custom */ }, }); ``` ### Refresh after DOM changes [#refresh-after-dom-changes] After dynamically inserting content, rescan for new `[data-reveal]` elements: ```js container.innerHTML = newContent; Reveal.getAutoInstance()?.refresh(); ``` The SPA navigation script (`navigation.js`) calls `refresh()` automatically after swapping `
` content. ## Reduced motion [#reduced-motion] Reveal respects `prefers-reduced-motion`. The CSS uses the standard `@media (prefers-reduced-motion: reduce)` query: when reduced motion is preferred, elements appear immediately with transitions and transforms disabled. ## CSS custom properties [#css-custom-properties] The script sets these properties per-element for the CSS transitions to consume: | Property | Set by | | ------------------- | ------------------------------------------ | | `--reveal-duration` | `data-reveal-duration` | | `--reveal-ease` | `data-reveal-ease` | | `--reveal-wait` | `data-reveal-wait` (+ stagger calculation) | | `--reveal-distance` | `data-reveal-distance` | | `--reveal-scale` | `data-reveal-scale` | # Select (/docs/components/select) The `.select` restyles a native ``. Accessible and keyboard-operable. Use the standard `min`, `max`, `step`, and `value` attributes. ## Default [#default] # Switch (/docs/components/switch) The switch is a restyled `` with a track and thumb. Bind it like any checkbox (`checked`, `name`, `value`). ## Default [#default] # Tabs (/docs/components/tabs) Tabs are CSS-first: grouped `input[type="radio"][role="tab"]` inputs drive the panels and the sliding indicator. Wrap the pattern in `` to add orientation-aware keyboard navigation: Left/Right for horizontal tabs, Up/Down for vertical tabs, plus Home/End and wrap-around. **Panel order must match the radio order.** Without JavaScript, the grouped radios still select panels natively. The preview loads the required script automatically; open the JS tab to inspect the custom element. ## Default [#default] ## API [#api] | Attribute | Where | Values | | ------------------ | ----- | ----------------------------------- | | `` | root | Custom element that carries `.tabs` | | `data-orientation` | root | `vertical` (horizontal is default) | # Textarea (/docs/components/textarea) The `.textarea` shares the `--field-*` tokens with the other form controls, including `:user-invalid` validation styling. It sets `field-sizing: content` unconditionally, so the box auto-grows with its text and clamps between `--textarea-min-block-size` (`5lh`) and `--textarea-max-block-size` (`12lh`), then scrolls — adjust those tokens to change the height range. `rows` is not the height lever here. ## Default [#default] # Toggle group (/docs/components/toggle-group) Wrap two or more [toggles](/docs/components/toggle) in `.toggle-group` to fuse them into one segmented control — the same border-collapse and inner-corner squaring as a [button group](/docs/components/button-group). Make it **single-select** by giving the toggles `radio` inputs that share a `name`; make it **multi-select** with `checkbox` inputs. Add `role="group"` and an `aria-label` so the set is announced. ## Single select [#single-select] Radios sharing a `name` — exactly one toggle stays on. ## Multi select [#multi-select] Checkboxes — any number can be on at once. ## Vertical [#vertical] Set `data-orientation="vertical"` on the wrapper to stack them. ## API [#api] | Attribute | Values | | ------------------ | -------------------------------------- | | `data-orientation` | `vertical` (omit = horizontal default) | The group reads each child's own `--toggle-radius`, so the outer corners follow the toggles' `data-size` automatically — only the inner corners are squared. You never need to touch `zazz/styles/`. # Toggle (/docs/components/toggle) A toggle is a `