Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Radio

Native radio buttons and radio groups, restyled.

Installation

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

Also adds fields, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.

Examples

Options
<fieldset data-ui="radio-group">
  <legend>Options</legend>
  <div
    style="
      --display: flex-col;
      --gap: 2;
    "
  >
    <label data-ui="field" data-field-orientation="horizontal">
      <input data-ui="radio" type="radio" name="radio-options-example" value="option-1" />
      <span data-field-slot="label">Option 1</span>
    </label>
    <label data-ui="field" data-field-orientation="horizontal">
      <input data-ui="radio" type="radio" name="radio-options-example" value="option-2" checked />
      <span data-field-slot="label">Option 2</span>
    </label>
    <label data-ui="field" data-field-orientation="horizontal">
      <input data-ui="radio" type="radio" name="radio-options-example" value="option-3" />
      <span data-field-slot="label">Option 3</span>
    </label>
  </div>
</fieldset>
/**
 * radio.css — Radio ([data-ui~="radio"] on input[type="radio"]; groups wear [data-ui~="radio-group"])
 *
 * @layer      variables, components
 * @requires   _layers.css, _variables.css, _properties.css (--_*-resolved, --_focus-shadow), fields.css (field-group layout, --ui-field-* surface/border)
 * @uses       appearance: none — custom circle + masked ::before dot when :checked
 * @tokens     --ui-radio-* (@layer variables)
 */

@layer variables {
  :root {
    --ui-radio-size: calc(var(--spacing) * 4.5);
    --ui-radio-bg: var(--ui-field-bg);
    --ui-radio-bg--checked: var(--color-primary);
    --ui-radio-dot-foreground: var(--color-primary-foreground);
    --ui-radio-border-width: var(--ui-field-border-width);
    --ui-radio-border-style: var(--ui-field-border-style);
    --ui-radio-border-color: var(--ui-field-border-color);
    --ui-radio-border-color--hover: var(--ui-field-border-color--hover);
    --ui-radio-border-color--checked: var(--color-primary);
    --ui-radio-rounded: var(--radius-full);
    --ui-radio-dot-size: 1em;
    --ui-radio-dot-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='currentColor' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='6'/%3E%3C/svg%3E")
      center / contain no-repeat;
  }
}

@layer ui.components {
  /* ===========================================================================
  RADIO — data-ui="radio" (+ data-ui="radio-group")
  - appearance: none redraws the circle from theme tokens; the checked dot is a
    masked ::before so it tracks --ui-radio-dot-foreground, not color alone.
  - Group by shared `name` inside a <fieldset>/<legend> or [data-ui~="radio-group"].
  =========================================================================== */

  :where([data-ui~="radio"]) {
    position: relative;
    appearance: none;
    flex-shrink: 0;
    --_size-len: var(--_size-resolved, var(--ui-radio-size));
    --_size-num: var(--_size-resolved, var(--ui-radio-size));
    inline-size: calc(var(--_size-len) + var(--_size-num) * var(--spacing));
    block-size: calc(var(--_size-len) + var(--_size-num) * var(--spacing));

    background-color: var(--_bg-resolved, var(--ui-radio-bg));
    border-width: var(--_border-width-resolved, var(--ui-radio-border-width));
    border-style: var(--_border-style-resolved, var(--ui-radio-border-style));
    border-color: var(--_border-color-resolved, var(--ui-radio-border-color));
    border-radius: var(--_rounded-resolved, var(--ui-radio-rounded));

    /* centres the ::before dot */
    display: inline-grid;
    place-items: center;
    background-clip: padding-box;

    cursor: pointer;

    /* the transparent outline keeps focus visible in forced-colors */
    --_ring-offset-width: 0px;
    --_ring-width: 0px;
    --_focus-in: max(0, -1 * sign(var(--_focus-ring-w, 1px)));
    --_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, 0 0 #0000);
    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-property: border-color, background-color, color, opacity, box-shadow;
    transition-duration: var(--default-transition-duration);
    transition-timing-function: var(--default-transition-timing-function);
  }

  @media (hover: hover) {
    :where([data-ui~="radio"]):hover {
      border-color: var(--_border-color-resolved, var(--ui-radio-border-color--hover));
    }
  }

  :where([data-ui~="radio"]):focus-visible {
    --_ring-offset-width: var(--ring-offset-width);
    --_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
    outline-color: transparent;
  }

  :where([data-ui~="radio"]):checked {
    background-color: var(--_bg-resolved, var(--ui-radio-bg--checked));
    border-color: var(--_border-color-resolved, var(--ui-radio-border-color--checked));
    color: var(--ui-radio-dot-foreground);
  }

  /* masked ::before dot painted in currentColor; the :where() closes before
     the pseudo-element, which is invalid inside it */
  :where([data-ui~="radio"]:checked)::before {
    content: "";
    position: absolute;
    pointer-events: none;
    background-color: currentColor;
    inline-size: var(--ui-radio-dot-size);
    block-size: var(--ui-radio-dot-size);
    max-inline-size: 100%;
    max-block-size: 100%;
    mask: var(--ui-radio-dot-mask);
    transition-property: background-color, opacity, transform;
    transition-duration: calc(var(--default-transition-duration) * 0.75);
    transition-delay: calc(var(--default-transition-duration) * 0.25);
    transition-timing-function: var(--default-transition-timing-function);
    transform: scale(1);
    opacity: 1;
    will-change: transform;

    @starting-style {
      transform: scale(0);
      opacity: 0;
    }
  }
}

/* An invalid control remaps the --ui-field-* family on itself. */
@layer ui.components {
  :where([data-ui~="radio"]):disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
}

Attributes

radio attributes
AttributeKindValuesNotes
data-ui="radio-group"Identityfree valueIdentity token in data-ui.
data-ui="radio"Identityfree valueIdentity token in data-ui.

Requires: fields.

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.

radio hooks
HookDefaultState
--ui-radio-bgvar(--ui-field-bg)—
--ui-radio-bg--checkedvar(--color-primary)checked
--ui-radio-border-colorvar(--ui-field-border-color)—
--ui-radio-border-color--checkedvar(--color-primary)checked
--ui-radio-border-color--hovervar(--ui-field-border-color--hover)hover
--ui-radio-border-stylevar(--ui-field-border-style)—
--ui-radio-border-widthvar(--ui-field-border-width)—
--ui-radio-dot-foregroundvar(--color-primary-foreground)—
--ui-radio-dot-maskurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' fill='currentColor' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='6'/%3E%3C/svg%3E") center / contain no-repeat—
--ui-radio-dot-size1em—
--ui-radio-roundedvar(--radius-full)—
--ui-radio-sizecalc(var(--spacing) * 4.5)—