API
Radio
Native radio buttons and radio groups, restyled.
Installation
pnpm dlx zazz-ui add radionpx zazz-ui add radioyarn dlx zazz-ui add radiobunx zazz-ui add radioAlso adds fields, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.
Examples
<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
| Attribute | Kind | Values | Notes |
|---|---|---|---|
data-ui="radio-group" | Identity | free value | Identity token in data-ui. |
data-ui="radio" | Identity | free value | Identity 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.
| Hook | Default | State |
|---|---|---|
--ui-radio-bg | var(--ui-field-bg) | — |
--ui-radio-bg--checked | var(--color-primary) | checked |
--ui-radio-border-color | var(--ui-field-border-color) | — |
--ui-radio-border-color--checked | var(--color-primary) | checked |
--ui-radio-border-color--hover | var(--ui-field-border-color--hover) | hover |
--ui-radio-border-style | var(--ui-field-border-style) | — |
--ui-radio-border-width | var(--ui-field-border-width) | — |
--ui-radio-dot-foreground | var(--color-primary-foreground) | — |
--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 | — |
--ui-radio-dot-size | 1em | — |
--ui-radio-rounded | var(--radius-full) | — |
--ui-radio-size | calc(var(--spacing) * 4.5) | — |