Radio Group

A set of radio buttons where no more than one can be checked at a time.

import * as stylex from "@stylexjs/stylex"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { HStack } from "@/components/ui/stack"

const styles = stylex.create({
  group: { width: "fit-content" },
})

export default function RadioGroupDemo() {
  return (
    <RadioGroup defaultValue="comfortable" xstyle={styles.group}>
      <HStack gap={3} align="center">
        <RadioGroupItem value="default" id="density-default" />
        <Label htmlFor="density-default">Default</Label>
      </HStack>
      <HStack gap={3} align="center">
        <RadioGroupItem value="comfortable" id="density-comfortable" />
        <Label htmlFor="density-comfortable">Comfortable</Label>
      </HStack>
      <HStack gap={3} align="center">
        <RadioGroupItem value="compact" id="density-compact" />
        <Label htmlFor="density-compact">Compact</Label>
      </HStack>
    </RadioGroup>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts and state-styles.ts from the same page. The radio group imports both. The examples caption their buttons with a Label or a Field.

Paste field-state.ts from the same installation page.

Copy and paste the following code into your project.

components/ui/radio-group.tsx
"use client"

import { use, type CSSProperties } from "react"
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { stateStyles } from "@/lib/state-styles"
import { FieldStateContext } from "@/lib/field-state"
import { states } from "@/theme/states"
import { color, duration, ease, radius, space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  group: {
    display: "grid",
    gap: space.s2,
    width: "100%",
  },
  item: {
    position: "relative",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    flexShrink: 0,
    width: space.s4,
    height: space.s4,
    borderRadius: radius.full,
    borderWidth: 1,
    borderStyle: "solid",
    transitionProperty: "color, background-color, border-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    "::after": {
      content: '""',
      position: "absolute",
      insetInline: `calc(-1 * ${space.s3})`,
      insetBlock: `calc(-1 * ${space.s2})`,
    },
  },
  indicator: {
    width: space.s1_5,
    height: space.s1_5,
    borderRadius: radius.full,
    boxShadow: `0 1px 1px ${color.buttonDrop}`,
    backgroundColor: color.current,
    transitionProperty: "opacity, transform",
    transitionDuration: {
      default: duration.color,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.standard,
  },
  indicatorHidden: { opacity: 0, transform: "scale(0.6)" },
})

const radioGroupStyles = {
  group: styles.group,
  item: styles.item,
  key: [states.key, states.keyChoice],
  surface: states.keySurface,
  checked: states.keyChecked,
  disabled: states.disabled,
  invalid: states.invalidRing,
}

type StyleProps = {
  xstyle?: StyleXStyles
  className?: string
}

type StateOverrides = StyleProps & { style?: CSSProperties }

type RadioGroupProps = Omit<RadioGroupPrimitive.Props, "className"> & StyleProps

function RadioGroup({ xstyle, className, ...props }: RadioGroupProps) {
  const sx = stylex.props(styles.group, xstyle)

  return (
    <RadioGroupPrimitive
      data-slot="radio-group"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type RadioGroupItemProps = Omit<RadioPrimitive.Root.Props, "className" | "style"> & StateOverrides

function RadioGroupItem({ xstyle, style, className, ...props }: RadioGroupItemProps) {
  const fieldState = use(FieldStateContext)
  return (
    <RadioPrimitive.Root
      data-slot="radio-group-item"
      {...props}
      {...stateStyles(
        (state) => [
          styles.item,
          states.key,
          states.keyChoice,
          states.focusRing,
          states.keySurface,
          (state.valid === false ||
            fieldState?.valid === false ||
            props["aria-invalid"] === true ||
            props["aria-invalid"] === "true") &&
            states.invalidRing,
          state.checked && states.keyChecked,
          state.disabled && states.disabled,
          xstyle,
        ],
        { className, style },
      )}
    >
      <RadioPrimitive.Indicator
        data-slot="radio-group-indicator"
        {...stateStyles((state) => [
          styles.indicator,
          (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
            styles.indicatorHidden,
        ])}
      />
    </RadioPrimitive.Root>
  )
}

export { RadioGroup, RadioGroupItem, radioGroupStyles }

Update the import paths to match your project setup.

Usage

import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
<RadioGroup defaultValue="comfortable">
  <HStack gap={3} align="center">
    <RadioGroupItem value="comfortable" id="r1" />
    <Label htmlFor="r1">Comfortable</Label>
  </HStack>
</RadioGroup>

RadioGroup is a grid that fills its container and stacks its children. Match each label's htmlFor to its button's id, or wrap the button in the label.

RadioGroup
├── RadioGroupItem
└── RadioGroupItem

Selected value

Use defaultValue for an uncontrolled group, or value and onValueChange to control it. value is the value of the selected RadioGroupItem, which can be any type.

const [density, setDensity] = useState("comfortable")

return (
  <RadioGroup value={density} onValueChange={setDensity}>
    …
  </RadioGroup>
)

Description

Put a Field around each button and a FieldContent beside it to add a line of helper text under the label.

Standard spacing for most use cases.

More space between elements.

Minimal spacing for dense layouts.

import * as stylex from "@stylexjs/stylex"
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const styles = stylex.create({
  group: { maxWidth: "24rem" },
})

const densities = [
  { id: "default", title: "Default", description: "Standard spacing for most use cases." },
  { id: "comfortable", title: "Comfortable", description: "More space between elements." },
  { id: "compact", title: "Compact", description: "Minimal spacing for dense layouts." },
]

export default function RadioGroupDescription() {
  return (
    <RadioGroup defaultValue="comfortable" xstyle={styles.group}>
      {densities.map((density) => (
        <Field key={density.id} orientation="horizontal">
          <RadioGroupItem value={density.id} id={`desc-${density.id}`} />
          <FieldContent>
            <FieldLabel>{density.title}</FieldLabel>
            <FieldDescription>{density.description}</FieldDescription>
          </FieldContent>
        </Field>
      ))}
    </RadioGroup>
  )
}

Choice card

Wrap a whole Field in a FieldLabel to turn the option into a card, and pass checked to mark the selected one.

"use client"

import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const styles = stylex.create({
  group: { maxWidth: "24rem" },
})

const plans = [
  { id: "plus", title: "Plus", description: "For individuals and small teams." },
  { id: "pro", title: "Pro", description: "For growing businesses." },
  { id: "enterprise", title: "Enterprise", description: "For large teams and enterprises." },
]

export default function RadioGroupChoiceCard() {
  const [plan, setPlan] = useState("plus")

  return (
    <RadioGroup value={plan} onValueChange={setPlan} xstyle={styles.group}>
      {plans.map((entry) => (
        <FieldLabel key={entry.id} htmlFor={`plan-${entry.id}`} checked={plan === entry.id}>
          <Field orientation="horizontal">
            <FieldContent>
              <FieldTitle>{entry.title}</FieldTitle>
              <FieldDescription>{entry.description}</FieldDescription>
            </FieldContent>
            <RadioGroupItem value={entry.id} id={`plan-${entry.id}`} />
          </Field>
        </FieldLabel>
      ))}
    </RadioGroup>
  )
}

FieldLabel cannot read the button's state, so checked is a prop you set from the same value you give RadioGroup. That makes the card a controlled group.

Fieldset

Use Fieldset and FieldsetLegend to caption the group as a whole, and give the group a name to submit it with a form.

Subscription plan

Yearly and lifetime plans offer significant savings.

import * as stylex from "@stylexjs/stylex"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { FieldsetLegend, Fieldset } from "@/components/ui/fieldset"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const styles = stylex.create({
  fieldset: { maxWidth: "20rem" },
  label: { fontWeight: 400 },
})

const plans = [
  { id: "monthly", label: "Monthly ($9.99/month)" },
  { id: "yearly", label: "Yearly ($99.99/year)" },
  { id: "lifetime", label: "Lifetime ($299.99)" },
]

export default function RadioGroupFieldset() {
  return (
    <Fieldset xstyle={styles.fieldset}>
      <FieldsetLegend variant="label">Subscription plan</FieldsetLegend>
      <FieldDescription>Yearly and lifetime plans offer significant savings.</FieldDescription>
      <RadioGroup defaultValue="monthly" name="subscription-plan">
        {plans.map((plan) => (
          <Field key={plan.id} orientation="horizontal">
            <RadioGroupItem value={plan.id} id={`billing-${plan.id}`} />
            <FieldLabel xstyle={styles.label}>{plan.label}</FieldLabel>
          </Field>
        ))}
      </RadioGroup>
    </Fieldset>
  )
}

Disabled

Set disabled on Field to disable its control and dim its label, or on a bare control directly.

import * as stylex from "@stylexjs/stylex"
import { Field, FieldLabel } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

const styles = stylex.create({
  group: { width: "fit-content" },
  label: { fontWeight: 400 },
})

export default function RadioGroupDisabled() {
  return (
    <RadioGroup defaultValue="standard" xstyle={styles.group}>
      <Field orientation="horizontal" disabled>
        <RadioGroupItem value="free" id="shipping-free" />
        <FieldLabel xstyle={styles.label}>Free (unavailable)</FieldLabel>
      </Field>
      <Field orientation="horizontal">
        <RadioGroupItem value="standard" id="shipping-standard" />
        <FieldLabel xstyle={styles.label}>Standard</FieldLabel>
      </Field>
      <Field orientation="horizontal">
        <RadioGroupItem value="express" id="shipping-express" />
        <FieldLabel xstyle={styles.label}>Express</FieldLabel>
      </Field>
    </RadioGroup>
  )
}

Invalid

Set invalid on Field to style its control, or aria-invalid on a bare control. The examples show both.

import { Field, FieldLabel, FieldError } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { HStack, VStack } from "@/components/ui/stack"
const channels = [
  { id: "email", label: "Email only" },
  { id: "sms", label: "SMS only" },
  { id: "both", label: "Email and SMS" },
]
function Options({ bare = false }: { bare?: boolean }) {
  return (
    <RadioGroup aria-label={bare ? "Notification preferences" : undefined}>
      {channels.map((channel) => (
        <HStack key={channel.id} gap={2} align="center">
          <RadioGroupItem
            value={channel.id}
            id={`${bare ? "bare" : "field"}-notify-${channel.id}`}
            aria-invalid={bare || undefined}
          />
          <Label htmlFor={`${bare ? "bare" : "field"}-notify-${channel.id}`}>{channel.label}</Label>
        </HStack>
      ))}
    </RadioGroup>
  )
}
export default function RadioGroupInvalid() {
  return (
    <VStack gap={5}>
      <Options bare />
      <Field invalid>
        <FieldLabel>Notification preferences</FieldLabel>
        <Options />
        <FieldError>Pick a notification channel to continue.</FieldError>
      </Field>
    </VStack>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Both parts wrap the Base UI RadioGroup and Radio primitives and accept their props. See the Base UI reference. Each part also takes xstyle and className.

RadioGroup

PropTypeDefault
valueValue—
defaultValueValue—
onValueChange(value: Value, details) => void—
namestring—
disabledbooleanfalse
readOnlybooleanfalse
requiredbooleanfalse
formstring—
xstyleStyleXStyles—
classNamestring—

The root is a <div role="radiogroup">. Arrow keys move between the buttons and select as they go, and the group is one tab stop.

RadioGroupItem

PropTypeDefault
valueValue—
disabledbooleanfalse
readOnlybooleanfalse
requiredbooleanfalse
aria-invalidboolean—
idstring—
inputRefRef<HTMLInputElement>—
xstyleStyleXStyles—
styleCSSProperties—
classNamestring—

The item is a <span role="radio">. Base UI renders a hidden <input> beside it that carries id, name and value. A label's htmlFor and a form submission reach the item through that input.

radioGroupStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
groupThe grid and its gap
itemThe circle: size, border width, hit area
keyThe raised key's colours at a choice's size
surfaceThe raised shadow, kept through focus
checkedThe glossy primary fill and inverted dot
disabledThe dimmed, non-interactive state
invalidThe destructive border and ring

item has no colour and no focus ring. Everything else comes from states in theme/states.ts, the same key the checkbox uses. Merge key, then states.focusRing, then surface. Merge invalid before checked, so a selected invalid button keeps the primary fill and shows only the destructive ring.

Data attributes

AttributeOnEffect
data-slot="radio-group"the rootTargets the grid from a parent
data-slot="radio-group-item"a buttonTargets the circle
data-slot="radio-group-indicator"the dotTargets the animated indicator
data-checked, data-uncheckeditem, dotBase UI writes them
data-disabled, data-readonly, data-requiredroot, itemBase UI writes them
data-starting-style, data-ending-stylethe dotBase UI writes them while the dot enters or leaves