Inputs

Radio group

A set of native radios under one question, for picking exactly one of a few visible options.

Built on
<fieldset><legend><input type="radio">appearance: none:checked:user-invalid:has()
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
2.1 kB
Dependencies
React
Billing cycle
Try its options

Loading its options.

Code
import { Radio, RadioGroup } from "@/components/equip/radio";

export default function Demo() {
  return (
    <div style={{ width: 200 }}>
      <RadioGroup legend="Billing cycle">
        <Radio name="demo-cycle" value="monthly" label="Monthly" defaultChecked />
        <Radio name="demo-cycle" value="yearly" label="Yearly, save 15%" />
        <Radio name="demo-cycle" value="invoice" label="Invoice on request" />
      </RadioGroup>
    </div>
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div style="width:200px">
  <fieldset class="eq-radio-group">
    <legend class="eq-radio-legend">Billing cycle</legend>
    <div class="eq-radio-options" data-orientation="vertical">
      <label class="eq-radio">
        <input type="radio" class="eq-radio-input" name="demo-cycle" checked value="monthly">
        <span class="eq-radio-text">
          <span class="eq-radio-label">Monthly</span>
        </span>
      </label>
      <label class="eq-radio">
        <input type="radio" class="eq-radio-input" name="demo-cycle" value="yearly">
        <span class="eq-radio-text">
          <span class="eq-radio-label">Yearly, save 15%</span>
        </span>
      </label>
      <label class="eq-radio">
        <input type="radio" class="eq-radio-input" name="demo-cycle" value="invoice">
        <span class="eq-radio-text">
          <span class="eq-radio-label">Invoice on request</span>
        </span>
      </label>
    </div>
  </fieldset>
</div>

Examples

With descriptions

Each radio has an id, so its description is announced after the label instead of inside it.

Project visibility

You can change this later in settings.

Code
import { Radio, RadioGroup } from "@/components/equip/radio";

export default function Example() {
  return (
    <div style={{ maxWidth: 340 }}>
      <RadioGroup id="radio-visibility" legend="Project visibility" hint="You can change this later in settings.">
        <Radio
          id="radio-visibility-private"
          name="visibility"
          value="private"
          label="Private"
          description="Only invited members can see or join."
          defaultChecked
        />
        <Radio
          id="radio-visibility-internal"
          name="visibility"
          value="internal"
          label="Internal"
          description="Anyone in the Larder organisation can view."
        />
        <Radio
          id="radio-visibility-public"
          name="visibility"
          value="public"
          label="Public"
          description="Anyone with the link can view. Only members can edit."
        />
      </RadioGroup>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="max-width:340px">
  <fieldset id="radio-visibility" class="eq-radio-group" aria-describedby="radio-visibility-hint">
    <legend class="eq-radio-legend">Project visibility</legend>
    <p class="eq-radio-hint" id="radio-visibility-hint">You can change this later in settings.</p>
    <div class="eq-radio-options" data-orientation="vertical">
      <label class="eq-radio">
        <input type="radio" id="radio-visibility-private" class="eq-radio-input" aria-labelledby="radio-visibility-private-label" aria-describedby="radio-visibility-private-description" name="visibility" checked value="private">
        <span class="eq-radio-text">
          <span class="eq-radio-label" id="radio-visibility-private-label">Private</span>
          <span class="eq-radio-description" id="radio-visibility-private-description">Only invited members can see or join.</span>
        </span>
      </label>
      <label class="eq-radio">
        <input type="radio" id="radio-visibility-internal" class="eq-radio-input" aria-labelledby="radio-visibility-internal-label" aria-describedby="radio-visibility-internal-description" name="visibility" value="internal">
        <span class="eq-radio-text">
          <span class="eq-radio-label" id="radio-visibility-internal-label">Internal</span>
          <span class="eq-radio-description" id="radio-visibility-internal-description">Anyone in the Larder organisation can view.</span>
        </span>
      </label>
      <label class="eq-radio">
        <input type="radio" id="radio-visibility-public" class="eq-radio-input" aria-labelledby="radio-visibility-public-label" aria-describedby="radio-visibility-public-description" name="visibility" value="public">
        <span class="eq-radio-text">
          <span class="eq-radio-label" id="radio-visibility-public-label">Public</span>
          <span class="eq-radio-description" id="radio-visibility-public-description">Anyone with the link can view. Only members can edit.</span>
        </span>
      </label>
    </div>
  </fieldset>
</div>

Horizontal

Arrow keys still move through the options in DOM order, whichever way they are laid out.

Instance size
Code
import { Radio, RadioGroup } from "@/components/equip/radio";

export default function Example() {
  return (
    <RadioGroup legend="Instance size" orientation="horizontal">
      <Radio name="instance" value="small" label="2 vCPU" />
      <Radio name="instance" value="medium" label="4 vCPU" defaultChecked />
      <Radio name="instance" value="large" label="8 vCPU" />
    </RadioGroup>
  );
}
HTMLworks in any framework, with the part's CSS
<fieldset class="eq-radio-group">
  <legend class="eq-radio-legend">Instance size</legend>
  <div class="eq-radio-options" data-orientation="horizontal">
    <label class="eq-radio">
      <input type="radio" class="eq-radio-input" name="instance" value="small">
      <span class="eq-radio-text">
        <span class="eq-radio-label">2 vCPU</span>
      </span>
    </label>
    <label class="eq-radio">
      <input type="radio" class="eq-radio-input" name="instance" checked value="medium">
      <span class="eq-radio-text">
        <span class="eq-radio-label">4 vCPU</span>
      </span>
    </label>
    <label class="eq-radio">
      <input type="radio" class="eq-radio-input" name="instance" value="large">
      <span class="eq-radio-text">
        <span class="eq-radio-label">8 vCPU</span>
      </span>
    </label>
  </div>
</fieldset>

Disabled

Disable one option with its own disabled attribute, or the whole group by disabling the fieldset.

Backup frequency
Data region
Code
import { Radio, RadioGroup } from "@/components/equip/radio";

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 16 }}>
      <RadioGroup legend="Backup frequency">
        <Radio name="backups" value="daily" label="Daily" defaultChecked />
        <Radio name="backups" value="hourly" label="Hourly (Enterprise plan)" disabled />
      </RadioGroup>
      <RadioGroup legend="Data region" disabled>
        <Radio name="region" value="eu-west-1" label="eu-west-1" defaultChecked />
        <Radio name="region" value="us-east-1" label="us-east-1" />
      </RadioGroup>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:16px">
  <fieldset class="eq-radio-group">
    <legend class="eq-radio-legend">Backup frequency</legend>
    <div class="eq-radio-options" data-orientation="vertical">
      <label class="eq-radio">
        <input type="radio" class="eq-radio-input" name="backups" checked value="daily">
        <span class="eq-radio-text">
          <span class="eq-radio-label">Daily</span>
        </span>
      </label>
      <label class="eq-radio">
        <input disabled type="radio" class="eq-radio-input" name="backups" value="hourly">
        <span class="eq-radio-text">
          <span class="eq-radio-label">Hourly (Enterprise plan)</span>
        </span>
      </label>
    </div>
  </fieldset>
  <fieldset disabled class="eq-radio-group">
    <legend class="eq-radio-legend">Data region</legend>
    <div class="eq-radio-options" data-orientation="vertical">
      <label class="eq-radio">
        <input type="radio" class="eq-radio-input" name="region" checked value="eu-west-1">
        <span class="eq-radio-text">
          <span class="eq-radio-label">eu-west-1</span>
        </span>
      </label>
      <label class="eq-radio">
        <input type="radio" class="eq-radio-input" name="region" value="us-east-1">
        <span class="eq-radio-text">
          <span class="eq-radio-label">us-east-1</span>
        </span>
      </label>
    </div>
  </fieldset>
</div>

Props

PropTypeDefaultWhat it does
legendReactNodenoneRadioGroup. The question the options answer.
hintReactNodenoneRadioGroup. Help text under the legend. Pass id as well so it is linked to the group.
orientation"vertical" | "horizontal""vertical"RadioGroup. Lays the options out in a column or a wrapping row.
...restComponentProps<"fieldset">noneRadioGroup. Every native fieldset attribute, including disabled and name for form access.
labelReactNodenoneRadio. Visible label. Clicking it selects the option.
descriptionReactNodenoneRadio. Secondary line under the label. Pass id as well so it is read as a description.
namestringnoneRadio. Required in practice: give every radio in a group the same name, or arrow keys will not move between them.
classNamestringnoneRadio. Added to the root label, not the input.
...restOmit<ComponentProps<"input">, "type">noneRadio. Every native input attribute and ref, applied to the input. type is always radio.

Anatomy

group.eq-radio-groupThe native fieldset.
legend.eq-radio-legend
hint.eq-radio-hint
options.eq-radio-optionsLays the radios out by orientation.
radio.eq-radioThe wrapping label for one option.
input.eq-radio-inputThe native radio with appearance: none.
text.eq-radio-text
label.eq-radio-label
description.eq-radio-description

Keyboard

TabMoves focus into the group, to the checked radio or the first one. Provided by the browser.
DownRightMoves to the next radio and checks it. Provided by the browser.
UpLeftMoves to the previous radio and checks it. Provided by the browser.
SpaceChecks the focused radio if none is checked. Provided by the browser.

Notes

  • Renders on the server with no client JavaScript; grouping and arrow keys come from radios sharing a name.
  • RadioGroup cannot pass name down without client context, so set the same name on each Radio.
  • The fieldset and legend give the group its accessible name, so screen readers announce the question before the options.
  • Disabling the fieldset disables every radio inside it.
  • For bordered option cards with a price, such as a shipping method or a plan, use Plan picker, which is radio cards built on the same native inputs.
  • Without an id, a description stays part of the option's accessible name; with one, it is linked by aria-describedby instead. Not yet checked with a screen reader.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/radio.tsx95 lines
import type { ComponentProps, ReactNode } from "react";
import "./radio.css";

/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
  return node != null && node !== false && node !== "";
}

export type RadioGroupProps = ComponentProps<"fieldset"> & {
  /** The question the options answer. Rendered as the fieldset's legend. */
  legend: ReactNode;
  /** Help text under the legend. Pass an id too, so it is linked to the group. */
  hint?: ReactNode;
  /** Lay the options out in a column or a row. */
  orientation?: "vertical" | "horizontal";
};

export function RadioGroup({
  legend,
  hint,
  orientation = "vertical",
  id,
  className,
  children,
  "aria-describedby": describedBy,
  ...rest
}: RadioGroupProps) {
  const hintId = id != null && present(hint) ? `${id}-hint` : undefined;
  const described = [describedBy, hintId].filter(Boolean).join(" ") || undefined;

  return (
    <fieldset
      {...rest}
      id={id}
      className={["eq-radio-group", className].filter(Boolean).join(" ")}
      aria-describedby={described}
    >
      <legend className="eq-radio-legend">{legend}</legend>
      {present(hint) && (
        <p className="eq-radio-hint" id={hintId}>
          {hint}
        </p>
      )}
      <div className="eq-radio-options" data-orientation={orientation}>
        {children}
      </div>
    </fieldset>
  );
}

export type RadioProps = Omit<ComponentProps<"input">, "type"> & {
  /** Visible label. Clicking it selects the option. */
  label: ReactNode;
  /** Secondary line under the label. Pass an id too, so it is read as a description, not as part of the name. */
  description?: ReactNode;
};

export function Radio({
  label,
  description,
  id,
  className,
  "aria-labelledby": labelledBy,
  "aria-describedby": describedBy,
  ...rest
}: RadioProps) {
  // With an id, the description is split out of the name; without one it stays inside the label.
  const split = id != null && present(description);
  const labelId = split ? `${id}-label` : undefined;
  const descriptionId = split ? `${id}-description` : undefined;
  const described = [describedBy, descriptionId].filter(Boolean).join(" ") || undefined;

  return (
    <label className={["eq-radio", className].filter(Boolean).join(" ")}>
      <input
        {...rest}
        type="radio"
        id={id}
        className="eq-radio-input"
        aria-labelledby={labelId ?? labelledBy}
        aria-describedby={described}
      />
      <span className="eq-radio-text">
        <span className="eq-radio-label" id={labelId}>
          {label}
        </span>
        {present(description) && (
          <span className="eq-radio-description" id={descriptionId}>
            {description}
          </span>
        )}
      </span>
    </label>
  );
}
components/equip/radio.css133 lines
@layer equip {
  .eq-radio-group {
    min-inline-size: 0;
    margin: 0;
    padding: 0;
    border: 0;
    font-family: var(--eq-font);
    color: var(--eq-ink);
  }

  .eq-radio-legend {
    margin: 0 0 var(--eq-space-1);
    padding: 0;
    font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
    color: var(--eq-ink);
  }

  .eq-radio-hint {
    margin: 0;
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
    color: var(--eq-ink-2);
  }

  .eq-radio-options {
    display: flex;
    flex-direction: column;
    gap: var(--eq-space-1);
    padding-top: var(--eq-space-1);
  }

  .eq-radio-options[data-orientation="horizontal"] {
    flex-direction: row;
    flex-wrap: wrap;
    column-gap: var(--eq-space-4);
  }

  .eq-radio {
    display: inline-flex;
    align-items: flex-start;
    gap: var(--eq-space-2);
    /* 2px above and below a 20px line keeps the whole row a 24px target. */
    min-height: 24px;
    box-sizing: border-box;
    padding-block: 2px;
    font: var(--eq-weight) var(--eq-text-m) / 20px var(--eq-font);
    color: var(--eq-ink);
    cursor: pointer;
  }

  .eq-radio-input {
    appearance: none;
    box-sizing: border-box;
    flex: none;
    display: block;
    width: 16px;
    height: 16px;
    margin: 2px 0 0;
    border: 1px solid var(--eq-line-strong);
    /* Round, unlike the checkbox, so the two never read as the same control. */
    border-radius: var(--eq-radius-full);
    /* Unchecked, the face is an inset fill rather than the background, so it is painted
       once (finishes may give --eq-raised alpha) and the dot has room to grow out of it. */
    background: transparent;
    box-shadow: inset 0 0 0 7px var(--eq-raised);
    cursor: inherit;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      border-color var(--eq-dur) var(--eq-ease),
      box-shadow var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  /* A solid ring around a dot in the solid ink: the dot is the background showing through.
     The ring thins from the full face to 4px on the spring, so the dot grows from the centre. */
  .eq-radio-input:checked {
    border-color: var(--eq-solid);
    background: var(--eq-solid-ink);
    box-shadow: inset 0 0 0 4px var(--eq-solid);
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      border-color var(--eq-dur) var(--eq-ease),
      box-shadow var(--eq-dur-move) var(--eq-spring),
      scale var(--eq-dur) var(--eq-ease);
  }

  /* At 16px the ring gives a little more than a button. */
  .eq-radio:active .eq-radio-input:not(:disabled) {
    scale: calc(var(--eq-press) - 0.04);
  }

  @media (hover: hover) {
    .eq-radio:hover .eq-radio-input:where(:not(:checked, :disabled)) {
      border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
    }
  }

  .eq-radio-input:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  /* A required group left empty, once the person has tried to submit. */
  .eq-radio .eq-radio-input:user-invalid {
    border-color: var(--eq-danger);
  }

  .eq-radio:has(.eq-radio-input:disabled) {
    opacity: 0.45;
    cursor: not-allowed;
  }

  /* A disabled fieldset disables every radio inside without dimming them twice. */
  .eq-radio-group:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-radio-group:disabled .eq-radio {
    opacity: 1;
  }

  .eq-radio-text {
    display: flex;
    flex-direction: column;
    min-width: 0;
  }

  .eq-radio-description {
    font-size: var(--eq-text-s);
    line-height: 18px;
    color: var(--eq-ink-2);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index