UIForms and Inputsmotion/react

RadioCards

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

Choose a plan

Customize

7 props
3

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add radio-cards

Writes 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.

example.tsx
1import { RadioCards } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <RadioCards />6  );7}

Props

PropTypeDefault
options

Options rendered as cards.

{ value: string; label: string; description?: string; meta?: string; disabled?: boolean }[]Starter / Pro / Team plans
value

Controlled selected value.

stringundefined
defaultValue

Initial selected value when uncontrolled (falls back to the second, then first option).

stringsecond option
onValueChange

Called with the newly selected value.

(value: string) => voidundefined
label

Visible group label, used as the radiogroup's accessible name.

string'Choose a plan'
columns

Number of grid columns (1-4); collapses to one column on narrow screens.

number3
size

Card padding and type size preset.

'sm' | 'md' | 'lg''md'
accentColor

Color of the highlight border, tint and check indicator.

string'#6366f1'
showDescriptions

Show the description line of each option.

booleantrue
slidingHighlight

Spring the highlight border between cards; when false it fades in place.

booleantrue
disabled

Disable the whole group.

booleanfalse
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/radio-cards.tsx
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}