A row of numbered panels where the hovered or focused one expands, fills with the accent color, and reveals its title and description while the others narrow.
Preview
Customize
8 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add accordion-cardsWrites components/ui/accordion-cards.tsx and components/styles/accordion-cards.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { AccordionCards } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <AccordionCards />6 );7}Props
Accessibility
Each panel is a native button with aria-expanded and aria-controls pointing at its content; collapsed panels keep their title as visually hidden text. Focus expands a panel just like hover (or use expandOn="click"), and Arrow keys, Home and End move between panels. Reduced motion removes the width and fade transitions.
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 styles from "../styles/accordion-cards.module.css";5 6export interface AccordionCardsProps {7 /** Panel titles, left to right */8 items?: string[];9 /** One-line description per panel (shown when expanded) */10 descriptions?: string[];11 /** Initially expanded panel index */12 defaultIndex?: number;13 /** Controlled expanded panel index */14 index?: number;15 /** Called when the expanded panel changes */16 onIndexChange?: (index: number) => void;17 /** Expand on hover/focus, or only on click/Enter */18 expandOn?: "hover" | "click";19 /** Fill color of the expanded panel */20 accentColor?: string;21 /** Text color on the expanded panel */22 activeTextColor?: string;23 /** Height in pixels */24 height?: number;25 /** Gap between panels in pixels */26 gap?: number;27 /** How much wider the expanded panel grows */28 expandRatio?: number;29 /** Optional className merged onto the root */30 className?: string;31 /** Optional inline styles merged onto the root */32 style?: React.CSSProperties;33}34 35const DEFAULT_ITEMS = ["Design", "Develop", "Deploy", "Scale"];36const DEFAULT_DESCRIPTIONS = ["Shape the idea into flows and screens.", "Build it with typed, tested components.", "Ship to the edge in one command.", "Grow without rewriting the foundation."];37 38export function AccordionCards({39 items = DEFAULT_ITEMS,40 descriptions = DEFAULT_DESCRIPTIONS,41 defaultIndex = 0,42 index,43 onIndexChange,44 expandOn = "hover",45 accentColor = "#ff8a1f",46 activeTextColor = "#140b02",47 height = 260,48 gap = 10,49 expandRatio = 3.2,50 className = "",51 style,52}: AccordionCardsProps) {53 const list = items.length ? items : DEFAULT_ITEMS;54 const [indexState, setIndexState] = React.useState(Math.min(Math.max(defaultIndex, 0), list.length - 1));55 const active = index ?? indexState;56 const buttons = React.useRef<(HTMLButtonElement | null)[]>([]);57 const baseId = React.useId();58 59 const activate = (i: number) => {60 if (i === active) return;61 if (index === undefined) setIndexState(i);62 onIndexChange?.(i);63 };64 65 const onKeyDown = (e: React.KeyboardEvent, i: number) => {66 const map: Record<string, number> = { ArrowRight: i + 1, ArrowDown: i + 1, ArrowLeft: i - 1, ArrowUp: i - 1, Home: 0, End: list.length - 1 };67 if (!(e.key in map)) return;68 e.preventDefault();69 const next = (map[e.key] + list.length) % list.length;70 buttons.current[next]?.focus();71 activate(next);72 };73 74 return (75 <div76 role="group"77 aria-label="Expandable panels"78 className={`${styles.root} ${className}`.trim()}79 style={80 {81 "--mf-ac-height": `${height}px`,82 "--mf-ac-gap": `${gap}px`,83 "--mf-ac-accent": accentColor,84 "--mf-ac-on": activeTextColor,85 "--mf-ac-grow": expandRatio,86 ...style,87 } as React.CSSProperties88 }89 >90 {list.map((title, i) => {91 const isActive = i === active;92 const panelId = `${baseId}-panel-${i}`;93 return (94 <button95 key={`${title}-${i}`}96 ref={(el) => {97 buttons.current[i] = el;98 }}99 type="button"100 className={styles.panel}101 data-active={isActive || undefined}102 aria-expanded={isActive}103 aria-controls={panelId}104 onPointerEnter={() => expandOn === "hover" && activate(i)}105 onFocus={() => expandOn === "hover" && activate(i)}106 onClick={() => activate(i)}107 onKeyDown={(e) => onKeyDown(e, i)}108 >109 <span className={styles.index}>{String(i + 1).padStart(2, "0")}</span>110 <span id={panelId} className={styles.content} aria-hidden={!isActive}>111 <span className={styles.title}>{title}</span>112 {descriptions[i] && <span className={styles.description}>{descriptions[i]}</span>}113 </span>114 {!isActive && <span className={styles.srOnly}>{title}</span>}115 </button>116 );117 })}118 </div>119 );120}