Customize
Usage
import { Accordion } from "@/components/motionkit/accordion";
<Accordion
items={[
{ id: "free", title: "Is it free?", content: "Yes, for any project." },
{ id: "deps", title: "What do I need?", content: "React, Tailwind and motion." },
]}
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Panel height
Header clicked
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/accordion.tsx.
"use client";
import { useId, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type AccordionItem = {
id: string;
title: string;
content: ReactNode;
};
type AccordionProps = {
items: AccordionItem[];
/** Let several panels stay open at once. */
multiple?: boolean;
/** Ids open on first render. */
defaultOpen?: string[];
className?: string;
};
export function Accordion({ items, multiple = false, defaultOpen = [], className = "" }: AccordionProps) {
const [open, setOpen] = useState<string[]>(defaultOpen);
const reduceMotion = useReducedMotion();
const base = useId();
function toggle(id: string) {
setOpen((current) =>
current.includes(id) ? current.filter((value) => value !== id) : multiple ? [...current, id] : [id],
);
}
return (
<div className={`divide-y divide-white/10 rounded-2xl border border-white/10 bg-[#141B1B] text-[#F6F1E8] ${className}`}>
{items.map((item) => {
const isOpen = open.includes(item.id);
const buttonId = `${base}-${item.id}-button`;
const panelId = `${base}-${item.id}-panel`;
return (
<div key={item.id}>
<h3>
<button
id={buttonId}
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => toggle(item.id)}
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left text-sm font-medium transition-colors hover:bg-white/[0.02] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#5FAEB4]"
>
{item.title}
<motion.svg
aria-hidden
viewBox="0 0 24 24"
className="size-4 shrink-0 text-[#94A9A7]"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
animate={{ rotate: isOpen ? 180 : 0 }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", bounce: 0.2, duration: 0.35 }}
>
<path d="m6 9 6 6 6-6" />
</motion.svg>
</button>
</h3>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
id={panelId}
role="region"
aria-labelledby={buttonId}
className="overflow-hidden"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", bounce: 0, duration: 0.35 }}
>
<div className="px-5 pb-5 text-sm leading-6 text-[#94A9A7]">{item.content}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { id, title, content }[] | — | The panels. |
| multiple | boolean | false | Let several panels stay open at once. |
| defaultOpen | string[] | [] | Ids open on first render. |
Marquee
A seamless infinite scroller for logos and testimonials that pauses on hover.
Acme Supplies
Workshop tools, delivered next day across the UK.
Shop the rangeCompare Slider
Drag a handle across two layers to compare before and after, smoothly sprung.
Discover
Design
Build
Motion Path
A line that draws itself through your steps when the section scrolls into view, with a dot of light travelling along it.