Pick one option from a set, with a dot that hands off between choices and cards whose selection ring slides to the new pick.
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const options = [
{ value: "all", label: "All new messages" },
{ value: "mentions", label: "Direct messages and mentions" },
{ value: "none", label: "Nothing" },
]
export function RadioGroupDemo() {
return (
<RadioGroup
aria-label="Notify me about"
defaultValue="mentions"
className="w-fit"
>
{options.map((option) => (
<label key={option.value} className="flex items-center gap-3 text-sm">
<RadioGroupItem value={option.value} />
{option.label}
</label>
))}
</RadioGroup>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/radio-group.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react cn - 3
Copy and paste the following code into your project.
components/ui/radio-group.tsx "use client" import * as React from "react" import { Radio as RadioPrimitive } from "@base-ui/react/radio" import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group" import { cn } from "cn" import { useSlidingHighlight } from "@/lib/motion" function mergeClassName<State>( base: string, className: string | ((state: State) => string | undefined) | undefined ) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } type RadioGroupVariant = "default" | "card" const cardSelector = "[data-slot=radio-group-card]:has(> [data-slot=radio-group-item][data-checked])" type RadioGroupProps<Value> = RadioGroupPrimitive.Props<Value> & { variant?: RadioGroupVariant } function RadioGroup<Value>({ className, variant = "default", children, ref, ...props }: RadioGroupProps<Value>) { const groupRef = React.useRef<HTMLDivElement | null>(null) const highlightRef = React.useRef<HTMLSpanElement | null>(null) useSlidingHighlight(groupRef, highlightRef, cardSelector, "data-checked") const setRef = React.useCallback( (node: HTMLDivElement | null) => { groupRef.current = node if (typeof ref === "function") { return ref(node) } if (ref) { ref.current = node } return undefined }, [ref] ) return ( <RadioGroupPrimitive ref={setRef} className={mergeClassName( cn( "group/radio-group grid w-full min-w-0 gap-3", variant === "card" && "relative isolate gap-2" ), className )} {...props} data-slot="radio-group" data-variant={variant} > {variant === "card" ? ( <span ref={highlightRef} aria-hidden="true" data-slot="radio-group-highlight" className="pointer-events-none absolute top-0 z-1 rounded-xl opacity-0 inset-ring-[1.5px] inset-ring-primary transition-[transform,width,height,opacity] duration-300 ease-out-quint data-instant:transition-opacity data-visible:opacity-100 motion-reduce:transition-opacity" /> ) : null} {children} </RadioGroupPrimitive> ) } const radioGroupItemClassName = "peer relative inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-background text-primary-foreground inset-ring-(length:--hairline) forced-colors:border inset-ring-muted-foreground/80 transition-[background-color,box-shadow,scale] duration-150 ease-out-quint outline-none focus-visible:outline-hidden select-none after:absolute after:-inset-2.5 pointer-coarse:after:-inset-3.5 hover:inset-ring-foreground/70 [@media(hover:hover)]:in-[label:hover]:inset-ring-foreground/70 focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring motion-safe:active:scale-90 motion-safe:in-[label:active]:scale-90 data-checked:bg-primary data-checked:inset-ring-primary data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:inset-ring-muted-foreground/80 data-disabled:[@media(hover:hover)]:in-[label:hover]:inset-ring-muted-foreground/80 motion-safe:data-disabled:active:scale-100 motion-safe:data-disabled:in-[label:active]:scale-100 data-readonly:cursor-default motion-safe:data-readonly:active:scale-100 motion-safe:data-readonly:in-[label:active]:scale-100 aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive data-invalid:ring-3 data-invalid:ring-destructive/20 data-invalid:inset-ring-destructive dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 dark:data-invalid:ring-destructive/40 dark:data-invalid:inset-ring-destructive/50 data-checked:aria-invalid:inset-ring-primary data-checked:data-invalid:inset-ring-primary dark:data-checked:bg-primary" function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) { return ( <RadioPrimitive.Root className={mergeClassName(radioGroupItemClassName, className)} {...props} data-slot="radio-group-item" > <RadioPrimitive.Indicator keepMounted data-slot="radio-group-indicator" className="size-1.5 scale-0 rounded-full bg-current opacity-0 transition-[scale,opacity] duration-150 ease-out-quint motion-reduce:transition-none forced-colors:bg-canvas-text data-checked:scale-100 data-checked:opacity-100 data-checked:duration-300 data-checked:ease-spring" /> </RadioPrimitive.Root> ) } type RadioGroupCardProps = Omit<React.ComponentProps<"label">, "onChange"> & Pick< RadioPrimitive.Root.Props, "value" | "disabled" | "readOnly" | "required" | "inputRef" | "id" > & { radioClassName?: RadioPrimitive.Root.Props["className"] } function RadioGroupCard({ className, children, value, disabled, readOnly, required, inputRef, id, radioClassName, ...props }: RadioGroupCardProps) { return ( <label data-slot="radio-group-card" className={cn( "relative flex min-w-0 cursor-pointer items-start gap-3 rounded-xl bg-background p-4 text-sm inset-ring-(length:--hairline) inset-ring-border transition-[background-color,box-shadow] duration-200 ease-out-quint select-none has-focus-visible:ring-3 has-focus-visible:ring-focus-ring has-data-checked:bg-muted/50 has-data-checked:inset-ring-[1.5px] has-data-checked:inset-ring-primary group-has-[>[data-slot=radio-group-highlight][data-visible]]/radio-group:has-data-checked:inset-ring-(length:--hairline) group-has-[>[data-slot=radio-group-highlight][data-visible]]/radio-group:has-data-checked:inset-ring-border has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50 dark:bg-input/20 dark:has-data-checked:bg-muted/60 forced-colors:border [@media(hover:hover)]:hover:not-has-data-disabled:not-has-data-checked:bg-muted/30", className )} {...props} > <RadioGroupItem value={value} disabled={disabled} readOnly={readOnly} required={required} inputRef={inputRef} id={id} className={mergeClassName("mt-0.5", radioClassName)} /> <span data-slot="radio-group-card-content" className="flex min-w-0 flex-1 flex-col gap-0.5 leading-snug wrap-break-word" > {children} </span> </label> ) } function RadioGroupCardTitle({ className, ...props }: React.ComponentProps<"span">) { return ( <span data-slot="radio-group-card-title" className={cn("font-medium text-foreground", className)} {...props} /> ) } function RadioGroupCardDescription({ className, ...props }: React.ComponentProps<"span">) { return ( <span data-slot="radio-group-card-description" className={cn("text-muted-foreground", className)} {...props} /> ) } export { RadioGroup, RadioGroupCard, RadioGroupCardDescription, RadioGroupCardTitle, RadioGroupItem, radioGroupItemClassName, } export type { RadioGroupCardProps, RadioGroupProps, RadioGroupVariant }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"<RadioGroup aria-label="Notify me about" defaultValue="mentions">
<label className="flex items-center gap-3">
<RadioGroupItem value="all" />
All new messages
</label>
<label className="flex items-center gap-3">
<RadioGroupItem value="mentions" />
Direct messages and mentions
</label>
</RadioGroup>When the choice changes, the old dot shrinks away as the new one springs in, so the eye follows the pick instead of two things blinking at once. Wrap each item in a <label> so the whole row is clickable.
RadioGroup
├── RadioGroupItem
└── RadioGroupCard
├── RadioGroupCardTitle
└── RadioGroupCardDescriptionCards#
With variant="card", use RadioGroupCard for options with more to say. The selection ring slides to the new card while its surface fades in.
import {
RadioGroup,
RadioGroupCard,
RadioGroupCardDescription,
RadioGroupCardTitle,
} from "@/components/ui/radio-group"
const plans = [
{
value: "hobby",
title: "Hobby",
price: "Free",
description: "For side projects and trying things out.",
},
{
value: "pro",
title: "Pro",
price: "$20/mo",
description: "Unlimited projects, analytics and priority builds.",
},
{
value: "team",
title: "Team",
price: "$60/mo",
description: "Everything in Pro, plus roles and audit logs.",
},
]
export function RadioGroupCards() {
return (
<RadioGroup
variant="card"
aria-label="Plan"
defaultValue="pro"
className="max-w-md"
>
{plans.map((plan) => (
<RadioGroupCard key={plan.value} value={plan.value}>
<span className="flex items-baseline justify-between gap-3">
<RadioGroupCardTitle>{plan.title}</RadioGroupCardTitle>
<span className="text-muted-foreground tabular-nums">
{plan.price}
</span>
</span>
<RadioGroupCardDescription>
{plan.description}
</RadioGroupCardDescription>
</RadioGroupCard>
))}
</RadioGroup>
)
}Horizontal#
Cards can sit in a row or any grid. The ring follows them wherever they are.
import {
RadioGroup,
RadioGroupCard,
RadioGroupCardTitle,
} from "@/components/ui/radio-group"
const sizes = ["S", "M", "L", "XL"]
export function RadioGroupHorizontal() {
return (
<RadioGroup
variant="card"
aria-label="Size"
defaultValue="M"
className="w-full max-w-sm grid-cols-2 sm:grid-cols-4"
>
{sizes.map((size) => (
<RadioGroupCard key={size} value={size}>
<RadioGroupCardTitle>{size}</RadioGroupCardTitle>
</RadioGroupCard>
))}
</RadioGroup>
)
}Form#
Inside Field and FieldSet, the group gets a legend, required validation and an error message.
"use client"
import * as React from "react"
import { Form } from "@base-ui/react/form"
import { Button } from "@/components/ui/button"
import {
Field,
FieldError,
FieldItem,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
export function RadioGroupForm() {
const [submitted, setSubmitted] = React.useState<string>()
return (
<Form
className="flex w-full max-w-xs flex-col items-start gap-4"
onSubmit={(event) => {
event.preventDefault()
setSubmitted(String(new FormData(event.currentTarget).get("shipping")))
}}
>
<Field name="shipping">
<FieldSet render={<RadioGroup required />}>
<FieldLegend variant="label">Shipping</FieldLegend>
{["Standard", "Express", "Overnight"].map((option) => (
<FieldItem key={option}>
<RadioGroupItem value={option.toLowerCase()} />
<FieldLabel>{option}</FieldLabel>
</FieldItem>
))}
</FieldSet>
<FieldError match="valueMissing">Choose a shipping speed.</FieldError>
</Field>
<Button type="submit" size="sm">
Continue
</Button>
<output className="text-sm text-muted-foreground">
{submitted ? `shipping=${submitted}` : "Nothing submitted yet."}
</output>
</Form>
)
}Disabled and read-only#
Disable a single option, or make the whole group readOnly to show a value that can't change.
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
export function RadioGroupStates() {
return (
<div className="flex flex-wrap gap-12 text-sm">
<RadioGroup aria-label="Theme" defaultValue="system" className="w-fit">
<label className="flex items-center gap-3">
<RadioGroupItem value="light" />
Light
</label>
<label className="flex items-center gap-3">
<RadioGroupItem value="dark" disabled />
Dark (coming soon)
</label>
<label className="flex items-center gap-3">
<RadioGroupItem value="system" />
System
</label>
</RadioGroup>
<RadioGroup
aria-label="Region"
defaultValue="eu"
readOnly
className="w-fit"
>
<label className="flex items-center gap-3">
<RadioGroupItem value="us" />
United States
</label>
<label className="flex items-center gap-3">
<RadioGroupItem value="eu" />
Europe (locked)
</label>
</RadioGroup>
</div>
)
}Controlled#
Pass value and onValueChange. Outside changes animate the same way as clicks.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const speeds = ["0.5×", "1×", "1.5×", "2×"]
export function RadioGroupControlled() {
const [speed, setSpeed] = React.useState("1×")
return (
<div className="flex flex-col items-start gap-4 text-sm">
<RadioGroup
aria-label="Playback speed"
value={speed}
onValueChange={setSpeed}
className="flex w-fit flex-wrap"
>
{speeds.map((value) => (
<label key={value} className="flex items-center gap-2 tabular-nums">
<RadioGroupItem value={value} />
{value}
</label>
))}
</RadioGroup>
<Button
variant="outline"
size="sm"
onClick={() =>
setSpeed(speeds[(speeds.indexOf(speed) + 1) % speeds.length])
}
>
Next speed
</Button>
</div>
)
}Right to left#
Radios and cards follow the reading direction.
import {
RadioGroup,
RadioGroupCard,
RadioGroupCardDescription,
RadioGroupCardTitle,
} from "@/components/ui/radio-group"
export function RadioGroupRtl() {
return (
<div dir="rtl" className="w-full max-w-sm">
<RadioGroup variant="card" aria-label="طريقة الدفع" defaultValue="card">
<RadioGroupCard value="card">
<RadioGroupCardTitle>بطاقة ائتمان</RadioGroupCardTitle>
<RadioGroupCardDescription>
فيزا أو ماستركارد.
</RadioGroupCardDescription>
</RadioGroupCard>
<RadioGroupCard value="cash">
<RadioGroupCardTitle>الدفع عند الاستلام</RadioGroupCardTitle>
<RadioGroupCardDescription>
ادفع نقدًا عند التوصيل.
</RadioGroupCardDescription>
</RadioGroupCard>
</RadioGroup>
</div>
)
}| Key | Action |
|---|---|
| Tab | Moves focus into the group, onto the picked option or the first one. |
| ↑↓←→ | Moves to the previous or next option and picks it, wrapping at the ends. |
| Space | Picks the focused option. |
- Name the group with
aria-label,aria-labelledbyor aFieldLegend. - Each radio has a 36px hit area, and 44px on touch screens, while staying 16px on screen.
- The dot and card ring don't move with reduced motion.
| Prop | Type | Default |
|---|---|---|
variantcard adds the sliding selection ring. | "default" | "card" | "default" |
value | Value | – |
defaultValue | Value | – |
onValueChange | (value, details) => void | – |
name | string | – |
required | boolean | false |
disabled | boolean | false |
readOnly | boolean | false |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="radio-group" | The group, with data-variant. |
data-slot="radio-group-highlight" | The ring that slides between cards. |
data-disabled | The group is disabled. |
| Prop | Type | Default |
|---|---|---|
value | Value | – |
disabled | boolean | false |
render | ReactElement | (props, state) => ReactElement | <span> |
| Attribute | Description |
|---|---|
data-slot="radio-group-item" | The radio. |
data-checked / data-unchecked | Whether it's the picked option. |
data-disabled / data-readonly | Present when it can't be changed. |
| Prop | Type | Default |
|---|---|---|
value | Value | – |
disabled | boolean | false |
radioClassNameClasses for the radio inside the card. | string | – |
A <label> with the radio built in. Put RadioGroupCardTitle and RadioGroupCardDescription, or anything else, inside.