Smooth accordion
FAQ rows open and close with a smooth height animation while the plus icon turns into a minus.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useState } from "react";
import styles from "./demo.module.css";
const faqs = [
{ q: "When is check-in?", a: "From 15:00. If the night before is free we will offer you early check-in from 11:00." },
{ q: "Is breakfast included?", a: "Yes, Rosa serves breakfast on the terrace every morning between 8:00 and 10:30." },
{ q: "Can I bring my dog?", a: "Dogs are welcome in Villa Azul and Casa Mar. Let us know when you book so we can add a bed." },
{ q: "How do refunds work?", a: "Cancel up to 14 days before arrival for a full refund. After that, the first night is kept." },
];
export default function Demo() {
const [open, setOpen] = useState<number | null>(0);
return (
<div className={styles.root}>
<div className={styles.list}>
{faqs.map((f, i) => {
const expanded = open === i;
return (
<div key={f.q} className={`${styles.item} ${expanded ? styles.open : ""}`}>
<h3 className={styles.heading}>
<button
type="button"
id={`faq-q-${i}`}
aria-expanded={expanded}
aria-controls={`faq-a-${i}`}
className={styles.trigger}
onClick={() => setOpen(expanded ? null : i)}
>
{f.q}
<span className={styles.icon} aria-hidden="true" />
</button>
</h3>
<div id={`faq-a-${i}`} role="region" aria-labelledby={`faq-q-${i}`} className={styles.panel} inert={!expanded}>
<div className={styles.clip}>
<p>{f.a}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
demo.module.css
.root { width: 100%; height: 100%; display: grid; place-items: safe center; padding: 16px; overflow-y: auto; }
.list { width: min(520px, 100%); border-top: 1px solid var(--line); }
.item { border-bottom: 1px solid var(--line); }
.heading { margin: 0; font-size: inherit; }
.trigger { width: 100%; min-height: 48px; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 0 2px; border: 0; background: none; text-align: left; font-size: 15px; font-weight: 600; cursor: pointer; }
.trigger:focus-visible { outline-offset: -2px; border-radius: 6px; }
/* Plus: two bars. The vertical one turns flat to make a minus. */
.icon { position: relative; flex: none; width: 24px; height: 24px; border-radius: 50%; background: var(--white); border: 1px solid var(--line); transition: background-color .25s, border-color .25s; }
.icon::before, .icon::after { content: ""; position: absolute; left: 6px; top: 10px; width: 10px; height: 2px; border-radius: 1px; background: var(--ink); transition: rotate .35s var(--out), background-color .25s; }
.icon::after { rotate: 90deg; }
.trigger:hover .icon { background: var(--accent-pale); }
.open .icon { background: var(--ink); border-color: var(--ink); }
.open .icon::before, .open .icon::after { background: var(--white); }
.open .icon::after { rotate: 180deg; }
/* Height animation without measuring: a grid row from 0fr to 1fr. */
.panel { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .4s var(--out); }
.open .panel { grid-template-rows: 1fr; }
.clip { overflow: hidden; min-height: 0; }
.clip p { padding: 0 40px 14px 2px; font-size: 14px; color: var(--muted); opacity: 0; transition: opacity .25s; }
.open .clip p { opacity: 1; transition: opacity .3s .1s; }
@media (prefers-reduced-motion: reduce) { .panel, .icon::after, .clip p { transition: none; } }
Use it for
- FAQs and policy details on a booking or landing page
- Settings groups where only one section matters at a time
Avoid
- Content most visitors need, which should simply be shown
- Nesting accordions inside accordions
How it works
Each answer sits in a wrapper with display: grid and grid-template-rows: 0fr; opening switches it to 1fr and the row transitions, while an inner element with overflow: hidden and min-height: 0 clips the text. No height is ever measured in JS. The icon is two bars; the vertical one rotates a further 90 degrees to lie flat, turning the plus into a minus. One item is open at a time.
Accessibility
Each question is a button inside a heading with aria-expanded and aria-controls, and each answer is a labelled region. Closed answers are inert so their content is not reachable by Tab. Reduced motion opens and closes instantly.