defuss-shadcn / Forms & Inputs / radio
Radio GroupMOL
A set of mutually exclusive options, built on native <input type="radio">. What makes radios one set is a shared name: pick one and the browser clears the previous choice. The <fieldset> + <legend> only label the set - for sighted readers and screen readers - they do not make it exclusive.
On this page (10)
§Default
Three radios share name="plan" - that shared name is what makes them one set (pick one, the previous clears) and what the form submits (plan=free). The fieldset + legend give the set its label.
§The name makes the set
Try both. Left: every radio has name="size", so picking one clears the other - one set, and the form submits one size. Right: same fieldset, but each radio has its own name, so each is a set of one - all three stay selected and none can be cleared by clicking. The fieldset groups nothing; the name does. Radios with a shared name behave as one set even with no fieldset at all (still add one - the legend is the set's accessible label). The set is scoped to its form: the same name in two different forms is two sets.
§With Description
Radio items with label and description text using .radio-item-block.
§Choice Card
Card-style radios using .radio-card with :has() for checked highlight.
§Horizontal
Horizontal layout with data-orientation='horizontal'.
§Disabled
Individual or group-level disabling via disabled attribute.
§Invalid
Validation error state with aria-invalid='true'.
§Density
Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.
§States
Machine contract - verified against radio.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
disabled | boolean | true, false | false | Interaction off for the first option (native disabled attribute). |
invalid | boolean | true, false | false | Manual invalid flag (aria-invalid="true"). |
§CSS view file
/* -- Radio Group component -------------------------------------- */@layer components { .radio-group { border: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.5rem; & > legend { /* top + bottom breathing room: stacked groups (e.g. a density ladder demo) would otherwise jam each heading against the options above it */ margin-top: 0.5rem; margin-bottom: 0.375rem; } /* Horizontal layout */ &[data-orientation="horizontal"] { flex-direction: row; flex-wrap: wrap; gap: 1rem; } } .radio-item { display: flex; align-items: center; gap: 0.5rem; & > label { font-size: 0.875rem; font-weight: 400; cursor: pointer; } } /* -- With-description layout -------------------------------- */ .radio-item-block { display: grid; grid-template-columns: auto 1fr; gap: 0 0.5rem; & > .radio { grid-row: 1 / 3; margin-top: 0.125rem; } & > label { font-size: 0.875rem; font-weight: 500; cursor: pointer; line-height: 1.4; } & > .radio-description { grid-column: 2; font-size: 0.8125rem; color: var(--muted-foreground); line-height: 1.5; } } /* -- Density -------------------------------------------------- data-density on the .radio-group root is a whitespace policy: the gap between options (and card padding) scale at the shared 0.75 / 1 / 1.25 ratio; comfortable matches the unsized default. Radio size itself stays fixed - this is spacing, not zoom. */ .radio-group:where([data-density="compact"]) { gap: 0.25rem; &[data-orientation="horizontal"] { gap: 0.75rem; } & .radio-card { padding: 0.75rem 1rem; } } .radio-group:where([data-density="comfortable"]) { gap: 0.5rem; &[data-orientation="horizontal"] { gap: 1rem; } } .radio-group:where([data-density="spacious"]) { gap: 0.75rem; &[data-orientation="horizontal"] { gap: 1.5rem; } & .radio-card { padding: 1.25rem 1.5rem; } } /* Gap hit area: inside an item layout the radio's click target spans the gap to its label (a transparent ::before, part of the input's own hit box), so clicking the whitespace between circle and text selects too - the label's `for` covers the text itself. Inline-end only: the label always sits after the radio (the block layout's second grid column). */ :is(.radio-item, .radio-item-block) > .radio::before { content: ''; position: absolute; inset-block: -0.25rem; inset-inline: -0.25rem -0.5rem; } /* -- Card variant ------------------------------------------- */ .radio-card { position: relative; display: flex; flex-direction: column; gap: 0.25rem; padding: 1rem 1.25rem; border: 1px solid var(--border); border-radius: var(--radius-lg); cursor: pointer; transition: border-color 150ms, background 150ms; &:hover { background: var(--accent); } &:has(.radio:checked) { border-color: var(--primary); } &:has(.radio:focus-visible) { outline: 2px solid var(--ring); outline-offset: 2px; } &:has(.radio:disabled) { cursor: not-allowed; background: var(--muted); &:hover { background: var(--muted); } & > label { color: var(--muted-foreground); cursor: not-allowed; } } & > .radio { position: absolute; top: 1rem; right: 1rem; } & > label { font-size: 0.875rem; font-weight: 500; cursor: pointer; } & > .radio-description { font-size: 0.8125rem; color: var(--muted-foreground); line-height: 1.5; } } .radio { appearance: none; margin: 0; /* kill the UA's ~4px input margin - breaks flex/grid rhythm */ width: 1rem; height: 1rem; border: 1px solid var(--border); border-radius: 50%; background: var(--background); cursor: pointer; flex-shrink: 0; position: relative; transition: border-color 150ms; &:checked { border-color: var(--primary); &::after { content: ''; position: absolute; top: 50%; left: 50%; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--primary); transform: translate(-50%, -50%); } } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } /* Disabled = legible but inert: never an opacity fade (0.5 on a light --border edge left a ghost you had to look for). The shape keeps its full-strength edge, the surface turns muted, a chosen state keeps a softened primary fill, and the label goes muted with the no-entry cursor - "a choice that exists but is unavailable right now". */ &:disabled { background: var(--muted); /* a mid-grey ring: --border on the muted fill is too faint to read as a shape */ border-color: color-mix(in oklch, var(--muted-foreground) 50%, var(--background)); cursor: not-allowed; &:checked { border-color: color-mix(in oklch, var(--primary) 45%, var(--background)); &::after { background: color-mix(in oklch, var(--primary) 45%, var(--background)); } } & + label { color: var(--muted-foreground); cursor: not-allowed; } } &[aria-invalid="true"] { border-color: var(--destructive); &:checked { border-color: var(--destructive); &::after { background: var(--destructive); } } } } /* Helper text for group-level descriptions */ .radio-group-description { font-size: 0.8125rem; color: var(--muted-foreground); margin: -0.125rem 0 0.25rem; } /* -- Accessibility ------------------------------------------ */ @media (prefers-reduced-motion: reduce) { .radio, .radio-card { transition: none; } } @media (forced-colors: active) { .radio { border-color: ButtonText; &:checked { border-color: Highlight; &::after { background: Highlight; } } &:disabled { border-color: GrayText; &:checked::after { background: GrayText; } } } .radio-card { border-color: ButtonText; &:has(.radio:checked) { border-color: Highlight; } } }}Comments, ideas or improvements? Edit this page's source on GitHub