Radio

One choice from a mutually exclusive set, with all options visible.

Forms Radio.jsx Radio.d.ts Radio.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

Radio is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Radio } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

One choice from a mutually exclusive set, with all options visible.

Use it when

  • Two to five options and the user benefits from seeing them all.

Don't use it when

  • More than about five options. Use Select.
  • The choices are not exclusive. Use Checkbox.
  • There are exactly two opposite states applied instantly. Use Switch.

Example

<Stack gap="xs">
  <Radio name="billing" value="monthly" label="Monthly" hint="$20 per seat" defaultChecked />
  <Radio name="billing" value="annual" label="Annual" hint="$16 per seat, billed yearly" />
</Stack>

Variants

labelPosition="start" puts the label on the left and the radio at the right edge of the row, so a column of options lines up with the text above it. In a group, set it once on RadioGroup.

Accessibility

Every radio in a group needs the same name: that is what gives the group arrow-key navigation and a single tab stop. Wrap the group in a fieldset with a legend, or a Field with role="radiogroup".

Content

Options are parallel in structure and length. Put price or consequence in the hint, not the label.

Props

The typed contract, from Radio.d.ts.

import * as React from "react";

/** One choice from a mutually exclusive set. Group several by giving them the same name. */
export interface RadioProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange" | "type"> {
  label?: React.ReactNode;
  hint?: string;
  /** Shared across the group. Required for keyboard arrow navigation to work. */
  name?: string;
  value?: string;
  checked?: boolean;
  defaultChecked?: boolean;
  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
  disabled?: boolean;
  /** "start" puts the label on the left and pushes the control to the right edge, so labels in a column line up with the text above them instead of indenting past the control. @default "end" */
  labelPosition?: "start" | "end";
}

export declare function Radio(props: RadioProps): React.JSX.Element;

Tokens it reads

Every custom property Radio.jsx resolves, read from the source: 1 component, 12 semantic. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-input-bgcomponentvar(--dt-surface-base)
--dt-border-strongsemanticvar(--dt-color-neutral-400)
--dt-border-width-strongsemanticvar(--dt-dim-hair-2)
--dt-motion-microsemanticvar(--dt-duration-100) var(--dt-easing-standard)
--dt-space-inline-xssemanticvar(--dt-dim-2)
--dt-surface-actionsemanticvar(--dt-color-neutral-900)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-body-xs-linesemanticvar(--dt-line-height-xs)
--dt-text-body-xs-sizesemanticvar(--dt-font-size-xs)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)

Source

React only, no dependencies, every value a token.

import React from "react";

export function Radio({ label, hint, name, value, checked, defaultChecked, onChange, disabled = false, id, labelPosition = "end", style, ...rest }) {
  const [internal, setInternal] = React.useState(!!defaultChecked);
  const auto = React.useId();
  const radioId = id || auto;
  const isOn = checked !== undefined ? checked : internal;

  const handle = (e) => {
    if (checked === undefined) setInternal(e.target.checked);
    onChange && onChange(e);
  };

  return (
    <div style={{ display: "flex", gap: "var(--dt-space-inline-xs)", opacity: disabled ? 0.6 : 1, ...style }}>
      <input
        type="radio" id={radioId} name={name} value={value} checked={isOn} onChange={handle} disabled={disabled}
        style={{ position: "absolute", opacity: 0, width: 0, height: 0 }}
        {...rest}
      />
      <label
        htmlFor={radioId}
        style={{
          display: "flex", gap: "var(--dt-space-inline-xs)", cursor: disabled ? "not-allowed" : "pointer", alignItems: hint ? "flex-start" : "center",
          justifyContent: labelPosition === "start" ? "space-between" : undefined,
          width: labelPosition === "start" ? "100%" : undefined,
          flexDirection: labelPosition === "start" ? "row-reverse" : "row",
        }}
      >
        <span
          aria-hidden="true"
          style={{
            flex: "none", width: 18, height: 18, marginTop: hint ? 2 : 0, boxSizing: "border-box", borderRadius: "50%",
            border: isOn ? "6px solid var(--dt-surface-action)" : "var(--dt-border-width-strong) solid var(--dt-border-strong)",
            background: "var(--dt-input-bg)",
            transition: "border var(--dt-motion-micro)",
          }}
        />
        <span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
          <span style={{ fontFamily: "var(--dt-text-body-sm-family)", fontSize: "var(--dt-text-body-sm-size)", lineHeight: "var(--dt-text-body-sm-line)", color: "var(--dt-text-primary)" }}>{label}</span>
          {hint && <span style={{ fontSize: "var(--dt-text-body-xs-size)", lineHeight: "var(--dt-text-body-xs-line)", color: "var(--dt-text-secondary)" }}>{hint}</span>}
        </span>
      </label>
    </div>
  );
}

In context

Radio also appears on the forms reference cards, alongside the rest of its family.