one of a few, each shown, chosen with the arrow keys
npx shadcn add @ag/radio-groupAlready installed? Preview an update
npx shadcn add @ag/radio-group --dry-runYour 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).
RadioGroupsetssize,materialandorientation(vertical or a row) for everyRadioin it; a radio withchildrenis one label with its words. Red when the group isaria-invalid. Plain a sunken circle filled with the primary, glass a bead lit by the text.
the demo above, verbatim
"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
"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>
)
}