Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Toggle

A pressed or unpressed button over a checkbox or radio.

Installation

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

Also 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

toggle attributes
AttributeKindValuesNotes
data-ui="toggle"Identityfree valueIdentity token in data-ui.
data-toggle-sizePreseticon icon-sm smPreset: a named bundle of values.
data-toggle-variantPresetdestructive ghost muted primary secondary tertiaryPreset: 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.

toggle hooks
HookDefaultState
--ui-toggle-bgvar(--ui-button-bg)—
--ui-toggle-bg--activevar(--ui-button-bg--active)active
--ui-toggle-bg--checkedvar(--color-primary)checked
--ui-toggle-bg--checked-activeoklch(from var(--color-primary) l c h / 0.8)checked-active
--ui-toggle-bg--checked-hoveroklch(from var(--color-primary) l c h / 0.9)checked-hover
--ui-toggle-bg--hovervar(--ui-button-bg--hover)hover
--ui-toggle-border-colorvar(--ui-button-border-color)—
--ui-toggle-border-color--activevar(--ui-button-border-color--active)active
--ui-toggle-border-color--checkedvar(--color-primary)checked
--ui-toggle-border-color--checked-activevar(--color-primary)checked-active
--ui-toggle-border-color--checked-hovervar(--color-primary)checked-hover
--ui-toggle-border-color--hovervar(--ui-button-border-color--hover)hover
--ui-toggle-border-stylevar(--ui-button-border-style)—
--ui-toggle-border-widthvar(--ui-button-border-width)—
--ui-toggle-font-familyvar(--ui-button-font-family)—
--ui-toggle-font-sizevar(--ui-button-font-size)—
--ui-toggle-font-weightvar(--ui-button-font-weight)—
--ui-toggle-gapvar(--ui-button-gap)—
--ui-toggle-icon-sizevar(--ui-button-icon-size)—
--ui-toggle-leadingvar(--ui-button-leading)—
--ui-toggle-min-hvar(--ui-button-min-h)—
--ui-toggle-pxvar(--ui-button-px)—
--ui-toggle-ring-colorvar(--ui-button-ring-color)—
--ui-toggle-roundedvar(--ui-button-rounded)—
--ui-toggle-shadowvar(--ui-button-shadow)—
--ui-toggle-textvar(--ui-button-text)—
--ui-toggle-text--activevar(--ui-button-text--active)active
--ui-toggle-text--checkedvar(--color-primary-foreground)checked
--ui-toggle-text--checked-activevar(--color-primary-foreground)checked-active
--ui-toggle-text--checked-hovervar(--color-primary-foreground)checked-hover
--ui-toggle-text--hovervar(--ui-button-text--hover)hover