A single-choice list of cards with sliding, fading, scaling, or instant selection and adjustable timing.
pnpm dlx shadcn@latest add @latheui/radio-groupFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add radix-ui motionAdd the Lathe items this component imports.
lib/motion.tshooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { RadioGroup as Primitive } from "radix-ui"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { ease, spring, useMotionSafe } from "@/lib/motion"
export type RadioOption = {
value: string
label: string
description?: string
disabled?: boolean
}
export type RadioGroupProps = Omit<
React.ComponentProps<typeof Primitive.Root>,
"value" | "defaultValue" | "onValueChange"
> & {
options: RadioOption[]
label: string
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
/** How the selected card is highlighted. */
animation?: "slide" | "fade" | "scale" | "none"
/** Slide duration in milliseconds. Omit to keep the default smooth spring. */
slideDuration?: number
/** Milliseconds for the highlight to appear in fade and scale modes. */
fadeInDuration?: number
/** Milliseconds for the highlight to disappear in fade and scale modes. */
fadeOutDuration?: number
/** Milliseconds for the radio dot and its circle's color to change. */
dotDuration?: number
}
export function RadioGroup({
options,
label,
value: valueProp,
defaultValue = "",
onValueChange,
animation = "slide",
slideDuration,
fadeInDuration = 200,
fadeOutDuration = 200,
dotDuration = 200,
className,
...props
}: RadioGroupProps) {
const id = React.useId()
const [value, setValue] = useControllableState({
value: valueProp,
defaultValue,
onChange: onValueChange,
})
const { safe } = useMotionSafe()
const instant = animation === "none"
const dotTiming = { transitionDuration: `${instant ? 0 : Math.max(0, dotDuration)}ms` }
const slideTransition = safe(
slideDuration === undefined
? spring.smooth
: { type: "tween", duration: Math.max(0, slideDuration) / 1000, ease: ease.out }
)
return (
<Primitive.Root
data-slot="radio-group"
data-animation={animation}
aria-label={label}
value={value}
onValueChange={setValue}
className={cn("grid gap-2", className)}
{...props}
>
{options.map((option, i) => {
const checked = option.value === value
return (
<label
key={option.value}
htmlFor={id + i}
data-slot="radio-group-option"
data-state={checked ? "checked" : "unchecked"}
className={cn(
"relative isolate flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-card p-3 transition-colors duration-200 hover:bg-accent/30 motion-reduce:transition-none",
checked && "z-10",
option.disabled && "cursor-not-allowed opacity-50 hover:bg-card"
)}
>
{/* Sliding shares one highlight; fade and scale keep one on each card so both directions animate. */}
{animation === "slide" && checked && (
<motion.span
layoutId={`${id}-card`}
aria-hidden="true"
data-slot="radio-group-highlight"
transition={slideTransition}
className="pointer-events-none absolute -inset-px -z-10 rounded-lg border border-ring bg-accent/50"
/>
)}
{animation !== "slide" && (
<span
aria-hidden="true"
data-slot="radio-group-highlight"
className={cn(
"pointer-events-none absolute -inset-px -z-10 rounded-lg border border-ring bg-accent/50 ease-out motion-reduce:transition-none motion-reduce:duration-0!",
animation === "scale" ? "transition-[opacity,scale]" : "transition-opacity",
checked ? "opacity-100" : "opacity-0",
animation === "scale" && (checked ? "scale-100" : "scale-[0.96]")
)}
style={{ transitionDuration: `${instant ? 0 : Math.max(0, checked ? fadeInDuration : fadeOutDuration)}ms` }}
/>
)}
<Primitive.Item
id={id + i}
value={option.value}
disabled={option.disabled}
style={dotTiming}
className={cn(
"grid size-4 shrink-0 place-items-center rounded-full border bg-background outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none motion-reduce:duration-0!",
checked ? "border-primary" : "border-input"
)}
>
{/* The dot stays in its own circle, growing in on the new choice as it fades out of the old one. */}
<span
aria-hidden="true"
data-slot="radio-group-dot"
style={dotTiming}
className={cn(
"size-2 rounded-full bg-primary transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none motion-reduce:duration-0!",
checked ? "scale-100 opacity-100" : "scale-0 opacity-0"
)}
/>
</Primitive.Item>
<span>
<span className="block text-sm font-medium">{option.label}</span>
{option.description && (
<span className="block text-xs text-muted-foreground">
{option.description}
</span>
)}
</span>
</label>
)
})}
</Primitive.Root>
)
}Depends on
| Prop | Type | Default | Description |
|---|---|---|---|
| options* (required) | RadioOption[] | — | Labels, descriptions and disabled options. |
| label* (required) | string | — | Accessible group name. |
| value / defaultValue | string | — | Controlled or initial selection. |
| animation | "slide" | "fade" | "scale" | "none" | "slide" | How the selected card is highlighted. Scale fades and grows in place; none also disables the dot animation. |
| slideDuration | number | Smooth spring | Slide duration in milliseconds. When set, uses a timed ease-out instead of the default spring. Zero moves immediately. |
| fadeInDuration | number | 200 | Milliseconds for the new highlight to appear in fade and scale modes. Also controls the scale-in timing. |
| fadeOutDuration | number | 200 | Milliseconds for the old highlight to disappear in fade and scale modes. Also controls the scale-out timing. |
| dotDuration | number | 200 | Milliseconds for the radio dot's opacity and size, and its circle's border color. Independent of the highlight timing. |
| onValueChange | (value: string) => void | — | Selection callback. |
Use fade for a stationary highlight, scale for a subtle pop, or none for instant selection. Set slideDuration to adjust the sliding outline. Fade-in, fade-out, and dot durations are independent, in milliseconds.
<RadioGroup
label="Export quality"
defaultValue="balanced"
animation="fade"
fadeInDuration={300}
fadeOutDuration={150}
dotDuration={120}
options={[
{ value: "balanced", label: "Balanced" },
{ value: "original", label: "Original" },
]}
/>Combine the control with your own layout. Semantic colors follow the active theme.
import { RadioGroup } from "@/components/ui/radio-group"
export default function Demo(){return <RadioGroup className="w-full max-w-xs" label="Export quality" defaultValue="balanced" options={[{value:"balanced",label:"Balanced",description:"Small files, crisp details."},{value:"original",label:"Original",description:"Every detail, full resolution."}]}/>}