Plan-style cards where one selection ring slides to whichever you choose.
01Preview
Choose a plan
Change or cancel any time
HobbySide projects and prototypes$0$0 /mofree forever
ProMost popularTeams shipping to production every day$20$20 /moper seat
ScaleSSO, audit logs and a 99.99% uptime SLA$48$48 /moper seat
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion @base-ui/react03Usage
import { RadioCard, RadioCardGroup } from "@/components/ui/radio-card";
<RadioCardGroup aria-label="Plan" name="plan" defaultValue="pro">
<RadioCard value="hobby" title="Hobby" description="Side projects" aside="$0" />
<RadioCard value="pro" title="Pro" badge="Most popular" description="Teams shipping daily" aside="$20" />
</RadioCardGroup>04Source
"use client";
import { Radio } from "@base-ui/react/radio";
import { RadioGroup } from "@base-ui/react/radio-group";
import { motion, useReducedMotion } from "motion/react";
import { createContext, use, useId } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
const CardsContext = createContext<{ value: string; ring: string }>({ value: "", ring: "" });
export type RadioCardGroupProps = Omit<RadioGroup.Props<string>, "className" | "value" | "defaultValue" | "onValueChange"> & {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
className?: string;
};
export function RadioCardGroup({ value: valueProp, defaultValue = "", onValueChange, className, ...rest }: RadioCardGroupProps) {
const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });
// One selection ring for the whole group; it travels to whichever card is chosen.
const ring = useId();
return (
<CardsContext value={{ value, ring }}>
<RadioGroup
value={value}
onValueChange={(next) => setValue(next as string)}
className={cn("grid gap-2", className)}
{...rest}
/>
</CardsContext>
);
}
export type RadioCardProps = Omit<Radio.Root.Props<string>, "className" | "children" | "render" | "title"> & {
value: string;
title: React.ReactNode;
description?: React.ReactNode;
/** Small pill beside the title, like “Most popular”. */
badge?: React.ReactNode;
/** Trailing content, usually the price. Right-aligned and never wrapped under the title. */
aside?: React.ReactNode;
className?: string;
};
export function RadioCard({ value, title, description, badge, aside, disabled, className, ...rest }: RadioCardProps) {
const { value: selected, ring } = use(CardsContext);
const reduce = useReducedMotion();
const checked = selected === value;
const titleId = useId();
const descriptionId = useId();
return (
<Radio.Root
value={value}
disabled={disabled}
render={<div />}
aria-labelledby={titleId}
aria-describedby={description != null ? descriptionId : undefined}
// Read disabled and read-only from the primitive so a disabled group styles every card.
className={(state) =>
cn(
"group/card relative flex min-w-0 select-none items-start gap-3 rounded-xl border p-3.5 text-left outline-none",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"transition-[background-color,border-color,scale] duration-150 ease-out-expo",
checked ? "border-transparent bg-hover" : "border-line-2 bg-raised",
state.disabled
? "cursor-not-allowed opacity-50"
: !state.readOnly && cn("motion-safe:active:scale-[0.99] active:duration-100", !checked && "hover:border-fg-4 hover:[&_[data-ring]]:border-fg-3"),
className,
)
}
{...rest}
>
{checked && (
<motion.span
layoutId={ring}
aria-hidden
className="pointer-events-none absolute -inset-px border border-fg"
// Radius in style so Motion corrects it while the ring stretches between cards of different heights.
style={{ borderRadius: 12 }}
transition={reduce ? { duration: 0 } : spring.snappy}
/>
)}
<span className="flex h-5 shrink-0 items-center">
<span
aria-hidden
data-ring
className={cn(
"grid size-4 place-items-center rounded-full border transition-[background-color,border-color] duration-150",
checked ? "border-fg bg-fg" : "border-fg-4 bg-raised",
)}
>
<motion.span
className="block size-1.5 rounded-full bg-frame"
initial={false}
animate={checked ? { scale: 1, opacity: 1 } : { scale: 0.2, opacity: 0 }}
transition={
reduce
? { opacity: { duration: 0.12 }, scale: { duration: 0 } }
: checked
? { ...spring.pop, opacity: { duration: 0.08 } }
: { duration: 0.1, ease: ease.in }
}
/>
</span>
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex min-w-0 items-center gap-1.5">
<span id={titleId} className="truncate text-[13px] font-medium leading-5 tracking-[-0.005em] text-fg">
{title}
</span>
{badge != null && (
<span className="shrink-0 rounded-full border border-line-2 px-1.5 text-[10.5px] leading-4 text-fg-2">{badge}</span>
)}
</span>
{description != null && (
<span id={descriptionId} className="text-[12.5px] leading-[18px] text-fg-3">
{description}
</span>
)}
</span>
{aside != null && <span className="shrink-0 text-right">{aside}</span>}
</Radio.Root>
);
}05Props
RadioCardGroup
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | Controlled selected card. |
| defaultValue | string | — | Uncontrolled initial card. |
| onValueChange | (value: string) => void | — | Called when the selection changes, by pointer or arrow key. |
| name | string | — | Form field name, submitted with the selected value. |
| required | boolean | false | A card must be chosen before the form submits. |
| disabled | boolean | false | Disables every card: dimmed, no hover or press, skipped by arrow keys. |
| className | string | — | The group is a one-column grid with 8px gaps; add grid-cols-* for side-by-side cards. |
RadioCard
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | — | The value this card selects. |
| title* | ReactNode | — | The card's name and accessible label. Truncates on one line. |
| description | ReactNode | — | Wraps under the title; linked as the description. |
| badge | ReactNode | — | Small pill beside the title. |
| aside | ReactNode | — | Trailing content, usually a price. Never wraps under the title. |
| disabled | boolean | false | Dims the card and skips it during arrow navigation. |
06Notes
Behavior
- The ring is one element shared by the group, so changing plans reads as the selection moving, not one card fading out while another fades in.
- Arrow keys move and select in one step and skip disabled cards; the selected card is the group's single tab stop.
- The trailing aside never shrinks, so prices stay aligned while descriptions wrap beside them.
Motion
- The ring travels between cards with a layoutId animation on the snappy spring (520/38), about 220ms, and reverses cleanly if you change your mind mid-flight. Its 12px radius is corrected while it stretches between cards of different heights.
- The radio dot springs from 0.2 on the pop spring (600/30), the wash fades in over 150ms, and the card presses to 0.99 in 100ms.
- Reduced motion moves the ring instantly, keeps a 120ms fade on the dot and drops the press.
Accessibility
- role=radiogroup with each card as role=radio, labeled by its title and described by its description.
- Space selects the focused card; the focus ring sits 2px outside the card, clear of the selection ring.
- Selection is shown by the filled radio as well as the ring, so it doesn't rely on a border alone.