← ui

radio-group

ui

one of a few, each shown, chosen with the arrow keys

npx shadcn add @ag/radio-group
Already installed? Preview an update
npx shadcn add @ag/radio-group --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-10
  • New: one of a few, each shown, on Base UI's radio group (the arrow keys move the choice, one Tab stop, a hidden input a form submits). RadioGroup sets size, material and orientation (vertical or a row) for every Radio in it; a radio with children is one label with its words. Red when the group is aria-invalid. Plain a sunken circle filled with the primary, glass a bead lit by the text.
Updated 2026-10-10 · release notes

shelf controls

uses field-context, material

used by components

the demo above, verbatim

radio-group.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Radio, RadioGroup } from "@/registry/base-nova/ui/radio-group"

// #region speed
function Speed() {
  return (
    <RadioGroup defaultValue="normal" aria-label="playback speed">
      <Radio value="slow">slow, 0.5×</Radio>
      <Radio value="normal">normal</Radio>
      <Radio value="fast">fast, 2×</Radio>
      <Radio value="faster" disabled>
        faster (premium)
      </Radio>
    </RadioGroup>
  )
}
// #endregion

export default function RadioGroupDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="materials"
        title="plain and glass"
        description="the arrow keys move the choice; one Tab stop for the group."
        with="speed"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="p-6">
            <Speed />
          </div>
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-6 text-white">
              <Speed />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample name="row" glance title="in a row, wrong">
        <RadioGroup
          orientation="horizontal"
          aria-label="size"
          aria-invalid
          size="lg"
        >
          <Radio value="s">small</Radio>
          <Radio value="m">medium</Radio>
          <Radio value="l">large</Radio>
        </RadioGroup>
      </Sample>
    </div>
  )
}

the source, every file an install writes

radio-group.tsx
"use client"

import { Radio as BaseRadio } from "@base-ui/react/radio"
import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group"
import * as React from "react"
import { cn } from "@/lib/utils"
import { fieldProps, useField } from "@/registry/base-nova/ui/field-context"
import {
  type Material,
  MaterialProvider,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"

// One of a few, each shown (Base UI's radio group: the arrow keys move the choice and
// one Tab stop holds the group; a hidden input submits it with a form). A `Radio` is
// one choice; given `children`, the circle and its words are one label. The group
// sets the size and the material for every radio in it.
//
// Plain: a sunken circle, the page's primary filling it with a dot when chosen.
// `material="glass"`: a bead of glass, lit and coloured by the text when chosen, the
// dot in the text's own colour. Wrong (`aria-invalid` on the group, or a Base UI Field
// that judged it), every circle's edge is red.

export type RadioSize = "sm" | "md" | "lg"

const CIRCLE: Record<RadioSize, string> = {
  sm: "size-3.5",
  md: "size-4",
  lg: "size-5",
}
/** The glass circle's radius, half of `CIRCLE`. */
const GLASS_RADIUS: Record<RadioSize, string> = {
  sm: "[--glass-radius:7px]",
  md: "[--glass-radius:8px]",
  lg: "[--glass-radius:10px]",
}
const DOT: Record<RadioSize, string> = {
  sm: "size-1.5",
  md: "size-1.5",
  lg: "size-2",
}
const WORDS: Record<RadioSize, string> = {
  sm: "gap-1.5 text-[12px]",
  md: "gap-2 text-[13px]",
  lg: "gap-2.5 text-sm",
}

const PLAIN =
  "bg-foreground/[0.04] ring-1 ring-foreground/25 ring-inset transition-[background-color,box-shadow] hover:ring-foreground/40 data-checked:bg-primary data-checked:ring-primary group-aria-invalid/radios:ring-tone-bad/70 data-invalid:ring-tone-bad/70"
const GLASS =
  "hover:[--glass-rim:1.4]! data-checked:[--glass-stain:currentColor]! data-checked:[--glass-stain-amount:18%]! data-checked:[--glass-rim:1.8]! group-aria-invalid/radios:[--glass-stain:var(--tone-bad)]! group-aria-invalid/radios:[--glass-rim-color:var(--tone-bad)]! group-aria-invalid/radios:[--glass-rim:2.2]! data-invalid:[--glass-stain:var(--tone-bad)]! data-invalid:[--glass-rim-color:var(--tone-bad)]! data-invalid:[--glass-rim:2.2]!"

/** The group's size, for every radio in it (material and scheme ride the
 *  MaterialProvider the group sets). */
const Size = React.createContext<RadioSize>("md")

export type RadioGroupProps = Omit<
  React.ComponentProps<typeof BaseRadioGroup>,
  "className"
> & {
  size?: RadioSize
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  /** Side by side rather than one under another. */
  orientation?: "vertical" | "horizontal"
  className?: string
}

export function RadioGroup({
  size = "md",
  material,
  scheme,
  orientation = "vertical",
  className,
  ref,
  ...props
}: RadioGroupProps) {
  const m = useMaterial(material)
  // Over a picture the group is read by the part of it under the group, and its
  // radios and their words wear what it reads: one scheme for one choice.
  const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme, ref)
  const field = useField()
  return (
    <MaterialProvider material={m} scheme={s}>
      <Size.Provider value={size}>
        <BaseRadioGroup
          ref={here}
          data-slot="radio-group"
          data-material={m}
          data-orientation={orientation}
          className={cn(
            "group/radios flex",
            schemeOf(s),
            orientation === "vertical"
              ? "flex-col gap-2"
              : "flex-wrap items-center gap-x-4 gap-y-2",
            className,
          )}
          // Inside a field: the group named by its label, its wiring and read-only;
          // what is passed here wins.
          {...fieldProps(field, { named: true })}
          {...props}
        />
      </Size.Provider>
    </MaterialProvider>
  )
}

export type RadioProps = Omit<
  React.ComponentProps<typeof BaseRadio.Root>,
  "className" | "children"
> & {
  /** Its words: the circle and these are one label. */
  children?: React.ReactNode
  className?: string
}

/** One choice of the group around it. */
export function Radio({ children, className, ...props }: RadioProps) {
  const m = useMaterial()
  const size = React.useContext(Size)
  const circle = (
    <BaseRadio.Root
      data-slot="radio"
      data-material={m}
      className={cn(
        "relative inline-grid shrink-0 cursor-pointer place-items-center rounded-full data-disabled:cursor-not-allowed",
        CIRCLE[size],
        m === "glass"
          ? [glassVariants({ shape: "surface" }), GLASS_RADIUS[size], GLASS]
          : PLAIN,
        !children && ["data-disabled:opacity-50", className],
      )}
      {...props}
    >
      <BaseRadio.Indicator
        data-slot="radio-indicator"
        className={cn(
          "rounded-full transition-[opacity,scale] duration-150 ease-out data-starting-style:scale-0 data-starting-style:opacity-0 motion-reduce:transition-none",
          DOT[size],
          m === "glass" ? "bg-current" : "bg-primary-foreground",
        )}
      />
    </BaseRadio.Root>
  )
  if (!children) return circle
  return (
    // biome-ignore lint/a11y/noLabelWithoutControl: the radio inside is its control
    <label
      data-slot="radio-label"
      className={cn(
        "inline-flex cursor-pointer items-center select-none has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:opacity-50",
        WORDS[size],
        m === "glass" ? "text-current/90" : "text-foreground/85",
        className,
      )}
    >
      {circle}
      {children}
    </label>
  )
}