UIHigh-End Animated Cardsno dependencies

AccordionCards

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 props
0
260
10
3.2

Installation

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

npx motionfoundry add accordion-cards

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

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

Props

PropTypeDefault
items

Panel titles, left to right.

string[]['Design', 'Develop', 'Deploy', 'Scale']
descriptions

One-line description per panel, shown when expanded.

string[]four sample lines
defaultIndex

Initially expanded panel (uncontrolled).

number0
index

Controlled expanded panel.

numberundefined
onIndexChange

Called when the expanded panel changes.

(index: number) => voidundefined
expandOn

Expand on hover/focus, or only on click/Enter.

'hover' | 'click''hover'
accentColor

Fill color of the expanded panel.

string'#ff8a1f'
activeTextColor

Text color on the expanded panel.

string'#140b02'
height

Height in pixels.

number260
gap

Gap between panels in pixels.

number10
expandRatio

How much wider the expanded panel grows.

number3.2
className

Merged onto the root.

string''
style

Merged onto the root.

React.CSSPropertiesundefined

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

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