Selectable option cards that behave as a single radio group. The accent border springs from card to card and a check mark draws itself into the selected card's indicator.
Preview
Customize
7 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add radio-cardsWrites components/ui/radio-cards.tsx and components/styles/radio-cards.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { RadioCards } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <RadioCards />6 );7}Props
Accessibility
Implements the WAI-ARIA radio group pattern: role="radiogroup" labelled by the visible group label, each card is role="radio" with aria-checked and aria-describedby pointing to its description. Roving tabindex keeps a single Tab stop; Arrow keys move and select (wrapping, skipping disabled cards), Home/End jump to the ends, Space/Enter select. Disabled cards expose aria-disabled. Under prefers-reduced-motion the highlight and check change instantly.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { motion, useReducedMotion } from "motion/react";5import styles from "../styles/radio-cards.module.css";6 7export interface RadioCardOption {8 /** Unique value of the option */9 value: string;10 /** Main label */11 label: string;12 /** Secondary line under the label */13 description?: string;14 /** Short meta text shown on the right (e.g. a price) */15 meta?: string;16 /** Disable just this option */17 disabled?: boolean;18}19 20export interface RadioCardsProps {21 /** Options to render as cards */22 options?: RadioCardOption[];23 /** Controlled selected value */24 value?: string;25 /** Initial selected value when uncontrolled */26 defaultValue?: string;27 /** Called with the newly selected value */28 onValueChange?: (value: string) => void;29 /** Visible group label (accessible name of the radiogroup) */30 label?: string;31 /** Number of grid columns */32 columns?: number;33 /** Card size preset */34 size?: "sm" | "md" | "lg";35 /** Accent color for the highlight border and check */36 accentColor?: string;37 /** Show option descriptions */38 showDescriptions?: boolean;39 /** Slide the highlight border between cards instead of fading it */40 slidingHighlight?: boolean;41 /** Disable the whole group */42 disabled?: boolean;43 /** Optional className merged onto the root */44 className?: string;45 /** Optional inline styles merged onto the root */46 style?: React.CSSProperties;47}48 49const DEFAULT_OPTIONS: RadioCardOption[] = [50 { value: "starter", label: "Starter", description: "For side projects and prototypes.", meta: "$0/mo" },51 { value: "pro", label: "Pro", description: "Unlimited projects and priority builds.", meta: "$19/mo" },52 { value: "team", label: "Team", description: "Shared workspaces, roles and SSO.", meta: "$49/mo" },53];54 55export function RadioCards({56 options = DEFAULT_OPTIONS,57 value: valueProp,58 defaultValue,59 onValueChange,60 label = "Choose a plan",61 columns = 3,62 size = "md",63 accentColor = "#6366f1",64 showDescriptions = true,65 slidingHighlight = true,66 disabled = false,67 className = "",68 style,69}: RadioCardsProps) {70 const reduce = useReducedMotion();71 const groupId = React.useId();72 const [inner, setInner] = React.useState<string | undefined>(defaultValue ?? options[1]?.value ?? options[0]?.value);73 const controlled = valueProp !== undefined;74 const selected = controlled ? valueProp : inner;75 const refs = React.useRef<Array<HTMLDivElement | null>>([]);76 77 const enabled = options.map((o) => !disabled && !o.disabled);78 const selectedIndex = options.findIndex((o) => o.value === selected);79 const firstEnabled = enabled.indexOf(true);80 const tabStop = selectedIndex >= 0 && enabled[selectedIndex] ? selectedIndex : firstEnabled;81 82 const select = (i: number) => {83 const opt = options[i];84 if (!opt || !enabled[i]) return;85 if (!controlled) setInner(opt.value);86 if (opt.value !== selected) onValueChange?.(opt.value);87 };88 89 const move = (from: number, dir: 1 | -1 | "first" | "last") => {90 const n = options.length;91 let i = dir === "first" ? -1 : dir === "last" ? n : from;92 const step = dir === "first" ? 1 : dir === "last" ? -1 : dir;93 for (let k = 0; k < n; k++) {94 i = (i + step + n) % n;95 if (enabled[i]) {96 select(i);97 refs.current[i]?.focus();98 return;99 }100 }101 };102 103 const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>, i: number) => {104 const keys: Record<string, 1 | -1 | "first" | "last"> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1, Home: "first", End: "last" };105 if (e.key in keys) {106 e.preventDefault();107 move(i, keys[e.key]);108 } else if (e.key === " " || e.key === "Enter") {109 e.preventDefault();110 select(i);111 }112 };113 114 const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 500, damping: 38 };115 const cols = Math.max(1, Math.min(4, Math.round(columns)));116 117 return (118 <div119 className={`${styles.root} ${styles[size] ?? ""} ${className}`.trim()}120 style={{ "--mf-rc-accent": accentColor, "--mf-rc-cols": cols, ...style } as React.CSSProperties}121 >122 {label && (123 <div id={`${groupId}-label`} className={styles.groupLabel}>124 {label}125 </div>126 )}127 <div role="radiogroup" aria-labelledby={label ? `${groupId}-label` : undefined} aria-label={label ? undefined : "Options"} aria-disabled={disabled || undefined} className={styles.grid}>128 {options.map((opt, i) => {129 const checked = opt.value === selected;130 const descId = opt.description && showDescriptions ? `${groupId}-d${i}` : undefined;131 return (132 <div133 key={opt.value}134 ref={(el) => {135 refs.current[i] = el;136 }}137 role="radio"138 aria-checked={checked}139 aria-disabled={!enabled[i] || undefined}140 aria-describedby={descId}141 tabIndex={i === tabStop ? 0 : -1}142 data-state={checked ? "checked" : "unchecked"}143 className={styles.card}144 onClick={() => select(i)}145 onKeyDown={(e) => onKeyDown(e, i)}146 >147 {checked &&148 (slidingHighlight ? (149 <motion.span aria-hidden="true" layoutId={`${groupId}-ring`} className={styles.ring} transition={spring} />150 ) : (151 <motion.span aria-hidden="true" className={styles.ring} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: reduce ? 0 : 0.18 }} />152 ))}153 <span className={styles.head}>154 <span className={styles.title}>{opt.label}</span>155 <span className={styles.indicator} aria-hidden="true">156 <motion.svg viewBox="0 0 16 16" width="12" height="12" initial={false} animate={{ scale: checked ? 1 : 0.4, opacity: checked ? 1 : 0 }} transition={spring}>157 <motion.path158 d="M3.5 8.5l3 3 6-7"159 fill="none"160 stroke="currentColor"161 strokeWidth="2.2"162 strokeLinecap="round"163 strokeLinejoin="round"164 initial={false}165 animate={{ pathLength: checked ? 1 : 0 }}166 transition={reduce ? { duration: 0 } : { duration: 0.25, ease: "easeOut", delay: checked ? 0.05 : 0 }}167 />168 </motion.svg>169 </span>170 </span>171 {descId && (172 <span id={descId} className={styles.description}>173 {opt.description}174 </span>175 )}176 {opt.meta && <span className={styles.meta}>{opt.meta}</span>}177 </div>178 );179 })}180 </div>181 </div>182 );183}