Skip to content

Plan-style cards where one selection ring slides to whichever you choose.

Selectionmotion@base-ui/react

01Preview

Choose a plan

Change or cancel any time

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/react

03Usage

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

PropTypeDefaultDescription
valuestringControlled selected card.
defaultValuestringUncontrolled initial card.
onValueChange(value: string) => voidCalled when the selection changes, by pointer or arrow key.
namestringForm field name, submitted with the selected value.
requiredbooleanfalseA card must be chosen before the form submits.
disabledbooleanfalseDisables every card: dimmed, no hover or press, skipped by arrow keys.
classNamestringThe group is a one-column grid with 8px gaps; add grid-cols-* for side-by-side cards.

RadioCard

PropTypeDefaultDescription
value*stringThe value this card selects.
title*ReactNodeThe card's name and accessible label. Truncates on one line.
descriptionReactNodeWraps under the title; linked as the description.
badgeReactNodeSmall pill beside the title.
asideReactNodeTrailing content, usually a price. Never wraps under the title.
disabledbooleanfalseDims 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.