Theme
On this page (10)
Component Skill — components/radio/component-skill.md

Native basis

<input type="radio"> elements with shared name attribute for mutual exclusivity.

Web Platform APIs

<input type="radio"><fieldset>:checked:focus-visible:has()prefers-reduced-motionforced-colors

Classes

.radio-group.radio-item.radio-item-block.radio-card.radio.radio-description.radio-group-description

Data Attributes

data-orientation="horizontal"

Accessibility

• Native <fieldset> + <legend> provides group labeling.

• Radio buttons sharing the same name are mutually exclusive by default.

• Arrow keys navigate between options (native browser behavior).

• :focus-visible provides keyboard-only focus rings.

• prefers-reduced-motion disables transitions.

• forced-colors provides Windows High Contrast Mode support.

Notes

• Styled with appearance: none and a custom dot via ::after.

• The shared name IS the set: same name (in the same form) = one exclusive set. <fieldset> only labels it - radios with different names in one fieldset are independent.

• No JavaScript needed - browsers handle group behavior natively.

• Use .radio-item for simple label. Use .radio-item-block for label + description.

• Use .radio-card for card-style selection with :has() highlight.

• Add data-orientation="horizontal" for horizontal layout.

• <fieldset disabled> disables all radios in the group natively.

§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:

StateTypeValuesDefaultDescription
disabledbooleantrue, falsefalseInteraction off for the first option (native disabled attribute).
invalidbooleantrue, falsefalseManual 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