Radio Group

A set of checkable buttons, known as radio buttons, where no more than one of the buttons can be checked at a time.

"use client"

import { cn } from "cn"
import {
  composeRenderProps,
  RadioGroup as RadioGroupPrimitive,
  Radio as RadioPrimitive,
  type RadioGroupProps,
  type RadioProps,
} from "@animate-ui/react/primitives/react-aria"
import { Label } from "@/components/ui/label"

function RadioGroup({ className, ...props }: RadioGroupProps) {
  return (
    <RadioGroupPrimitive
      data-slot="radio-group"
      className={cn("grid gap-2 w-full", className)}
      {...props}
    />
  )
}

function RadioGroupItem({ className, children, ...props }: RadioProps) {
  return (
    <RadioPrimitive
      data-slot="radio-group-item"
      className={cn(
        "border-input dark:bg-input/30 data-checked:text-primary-foreground aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 flex size-4 rounded-full focus-visible:ring-3 aria-invalid:ring-3 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input group-has-[:focus-visible]/field-label:data-checked:border-primary data-selected:text-primary-foreground data-focus-visible:border-ring data-focus-visible:ring-ring/50 data-focus-visible:ring-3 data-invalid:border-destructive data-invalid:ring-destructive/20 dark:data-invalid:ring-destructive/40 dark:data-invalid:border-destructive/50 data-invalid:ring-3 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 before:absolute before:-inset-px before:rounded-[inherit] before:bg-primary before:opacity-0 before:scale-70 before:transition-[scale,opacity] before:duration-300 motion-reduce:before:scale-100 data-selected:before:opacity-100 data-selected:before:scale-100 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
        className
      )}
      {...props}
    >
      {composeRenderProps(children, (children, { isSelected }) => (
        <>
          <span
            data-slot="radio-group-indicator"
            className="flex size-4 items-center justify-center"
          >
            {isSelected && <span className="bg-primary-foreground absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full" />}
          </span>
          {children}
        </>
      ))}
    </RadioPrimitive>
  )
}

export { RadioGroup, RadioGroupItem }

export default function RadioGroupDemo() {
  return (
    <RadioGroup
      aria-label="Density"
      defaultValue="comfortable"
      className="w-fit"
    >
      <div className="flex items-center gap-3">
        <RadioGroupItem value="default" id="r1" />
        <Label htmlFor="r1">Default</Label>
      </div>
      <div className="flex items-center gap-3">
        <RadioGroupItem value="comfortable" id="r2" />
        <Label htmlFor="r2">Comfortable</Label>
      </div>
      <div className="flex items-center gap-3">
        <RadioGroupItem value="compact" id="r3" />
        <Label htmlFor="r3">Compact</Label>
      </div>
    </RadioGroup>
  )
}

Installation

Install the packages

npm install @animate-ui/react motion
pnpm add @animate-ui/react motion
yarn add @animate-ui/react motion
bun add @animate-ui/react motion

Add the shadcn/ui component

npx shadcn@latest add radio-group
pnpm dlx shadcn@latest add radio-group
yarn dlx shadcn@latest add radio-group
bun x shadcn@latest add radio-group

Animate it

The CLI finds your shadcn/ui components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.

npx @animate-ui/cli@latest animate radio-group
pnpm dlx @animate-ui/cli@latest animate radio-group
yarn dlx @animate-ui/cli@latest animate radio-group
bun x @animate-ui/cli@latest animate radio-group

Usage

Import from Animate UI instead of react-aria-components: it re-exports all of React Aria, with the animated controls below. Everything else stays the same.

import {
  Label,
  Radio,
  RadioGroup,
} from '@animate-ui/react/primitives/react-aria';

export function Density() {
  return (
    <RadioGroup defaultValue="comfortable">
      <Label>Density</Label>
      {['default', 'comfortable', 'compact'].map((value) => (
        <Radio key={value} value={value}>
          {({ isSelected }) => (
            <>
              <span data-slot="radio-group-indicator">
                {isSelected && (
                  <span className="size-2 rounded-full bg-current" />
                )}
              </span>
              {value}
            </>
          )}
        </Radio>
      ))}
    </RadioGroup>
  );
}

React Aria lets you render the indicator yourself. Animate UI animates the content of the element marked data-slot="radio-group-indicator", which is what shadcn/ui renders: add the attribute to your own indicator.

Animation

The motion of Animate UI v1's Radix UI radio group (v1 had no React Aria one):

  • Radio: the dot you render inside data-slot="radio-group-indicator" pops in when selecting its radio mounts it. Its scale and its opacity spring from 0 to 1 (stiffness 200, damping 16: it overshoots a little before settling). When it unmounts, a copy of it springs back to 0 in its place, then goes away: your render function stays the single source of truth.
  • Radio scales to 0.97 while pressed (React Aria's isPressed), with no hover scale, on Motion's default spring for scale (stiffness 550, damping 30).
  • In the demo, a radio's fill is a layer of its own (before: in its classes) that grows from 70% while it fades in, in 300ms, and shrinks back as it fades out, like the Checkbox's: the dot pops in over it. The layer covers the border (before:-inset-px) and the border keeps its color, so no outline rings the growing fill. It's CSS, so copy the classes for the same effect; under reduced motion the layer only fades.
  • Selecting with the arrow keys animates like a click.
  • A radio already selected when it appears (on load, in a dialog that opens) renders at rest: only a change of selection animates a dot. On load, the dot is your markup, rendered by the server before any script runs.
  • Under reduced motion, the dot only fades and nothing scales.
  • className and style render props keep working.

API Reference

RadioGroup and Radio take exactly the props of React Aria's RadioGroup. A render function of your own gets React Aria's behaviour, without the press scale.

RadioGroup

React Aria props only: nothing animates on the group itself.

Radio

React Aria props, plus:

PropTypeDefault
transition?
Transition{ type: 'spring', stiffness: 200, damping: 16 }
exitTransition?
Transitionthe same spring as transition

The press scale uses Motion's default spring for scale (stiffness 550, damping 30). AnimateUIProvider's tempo scales these transitions with everything else.

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/18/2026

On this page