Feedback & Data
AccordionMorph
Each row is a pill that morphs into a tinted card as it opens, while its plus folds into a minus. Arrow keys, Home and End move between rows.
Download
- React
- Tailwind CSS v4
- motion
components/ui/accordion-morph.tsx
"use client";
import { useId, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type AccordionMorphProps = {
/** Rows, top to bottom. */
items: { title: string; content: ReactNode }[];
/** Let more than one row stay open. */
multiple?: boolean;
/** Row numbers open at the start, from 0. */
defaultOpen?: number[];
/** Open row tint and icon colour. */
accent?: string;
/** Replaces the row look. Keep corners out of it, they are the morph. */
className?: string;
};
/* AccordionMorph: each row is a pill that morphs into a tinted card as it opens, while its plus folds into a minus. */
export function AccordionMorph({ items, multiple = false, defaultOpen = [], accent = "#a78bfa", className = "border border-white/10 bg-white/5 text-white" }: AccordionMorphProps) {
const reduce = useReducedMotion();
const id = useId();
const root = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(defaultOpen);
const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 320, damping: 32 };
const toggle = (i: number) => setOpen((now) => (now.includes(i) ? now.filter((row) => row !== i) : multiple ? [...now, i] : [i]));
const onKey = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
const last = items.length - 1;
const moves: Record<string, number> = { ArrowDown: i === last ? 0 : i + 1, ArrowUp: i === 0 ? last : i - 1, Home: 0, End: last };
if (!(e.key in moves)) return;
e.preventDefault();
root.current?.querySelectorAll<HTMLButtonElement>("[data-accordion-row]")[moves[e.key]]?.focus();
};
return (
<div ref={root} style={{ "--am": accent } as CSSProperties} className="grid w-full gap-2">
{items.map((item, i) => {
const isOpen = open.includes(i);
return (
<motion.div key={item.title} className={`relative overflow-hidden ${className}`} initial={false} animate={{ borderRadius: isOpen ? 18 : 26 }} transition={spring}>
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] border border-(--am)/40 bg-(--am)/10"
initial={false}
animate={{ opacity: isOpen ? 1 : 0 }}
transition={{ duration: reduce ? 0 : 0.25 }}
/>
<h3 className="relative">
<button
id={`${id}-row-${i}`}
type="button"
data-accordion-row
aria-expanded={isOpen}
aria-controls={isOpen ? `${id}-panel-${i}` : undefined}
onClick={() => toggle(i)}
onKeyDown={(e) => onKey(e, i)}
className="flex w-full items-center justify-between gap-4 rounded-[inherit] px-5 py-3 text-left text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-inset"
>
{item.title}
<motion.span aria-hidden className="relative size-3.5 shrink-0 text-(--am)" initial={false} animate={{ rotate: isOpen ? 180 : 0 }} transition={spring}>
<span className="absolute inset-x-0 top-1/2 -mt-px h-0.5 rounded-full bg-current" />
<motion.span className="absolute inset-y-0 left-1/2 -ml-px w-0.5 rounded-full bg-current" initial={false} animate={{ scaleY: isOpen ? 0 : 1 }} transition={spring} />
</motion.span>
</button>
</h3>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
id={`${id}-panel-${i}`}
role="region"
aria-labelledby={`${id}-row-${i}`}
className="relative overflow-hidden"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={spring}
>
<div className="px-5 pb-4 text-sm leading-relaxed opacity-70">{item.content}</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/accordion-morph.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/accordion-morph-demo.tsx
import { AccordionMorph } from "@/components/ui/accordion-morph"; const FAQ = [ { title: "Can I use this in client work?", content: "Yes. Copy it into any project, paid or not." }, { title: "Does it need a build step?", content: "Only what your React app already has, plus Tailwind and motion." }, { title: "What about keyboard users?", content: "Tab reaches the first row, arrows move between rows, Enter opens." }, ]; export default function AccordionMorphExample() { return ( <div className="grid h-full place-items-center overflow-y-auto bg-[#0b0a14] p-4"> <div className="w-full max-w-md"> <AccordionMorph items={FAQ} defaultOpen={[0]} /> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { title, content }[] | — | Rows, top to bottom. Titles must be different. |
| multiple | boolean | false | Let more than one row stay open. |
| defaultOpen | number[] | [] | Row numbers open at the start, from 0. |
| accent | string | "#a78bfa" | Open row tint and icon colour. |
| className | string | glass row | Replaces the row look. Keep corners out of it, they are the morph. |