API
Fields
Field layout (label, control, hint, error) and the shared `--ui-field-*` metrics.
Installation
pnpm dlx zazz-ui add fieldsnpx zazz-ui add fieldsyarn dlx zazz-ui add fieldsbunx zazz-ui add fieldsRun zazz-ui init first if the project has no zazz/ folder yet.
Examples
<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
| Attribute | Kind | Values | Notes |
|---|---|---|---|
data-ui="field-group" | Identity | free value | Identity token in data-ui. |
data-ui="field" | Identity | free value | Identity token in data-ui. |
data-field-orientation | Preset | horizontal | Preset: a named bundle of values. |
data-field-slot | Slot | description error hint label | Slot: 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.
| Hook | Default | State |
|---|---|---|
--ui-field-addon-padding | var(--spacing) | — |
--ui-field-bg | var(--color-input) | — |
--ui-field-bg--focus-within | var(--color-background) | focus-within |
--ui-field-bg--hover | var(--color-input) | hover |
--ui-field-border-color | var(--color-border) | — |
--ui-field-border-color--focus-within | var(--color-primary) | focus-within |
--ui-field-border-color--hover | color-mix( in oklch, var(--color-border) 90%, var(--color-primary) 10% ) | hover |
--ui-field-border-style | solid | — |
--ui-field-border-width | 1px | — |
--ui-field-display | grid | — |
--ui-field-font-size | var(--font-size-sm) | — |
--ui-field-gap | calc(var(--spacing) * 2) | — |
--ui-field-group-bg | transparent | — |
--ui-field-group-border | 1px solid transparent | — |
--ui-field-group-display | grid | — |
--ui-field-group-gap | var(--space-xs) | — |
--ui-field-group-m | 0 | — |
--ui-field-group-p | 0 | — |
--ui-field-group-rounded | var(--radius-lg) | — |
--ui-field-h | calc(var(--spacing) * 8) | — |
--ui-field-hint-translate--end | 0 0 | end |
--ui-field-hint-translate--start | 0 calc(var(--spacing) * -1) | start |
--ui-field-horizontal-align-items | center | — |
--ui-field-horizontal-display | flex | — |
--ui-field-horizontal-flex-direction | row | — |
--ui-field-horizontal-gap | var(--space-xs) | — |
--ui-field-horizontal-justify-content | flex-start | — |
--ui-field-icon-size | calc(var(--spacing) * 4) | — |
--ui-field-inset-child-radius | var(--radius-sm) | — |
--ui-field-label-text | inherit | — |
--ui-field-leading | 1 | — |
--ui-field-option-gap | 1px | — |
--ui-field-px | calc(var(--spacing) * 2.5) | — |
--ui-field-ring-color | var(--color-ring) | — |
--ui-field-rounded | var(--radius-md) | — |
--ui-field-self | flex-start | — |
--ui-field-template-cols | 1fr | — |
--ui-field-text | var(--color-input-foreground) | — |
--ui-field-w | 100% | — |