Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Fields

Field layout (label, control, hint, error) and the shared `--ui-field-*` metrics.

Installation

pnpm dlx zazz-ui add fields
npx zazz-ui add fields
yarn dlx zazz-ui add fields
bunx zazz-ui add fields

Run zazz-ui init first if the project has no zazz/ folder yet.

Examples

We only use it to sign you in. Enter a valid email address.
Notifications
<form
  style="
    --display: flex-col;
    --gap: 6;
    --w: 100%;
    --max-w: 96;
  "
>
  <div data-ui="field">
    <label data-field-slot="label" for="fields-example-email">Email</label>
    <input
      data-ui="input"
      id="fields-example-email"
      name="fields-example-email"
      type="email"
      required
      placeholder="you@example.com"
      autocomplete="email"
      aria-describedby="fields-example-email-hint"
    />
    <div data-field-slot="description">
      <span data-field-slot="hint" id="fields-example-email-hint"
        >We only use it to sign you in.</span
      >
      <span data-field-slot="error" role="alert">Enter a valid email address.</span>
    </div>
  </div>
  <fieldset data-ui="field-group">
    <legend>Notifications</legend>
    <label data-ui="field" data-field-orientation="horizontal">
      <input type="checkbox" name="fields-example-notify" value="mentions" checked />
      <span data-field-slot="label">Mentions</span>
    </label>
    <label data-ui="field" data-field-orientation="horizontal">
      <input type="checkbox" name="fields-example-notify" value="digest" />
      <span data-field-slot="label">Weekly digest</span>
    </label>
  </fieldset>
</form>
/**
 * fields.css: Shared form field family (Tier 3 owner for --ui-field-*)
 *
 * @layer      variables, components
 * @requires   _layers.css, _variables.css, _properties.css (--_*-resolved)
 * @uses       :user-invalid: validation after commit (not while typing)
 * @uses       :has(:user-invalid): label/hint/error crossfade
 * @uses       @starting-style + visibility allow-discrete: hint <-> error swap
 * @uses       @supports (-webkit-touch-callout: none): iOS-only 16px field text,
 *             so a focused control does not zoom the page
 * @tokens     --ui-field-*, --ui-field-group-* (Tier 3 owner; @layer variables)
 * @consumedby input.css, textarea.css, select.css, input-group.css,
 *             password-group.css, otp.css, radio.css, checkbox.css,
 *             autocomplete.css, combobox.css, command.css,
 *             button.css (metrics: h/rounded/px/icon/ring/border width+style; not font-size
 *             or leading, so the iOS 16px rule below reaches text controls only),
 *             toggle.css (via button.css), tabs.css (same metrics), badge.css (border/ring)
 *
 * Identities: data-ui="field" (label + control + description wrapper),
 * data-ui="field-group" (fieldset; data-ui="radio-group" shares its rules).
 * Slots: data-field-slot="label | description | hint | error".
 * Each control owns its own disabled and :user-invalid rules and remaps the
 * --ui-field-* family on itself when invalid.
 */

@layer variables {
  :root {
    --ui-field-bg: var(--color-input);
    --ui-field-bg--hover: var(--color-input);
    --ui-field-bg--focus-within: var(--color-background);

    --ui-field-text: var(--color-input-foreground);

    --ui-field-border-width: 1px;
    --ui-field-border-style: solid;
    --ui-field-border-color: var(--color-border);
    --ui-field-border-color--hover: color-mix(
      in oklch,
      var(--color-border) 90%,
      var(--color-primary) 10%
    );
    --ui-field-border-color--focus-within: var(--color-primary);

    --ui-field-label-text: inherit;
    --ui-field-ring-color: var(--color-ring);
    --ui-field-rounded: var(--radius-md);
    --ui-field-inset-child-radius: var(--radius-sm);
    --ui-field-h: calc(var(--spacing) * 8);
    --ui-field-font-size: var(--font-size-sm);
    --ui-field-leading: 1;
    --ui-field-px: calc(var(--spacing) * 2.5);
    --ui-field-addon-padding: var(--spacing);
    --ui-field-gap: calc(var(--spacing) * 2);
    --ui-field-option-gap: 1px;
    --ui-field-icon-size: calc(var(--spacing) * 4);

    /* wrapper layout */
    --ui-field-display: grid;
    --ui-field-template-cols: 1fr;
    --ui-field-self: flex-start;
    --ui-field-w: 100%;

    /* Reveal transitions/animations */
    --ui-field-hint-translate--start: 0 calc(var(--spacing) * -1);
    --ui-field-hint-translate--end: 0 0;

    --ui-field-group-display: grid;
    --ui-field-group-border: 1px solid transparent;
    --ui-field-group-bg: transparent;
    --ui-field-group-p: 0;
    --ui-field-group-m: 0;
    --ui-field-group-gap: var(--space-xs);
    --ui-field-group-rounded: var(--radius-lg);
  }

  /* iOS zooms the page to any focused control that renders under 16px, so a
     --font-size-sm field opens the keyboard with a lurch. Every iOS browser is
     WebKit, and -webkit-touch-callout exists only there, so this raises the
     field size on iOS alone; desktop and Android keep the smaller type. */
  @supports (-webkit-touch-callout: none) {
    :root {
      --ui-field-font-size: max(16px, var(--font-size-sm));
    }
  }
}

@layer ui.components {
  /* ===========================================================================
  FORM: global form behaviour shared across every control
  - This file holds only the cross-control glue: the shared field-level tokens,
    the field layout wrapper, the field-group / radio-group fieldsets, and the
    validation messaging. Each control lives in its own file and owns its own
    disabled and :user-invalid rules.
  =========================================================================== */

  /* ---------------------------------------------------------------------------
  SHARED FIELD TOKENS: Tier-3 shared family, owned here.
  - input / textarea / select / input-group all share the same surface treatment
    so a text input, a dropdown, and a message box read as one family. Each
    control aliases these into its own hooks so a single override retunes the set.
  --------------------------------------------------------------------------- */

  /* ---------------------------------------------------------------------------
  FIELD WRAPPER: data-ui="field" (label + control + hint/error)
  - Optional layout helper. Validation messaging uses :user-invalid so errors
    surface only after the user commits a value (blur/submit), never while typing.
  --------------------------------------------------------------------------- */

  :where([data-ui~="field"]) {
    /* --ui-field-gap differs from the control family default (step-2) */
    --ui-field-gap: var(--spacing);

    display: var(--_display-resolved, var(--ui-field-display));
    grid-template-columns: var(--_template-cols-resolved, var(--ui-field-template-cols));
    --_gap-len: var(--_gap-resolved, var(--ui-field-gap));
    --_gap-num: var(--_gap-resolved, var(--ui-field-gap));
    gap: calc(var(--_gap-len) + var(--_gap-num) * var(--spacing));
    align-self: var(--_self-resolved, var(--ui-field-self));
    --_w-len: var(--_w-resolved, var(--ui-field-w));
    --_w-num: var(--_w-resolved, var(--ui-field-w));
    inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
  }

  :where([data-ui~="field"]) > :not([data-field-slot~="label"], [data-field-slot~="description"]) {
    grid-row: 2;
    grid-column: 1;
  }

  :where([data-ui~="field"]) [data-field-slot~="label"] {
    grid-row: 1;
    grid-column: 1;
    align-self: start;
    color: var(--ui-field-label-text);
    font-size: var(--font-size-sm);
    font-weight: var(--font-weight-strong);
    text-align: start;
    min-inline-size: max-content;
  }

  /* ---------------------------------------------------------------------------
  FIELD DESCRIPTION: data-field-slot="description" (wrapper for hint + error)
  - Occupies a stable grid slot so hint/error swaps never cause layout shift.
    The wrapper keeps a fixed block-size while the children crossfade inside it.
  --------------------------------------------------------------------------- */

  :where([data-ui~="field"]) [data-field-slot~="description"] {
    grid-row: 3;
    grid-column: 1;
    display: grid;
    grid-template-areas: "msg";
    font-size: var(--font-size-xs);
  }

  /* Hint and error stack in the same grid area: only one is visible at a time */
  :where([data-ui~="field"]) :is([data-field-slot~="hint"], [data-field-slot~="error"]) {
    grid-area: msg;
    display: inline-flex;
    transition:
      opacity var(--default-transition-duration) var(--default-transition-timing-function),
      translate var(--default-transition-duration) var(--default-transition-timing-function),
      visibility var(--default-transition-duration) var(--default-transition-timing-function)
        allow-discrete;
  }

  :where([data-ui~="field"]) [data-field-slot~="hint"] {
    color: var(--color-muted-foreground);
    opacity: 1;
    translate: var(--ui-field-hint-translate--end);
    visibility: visible;
  }

  :where([data-ui~="field"]) [data-field-slot~="error"] {
    color: var(--color-destructive);
    opacity: 0;
    translate: var(--ui-field-hint-translate--start);
    visibility: hidden;
  }

  /* After interaction: invalid controls turn destructive, reveal the error */
  :where([data-ui~="field"]:has(:user-invalid)) [data-field-slot~="hint"] {
    opacity: 0;
    translate: var(--ui-field-hint-translate--start);
    visibility: hidden;

    @starting-style {
      opacity: 1;
      translate: var(--ui-field-hint-translate--end);
      visibility: visible;
    }
  }

  :where([data-ui~="field"]:has(:user-invalid)) [data-field-slot~="error"] {
    opacity: 1;
    translate: var(--ui-field-hint-translate--end);
    visibility: visible;

    @starting-style {
      opacity: 0;
      translate: var(--ui-field-hint-translate--start);
      visibility: hidden;
    }
  }

  :where([data-ui~="field"]:has(:user-invalid)) [data-field-slot~="label"] {
    --ui-field-label-text: var(--color-destructive);
  }

  :where([data-ui~="field"]:has(:user-invalid)) ::placeholder {
    --placeholder-color: var(--color-destructive);
  }

  /* ---------------------------------------------------------------------------
  FIELD GROUP: data-ui="field-group" (data-ui="radio-group" shares the rules)
  - Semantic <fieldset> wrapper for a labelled set of related controls (a radio
    group, a set of checkboxes/switches). Resets browser chrome and stacks
    children; the <legend> names the whole set.
  - Use a fieldset/legend ONLY when the legend labels a genuine group. A single
    control (or several independently-labelled fields) should carry its own
    data-field-slot="label" instead, never lean on the legend as a stand-in label.
  --------------------------------------------------------------------------- */

  :where([data-ui~="field-group"], [data-ui~="radio-group"]) {
    display: var(--_display-resolved, var(--ui-field-group-display));
    background-color: var(--_bg-resolved, var(--ui-field-group-bg));
    --_gap-len: var(--_gap-resolved, var(--ui-field-group-gap));
    --_gap-num: var(--_gap-resolved, var(--ui-field-group-gap));
    gap: calc(var(--_gap-len) + var(--_gap-num) * var(--spacing));
    border: var(--ui-field-group-border);
    border-radius: var(--_rounded-resolved, var(--ui-field-group-rounded));
    --_p-len: var(--_p-resolved, var(--ui-field-group-p));
    --_p-num: var(--_p-resolved, var(--ui-field-group-p));
    padding: calc(var(--_p-len) + var(--_p-num) * var(--spacing));
    --_m-len: var(--_m-resolved, var(--ui-field-group-m));
    --_m-num: var(--_m-resolved, var(--ui-field-group-m));
    margin: calc(var(--_m-len) + var(--_m-num) * var(--spacing));
  }

  :where([data-ui~="field-group"], [data-ui~="radio-group"]) > legend {
    padding: 0;
    margin-block: 0 calc(var(--spacing) * 1.5);
    color: var(--ui-field-label-text, inherit);
    font-size: var(--font-size-sm);
    font-weight: var(--font-weight-strong);
  }

  /* ===========================================================================
  FIELD ORIENTATION: data-field-orientation="horizontal"
  =========================================================================== */

  /* Inline layout for checkbox/radio/switch: control beside its label */
  :where([data-ui~="field"][data-field-orientation="horizontal"]) {
    --ui-field-horizontal-display: flex;
    --ui-field-horizontal-flex-direction: row;
    --ui-field-horizontal-align-items: center;
    --ui-field-horizontal-justify-content: flex-start;
    --ui-field-horizontal-gap: var(--space-xs);

    display: var(--_display-resolved, var(--ui-field-horizontal-display));
    flex-direction: var(--_flex-direction-resolved, var(--ui-field-horizontal-flex-direction));
    align-items: var(--_items-resolved, var(--ui-field-horizontal-align-items));
    justify-content: var(--_justify-resolved, var(--ui-field-horizontal-justify-content));
    --_gap-len: var(--_gap-resolved, var(--ui-field-horizontal-gap));
    --_gap-num: var(--_gap-resolved, var(--ui-field-horizontal-gap));
  }

  :where([data-ui~="field"][data-field-orientation="horizontal"]) [data-field-slot~="label"] {
    align-self: center;
  }
}

Field text on iOS

iOS zooms the page to any focused control whose text renders under 16px, and --ui-field-font-size is --font-size-sm (about 12.8px). Rather than forcing 16px fields everywhere, the kit raises the token to max(16px, var(--font-size-sm)) inside @supports (-webkit-touch-callout: none), which only iOS browsers match. Desktop and Android keep the smaller type; an iPhone gets 16px controls and never zooms. Set --ui-field-font-size yourself and the override still applies on iOS, so a smaller value there is not possible without reintroducing the zoom.

Attributes

fields attributes
AttributeKindValuesNotes
data-ui="field-group"Identityfree valueIdentity token in data-ui.
data-ui="field"Identityfree valueIdentity token in data-ui.
data-field-orientationPresethorizontalPreset: a named bundle of values.
data-field-slotSlotdescription error hint labelSlot: a part inside the primitive.

Hooks

Set a hook on :root, on a subtree, or inline on one element. A state hook (--…--hover) themes that state only; the primitive keeps its other states.

fields hooks
HookDefaultState
--ui-field-addon-paddingvar(--spacing)—
--ui-field-bgvar(--color-input)—
--ui-field-bg--focus-withinvar(--color-background)focus-within
--ui-field-bg--hovervar(--color-input)hover
--ui-field-border-colorvar(--color-border)—
--ui-field-border-color--focus-withinvar(--color-primary)focus-within
--ui-field-border-color--hovercolor-mix( in oklch, var(--color-border) 90%, var(--color-primary) 10% )hover
--ui-field-border-stylesolid—
--ui-field-border-width1px—
--ui-field-displaygrid—
--ui-field-font-sizevar(--font-size-sm)—
--ui-field-gapcalc(var(--spacing) * 2)—
--ui-field-group-bgtransparent—
--ui-field-group-border1px solid transparent—
--ui-field-group-displaygrid—
--ui-field-group-gapvar(--space-xs)—
--ui-field-group-m0—
--ui-field-group-p0—
--ui-field-group-roundedvar(--radius-lg)—
--ui-field-hcalc(var(--spacing) * 8)—
--ui-field-hint-translate--end0 0end
--ui-field-hint-translate--start0 calc(var(--spacing) * -1)start
--ui-field-horizontal-align-itemscenter—
--ui-field-horizontal-displayflex—
--ui-field-horizontal-flex-directionrow—
--ui-field-horizontal-gapvar(--space-xs)—
--ui-field-horizontal-justify-contentflex-start—
--ui-field-icon-sizecalc(var(--spacing) * 4)—
--ui-field-inset-child-radiusvar(--radius-sm)—
--ui-field-label-textinherit—
--ui-field-leading1—
--ui-field-option-gap1px—
--ui-field-pxcalc(var(--spacing) * 2.5)—
--ui-field-ring-colorvar(--color-ring)—
--ui-field-roundedvar(--radius-md)—
--ui-field-selfflex-start—
--ui-field-template-cols1fr—
--ui-field-textvar(--color-input-foreground)—
--ui-field-w100%—