API
Toggle
A pressed or unpressed button over a checkbox or radio.
Installation
pnpm dlx zazz-ui add togglenpx zazz-ui add toggleyarn dlx zazz-ui add togglebunx zazz-ui add toggleAlso adds fields, kbd, button, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.
Examples
<div
style="
--display: flex-col;
--items: start;
--gap: 6;
"
>
<div
style="
--display: flex;
--items: center;
--gap: 4;
"
>
<label data-ui="toggle" data-toggle-size="icon">
<input type="checkbox" name="bold" aria-label="Bold" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M6 4h8a4 4 0 0 1 0 8H6z" />
<path d="M6 12h9a4 4 0 0 1 0 8H6z" />
</svg>
</label>
<label data-ui="toggle" data-toggle-size="icon">
<input type="checkbox" name="italic" aria-label="Italic" checked />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="19" y1="4" x2="10" y2="4" />
<line x1="14" y1="20" x2="5" y2="20" />
<line x1="15" y1="4" x2="9" y2="20" />
</svg>
</label>
<label data-ui="toggle" data-toggle-size="icon" data-toggle-variant="ghost">
<input type="checkbox" name="underline" aria-label="Underline" />
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M6 4v6a6 6 0 0 0 12 0V4" />
<line x1="4" y1="20" x2="20" y2="20" />
</svg>
</label>
</div>
<div
style="
--display: flex;
--items: center;
--gap: 4;
"
>
<label data-ui="toggle">
<input type="checkbox" name="bold" />
<span>Bold</span>
</label>
<label data-ui="toggle">
<input type="checkbox" name="italic" checked />
<span>Italic</span>
</label>
<label data-ui="toggle" data-toggle-variant="ghost">
<input type="checkbox" name="disabled" disabled />
<span>Disabled</span>
</label>
</div>
</div>/**
* toggle.css: Toggle ([data-ui~="toggle"]), a button-styled checkbox/radio
*
* Same look as the button (hooks default to the --ui-button-* family), but it wraps a
* native checkbox or radio inside a <label>. State is driven from the nested input
* via :has(): the checked state reuses the button's *active* token values, so an
* "on" toggle reads like a pressed button. Because the state lives on the input,
* the label's own state tiers (`--bg--checked`) do not fire; theme the checked
* look through the `--ui-toggle-*--checked` hooks.
*
* @layer variables, components
* @requires _layers.css, _variables.css, _properties.css (--_*-resolved, --_focus-shadow), button.css
* @uses :has(): drive label state from the nested input (checked/focus/disabled)
* @uses oklch(from …): variant hover/active tints (inherited from --ui-button-*)
* @uses text-box: vertical trim (patchy browser support)
* @tokens --ui-toggle-* (@layer variables;
* default to the --ui-button-* family)
* @consumedby toggle-group.css
*/
@layer variables {
:root {
--ui-toggle-ring-color: var(--ui-button-ring-color);
--ui-toggle-font-family: var(--ui-button-font-family);
--ui-toggle-font-size: var(--ui-button-font-size);
--ui-toggle-font-weight: var(--ui-button-font-weight);
--ui-toggle-leading: var(--ui-button-leading);
--ui-toggle-bg: var(--ui-button-bg);
--ui-toggle-bg--hover: var(--ui-button-bg--hover);
--ui-toggle-bg--active: var(--ui-button-bg--active);
--ui-toggle-bg--checked: var(--color-primary);
--ui-toggle-bg--checked-hover: oklch(from var(--color-primary) l c h / 0.9);
--ui-toggle-bg--checked-active: oklch(from var(--color-primary) l c h / 0.8);
--ui-toggle-text: var(--ui-button-text);
--ui-toggle-text--hover: var(--ui-button-text--hover);
--ui-toggle-text--active: var(--ui-button-text--active);
--ui-toggle-text--checked: var(--color-primary-foreground);
--ui-toggle-text--checked-hover: var(--color-primary-foreground);
--ui-toggle-text--checked-active: var(--color-primary-foreground);
--ui-toggle-border-width: var(--ui-button-border-width);
--ui-toggle-border-style: var(--ui-button-border-style);
--ui-toggle-border-color: var(--ui-button-border-color);
--ui-toggle-border-color--hover: var(--ui-button-border-color--hover);
--ui-toggle-border-color--active: var(--ui-button-border-color--active);
--ui-toggle-border-color--checked: var(--color-primary);
--ui-toggle-border-color--checked-hover: var(--color-primary);
--ui-toggle-border-color--checked-active: var(--color-primary);
/* dual-mode hooks: a scale number or a length */
--ui-toggle-gap: var(--ui-button-gap);
--ui-toggle-px: var(--ui-button-px);
--ui-toggle-min-h: var(--ui-button-min-h);
--ui-toggle-rounded: var(--ui-button-rounded);
--ui-toggle-icon-size: var(--ui-button-icon-size);
/* composes into the toggle's box-shadow list: use a transparent shadow,
never `none`, so the focus ring layers stay valid */
--ui-toggle-shadow: var(--ui-button-shadow);
}
}
/* variant privates: one per hook, non-inheriting */
@property --_toggle-ring-color {
syntax: "*";
inherits: false;
}
@property --_toggle-font-family {
syntax: "*";
inherits: false;
}
@property --_toggle-font-size {
syntax: "*";
inherits: false;
}
@property --_toggle-font-weight {
syntax: "*";
inherits: false;
}
@property --_toggle-leading {
syntax: "*";
inherits: false;
}
@property --_toggle-bg {
syntax: "*";
inherits: false;
}
@property --_toggle-bg--hover {
syntax: "*";
inherits: false;
}
@property --_toggle-bg--active {
syntax: "*";
inherits: false;
}
@property --_toggle-bg--checked {
syntax: "*";
inherits: false;
}
@property --_toggle-bg--checked-hover {
syntax: "*";
inherits: false;
}
@property --_toggle-bg--checked-active {
syntax: "*";
inherits: false;
}
@property --_toggle-text {
syntax: "*";
inherits: false;
}
@property --_toggle-text--hover {
syntax: "*";
inherits: false;
}
@property --_toggle-text--active {
syntax: "*";
inherits: false;
}
@property --_toggle-text--checked {
syntax: "*";
inherits: false;
}
@property --_toggle-text--checked-hover {
syntax: "*";
inherits: false;
}
@property --_toggle-text--checked-active {
syntax: "*";
inherits: false;
}
@property --_toggle-border-width {
syntax: "*";
inherits: false;
}
@property --_toggle-border-style {
syntax: "*";
inherits: false;
}
@property --_toggle-border-color {
syntax: "*";
inherits: false;
}
@property --_toggle-border-color--hover {
syntax: "*";
inherits: false;
}
@property --_toggle-border-color--active {
syntax: "*";
inherits: false;
}
@property --_toggle-border-color--checked {
syntax: "*";
inherits: false;
}
@property --_toggle-border-color--checked-hover {
syntax: "*";
inherits: false;
}
@property --_toggle-border-color--checked-active {
syntax: "*";
inherits: false;
}
@property --_toggle-gap {
syntax: "*";
inherits: false;
}
@property --_toggle-px {
syntax: "*";
inherits: false;
}
@property --_toggle-min-h {
syntax: "*";
inherits: false;
}
@property --_toggle-rounded {
syntax: "*";
inherits: false;
}
@property --_toggle-shadow {
syntax: "*";
inherits: false;
}
@layer ui.components {
/* The label wraps a visually-hidden checkbox/radio that stays focusable, so
:focus-visible on the input still drives the ring. */
:where([data-ui~="toggle"]) {
font-family: var(
--_font-family-resolved,
var(--_toggle-font-family, var(--ui-toggle-font-family))
);
font-size: var(--_font-size-resolved, var(--_toggle-font-size, var(--ui-toggle-font-size)));
font-weight: var(
--_font-weight-resolved,
var(--_toggle-font-weight, var(--ui-toggle-font-weight))
);
line-height: var(--_leading-resolved, var(--_toggle-leading, var(--ui-toggle-leading)));
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
/* dual-mode metrics go through the registered typed pair */
--_gap-len: var(--_gap-resolved, var(--_toggle-gap, var(--ui-toggle-gap)));
--_gap-num: var(--_gap-resolved, var(--_toggle-gap, var(--ui-toggle-gap)));
column-gap: calc(var(--_gap-len) + var(--_gap-num) * var(--spacing));
--_min-h-len: var(--_min-h-resolved, var(--_toggle-min-h, var(--ui-toggle-min-h)));
--_min-h-num: var(--_min-h-resolved, var(--_toggle-min-h, var(--ui-toggle-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(--_toggle-px, var(--ui-toggle-px)));
--_px-num: var(--_px-resolved, var(--_toggle-px, var(--ui-toggle-px)));
padding-inline: calc(var(--_px-len) + var(--_px-num) * var(--spacing));
border-width: var(
--_border-width-resolved,
var(--_toggle-border-width, var(--ui-toggle-border-width))
);
border-style: var(
--_border-style-resolved,
var(--_toggle-border-style, var(--ui-toggle-border-style))
);
border-color: var(
--_border-color-resolved,
var(--_toggle-border-color, var(--ui-toggle-border-color))
);
border-radius: var(--_rounded-resolved, var(--_toggle-rounded, var(--ui-toggle-rounded)));
color: var(--_text-resolved, var(--_toggle-text, var(--ui-toggle-text)));
background-color: var(--_bg-resolved, var(--_toggle-bg, var(--ui-toggle-bg)));
/* published to --_focus-shadow so a --shadow or --ring utility keeps it; the
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(--_toggle-ring-color, var(--ui-toggle-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(--_toggle-shadow, var(--ui-toggle-shadow)));
cursor: pointer;
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;
}
/* visually hidden but still in the tab order and focusable */
:where([data-ui~="toggle"]) > input:is([type="checkbox"], [type="radio"]) {
position: absolute;
clip-path: inset(50%);
opacity: 0;
pointer-events: none;
}
/* ===========================================================================
STATES — driven from the nested input
=========================================================================== */
@media (hover: hover) {
:where([data-ui~="toggle"]):hover:not(:has(> input:disabled)) {
color: var(--_text-resolved, var(--_toggle-text--hover, var(--ui-toggle-text--hover)));
background-color: var(--_bg-resolved, var(--_toggle-bg--hover, var(--ui-toggle-bg--hover)));
border-color: var(
--_border-color-resolved,
var(--_toggle-border-color--hover, var(--ui-toggle-border-color--hover))
);
}
}
:where([data-ui~="toggle"]):active:not(:has(> input:disabled)) {
color: var(--_text-resolved, var(--_toggle-text--active, var(--ui-toggle-text--active)));
background-color: var(--_bg-resolved, var(--_toggle-bg--active, var(--ui-toggle-bg--active)));
border-color: var(
--_border-color-resolved,
var(--_toggle-border-color--active, var(--ui-toggle-border-color--active))
);
}
/* the "on" state reuses the button's active look */
:where([data-ui~="toggle"]):has(> input:checked) {
color: var(--_text-resolved, var(--_toggle-text--checked, var(--ui-toggle-text--checked)));
background-color: var(--_bg-resolved, var(--_toggle-bg--checked, var(--ui-toggle-bg--checked)));
border-color: var(
--_border-color-resolved,
var(--_toggle-border-color--checked, var(--ui-toggle-border-color--checked))
);
}
@media (hover: hover) {
:where([data-ui~="toggle"]):has(> input:checked):hover:not(:has(> input:disabled)) {
color: var(
--_text-resolved,
var(--_toggle-text--checked-hover, var(--ui-toggle-text--checked-hover))
);
background-color: var(
--_bg-resolved,
var(--_toggle-bg--checked-hover, var(--ui-toggle-bg--checked-hover))
);
border-color: var(
--_border-color-resolved,
var(--_toggle-border-color--checked-hover, var(--ui-toggle-border-color--checked-hover))
);
}
}
:where([data-ui~="toggle"]):has(> input:checked):active:not(:has(> input:disabled)) {
color: var(
--_text-resolved,
var(--_toggle-text--checked-active, var(--ui-toggle-text--checked-active))
);
background-color: var(
--_bg-resolved,
var(--_toggle-bg--checked-active, var(--ui-toggle-bg--checked-active))
);
border-color: var(
--_border-color-resolved,
var(--_toggle-border-color--checked-active, var(--ui-toggle-border-color--checked-active))
);
}
:where([data-ui~="toggle"]):has(> input:focus-visible) {
--_ring-offset-width: var(--ring-offset-width);
--_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
outline-color: transparent;
}
/* ===========================================================================
ICONS
=========================================================================== */
:where([data-ui~="toggle"]) > svg {
inline-size: var(--ui-toggle-icon-size);
block-size: var(--ui-toggle-icon-size);
pointer-events: none;
justify-content: center;
align-items: center;
display: flex;
padding: 0;
}
/* ===========================================================================
VARIANTS
=========================================================================== */
:where([data-ui~="toggle"][data-toggle-variant="primary"]) {
--_toggle-bg: var(--color-primary);
--_toggle-bg--hover: oklch(from var(--color-primary) l c h / 0.9);
--_toggle-bg--active: oklch(from var(--color-primary) l c h / 0.8);
--_toggle-text: var(--color-primary-foreground);
--_toggle-text--hover: var(--color-primary-foreground);
--_toggle-text--active: var(--color-primary-foreground);
--_toggle-border-color: transparent;
--_toggle-border-color--hover: transparent;
--_toggle-border-color--active: transparent;
--_toggle-shadow: 0 0 transparent;
}
:where([data-ui~="toggle"][data-toggle-variant="secondary"]) {
--_toggle-bg: var(--color-secondary);
--_toggle-bg--hover: oklch(from var(--color-secondary) l c h / 0.9);
--_toggle-bg--active: oklch(from var(--color-secondary) l c h / 0.8);
--_toggle-text: var(--color-secondary-foreground);
--_toggle-text--hover: var(--color-secondary-foreground);
--_toggle-text--active: var(--color-secondary-foreground);
--_toggle-border-color: transparent;
--_toggle-border-color--hover: transparent;
--_toggle-border-color--active: transparent;
--_toggle-shadow: 0 0 transparent;
}
:where([data-ui~="toggle"][data-toggle-variant="tertiary"]) {
--_toggle-bg: var(--color-tertiary);
--_toggle-bg--hover: oklch(from var(--color-tertiary) l c h / 0.9);
--_toggle-bg--active: oklch(from var(--color-tertiary) l c h / 0.8);
--_toggle-text: var(--color-tertiary-foreground);
--_toggle-text--hover: var(--color-tertiary-foreground);
--_toggle-text--active: var(--color-tertiary-foreground);
--_toggle-border-color: transparent;
--_toggle-border-color--hover: transparent;
--_toggle-border-color--active: transparent;
--_toggle-shadow: 0 0 transparent;
}
:where([data-ui~="toggle"][data-toggle-variant="muted"]) {
--_toggle-bg: var(--color-muted);
--_toggle-bg--hover: oklch(from var(--color-muted) l c h / calc(alpha - 0.02));
--_toggle-bg--active: var(--color-muted);
--_toggle-text: var(--color-foreground);
--_toggle-text--hover: var(--color-foreground);
--_toggle-text--active: var(--color-foreground);
--_toggle-border-color: transparent;
--_toggle-border-color--hover: transparent;
--_toggle-border-color--active: transparent;
--_toggle-shadow: 0 0 transparent;
}
/* the "ghost" toggle reads as bare until on/hovered */
:where([data-ui~="toggle"][data-toggle-variant="ghost"]) {
--_toggle-bg: transparent;
--_toggle-bg--hover: var(--color-muted);
--_toggle-bg--active: oklch(from var(--color-muted) l c h / calc(alpha - 0.02));
--_toggle-text: var(--color-foreground);
--_toggle-text--hover: var(--color-foreground);
--_toggle-text--active: var(--color-foreground);
--_toggle-border-color: transparent;
--_toggle-border-color--hover: transparent;
--_toggle-border-color--active: transparent;
--_toggle-shadow: 0 0 transparent;
}
:where([data-ui~="toggle"][data-toggle-variant="destructive"]) {
--_toggle-ring-color: var(--color-destructive);
--_toggle-bg: var(--color-destructive);
--_toggle-bg--hover: oklch(from var(--color-destructive) l c h / 0.9);
--_toggle-bg--active: oklch(from var(--color-destructive) l c h / 0.8);
--_toggle-text: var(--color-destructive-foreground);
--_toggle-text--hover: var(--color-destructive-foreground);
--_toggle-text--active: var(--color-destructive-foreground);
--_toggle-border-color: var(--color-destructive);
--_toggle-border-color--hover: var(--color-destructive);
--_toggle-border-color--active: var(--color-destructive);
--_toggle-shadow: 0 0 transparent;
}
/* disabled comes after the variants so a disabled toggle is muted whatever its variant */
:where([data-ui~="toggle"]:has(> input:disabled)) {
--_toggle-text: var(--color-muted-foreground);
--_toggle-bg: var(--color-muted);
--_toggle-shadow: 0 0 transparent;
cursor: default;
opacity: 0.5;
outline: none;
}
/* ===========================================================================
SIZES
=========================================================================== */
:where([data-ui~="toggle"][data-toggle-size="sm"]) {
--_toggle-min-h: calc(var(--spacing) * 6);
--_toggle-px: calc(var(--spacing) * 2);
--_toggle-rounded: var(--radius-sm);
}
:where([data-ui~="toggle"][data-toggle-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~="toggle"][data-toggle-size="icon-sm"]) {
--_toggle-min-h: calc(var(--spacing) * 6);
--_toggle-px: calc(var(--spacing) * 2);
--_toggle-rounded: var(--radius-sm);
/* the hook, not a private: it must inherit to reach the icon */
--ui-toggle-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;
}
}Attributes
| Attribute | Kind | Values | Notes |
|---|---|---|---|
data-ui="toggle" | Identity | free value | Identity token in data-ui. |
data-toggle-size | Preset | icon icon-sm sm | Preset: a named bundle of values. |
data-toggle-variant | Preset | destructive ghost muted primary secondary tertiary | Preset: a named bundle of values. |
Requires: button.
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-toggle-bg | var(--ui-button-bg) | — |
--ui-toggle-bg--active | var(--ui-button-bg--active) | active |
--ui-toggle-bg--checked | var(--color-primary) | checked |
--ui-toggle-bg--checked-active | oklch(from var(--color-primary) l c h / 0.8) | checked-active |
--ui-toggle-bg--checked-hover | oklch(from var(--color-primary) l c h / 0.9) | checked-hover |
--ui-toggle-bg--hover | var(--ui-button-bg--hover) | hover |
--ui-toggle-border-color | var(--ui-button-border-color) | — |
--ui-toggle-border-color--active | var(--ui-button-border-color--active) | active |
--ui-toggle-border-color--checked | var(--color-primary) | checked |
--ui-toggle-border-color--checked-active | var(--color-primary) | checked-active |
--ui-toggle-border-color--checked-hover | var(--color-primary) | checked-hover |
--ui-toggle-border-color--hover | var(--ui-button-border-color--hover) | hover |
--ui-toggle-border-style | var(--ui-button-border-style) | — |
--ui-toggle-border-width | var(--ui-button-border-width) | — |
--ui-toggle-font-family | var(--ui-button-font-family) | — |
--ui-toggle-font-size | var(--ui-button-font-size) | — |
--ui-toggle-font-weight | var(--ui-button-font-weight) | — |
--ui-toggle-gap | var(--ui-button-gap) | — |
--ui-toggle-icon-size | var(--ui-button-icon-size) | — |
--ui-toggle-leading | var(--ui-button-leading) | — |
--ui-toggle-min-h | var(--ui-button-min-h) | — |
--ui-toggle-px | var(--ui-button-px) | — |
--ui-toggle-ring-color | var(--ui-button-ring-color) | — |
--ui-toggle-rounded | var(--ui-button-rounded) | — |
--ui-toggle-shadow | var(--ui-button-shadow) | — |
--ui-toggle-text | var(--ui-button-text) | — |
--ui-toggle-text--active | var(--ui-button-text--active) | active |
--ui-toggle-text--checked | var(--color-primary-foreground) | checked |
--ui-toggle-text--checked-active | var(--color-primary-foreground) | checked-active |
--ui-toggle-text--checked-hover | var(--color-primary-foreground) | checked-hover |
--ui-toggle-text--hover | var(--ui-button-text--hover) | hover |