Website Sections
FAQMorph
Questions on one side, one answer panel on the other. The marker slides to the question and the panel reshapes to fit. Narrow, it becomes an accordion.
Download
- React
- Tailwind CSS v4
- motion
components/ui/faq-morph.tsx
"use client";
import { useEffect, useId, useRef, useState, type ElementType, type KeyboardEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type FAQMorphItem = {
question: string;
answer: ReactNode;
};
type FAQMorphProps = {
items: FAQMorphItem[];
title?: string;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Which question starts open. */
defaultOpen?: number;
/** Colour of the marker and the answer panel. */
tile?: string;
/** Colour of the number in the answer panel. */
color?: string;
/** h2 by default. */
headingAs?: ElementType;
/** Set padding and colours here. */
className?: string;
};
const SPRING = { type: "spring", stiffness: 380, damping: 34 } as const;
/* FAQMorph: questions on one side, one answer panel on the other. The marker slides to the question and the panel reshapes to fit the answer. Narrow, it turns into a plain accordion. */
export function FAQMorph({
items,
title,
eyebrow,
subtitle,
defaultOpen = 0,
tile = "#15122a",
color = "#a78bfa",
headingAs: Heading = "h2",
className = "bg-[#0b0a14] px-5 py-16 text-white",
}: FAQMorphProps) {
const reduce = useReducedMotion();
const id = useId();
const side = useRef<HTMLDivElement>(null);
const inner = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState<number | null>(defaultOpen);
const [shown, setShown] = useState(defaultOpen);
const [height, setHeight] = useState<number>();
const answer = items[shown];
useEffect(() => {
if (!inner.current) return;
const watcher = new ResizeObserver(([entry]) => {
const next = entry.target.getBoundingClientRect().height;
if (next) setHeight(next);
});
watcher.observe(inner.current);
return () => watcher.disconnect();
}, []);
const pick = (i: number) => {
// With the side panel showing there is always one answer; only the narrow accordion can close.
if (open === i && !side.current?.offsetWidth) return setOpen(null);
setOpen(i);
setShown(i);
};
const step = (e: KeyboardEvent<HTMLUListElement>) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
const buttons = Array.from(e.currentTarget.querySelectorAll("button"));
const at = buttons.indexOf(e.target as HTMLButtonElement);
if (at < 0) return;
e.preventDefault();
buttons[(at + (e.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length].focus();
};
return (
<section aria-labelledby={title ? id : undefined} className={`@container ${className}`}>
{title && (
<header className="mx-auto mb-8 max-w-2xl text-center">
{eyebrow && <p className="mb-2 text-xs font-medium tracking-[0.2em] uppercase opacity-70">{eyebrow}</p>}
<Heading id={id} className="text-[clamp(1.5rem,6cqw,2.5rem)] leading-tight font-semibold tracking-tight text-balance">
{title}
</Heading>
{subtitle && <p className="mt-3 text-pretty opacity-70">{subtitle}</p>}
</header>
)}
<div className="mx-auto grid max-w-5xl items-start gap-4 @3xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] @3xl:gap-8">
<ul className="grid gap-1" onKeyDown={step}>
{items.map((item, i) => {
const on = open === i;
return (
<li key={item.question}>
<div className="relative">
{on && (
<motion.span
layoutId={`${id}-mark`}
className="absolute inset-0 rounded-xl"
style={{ backgroundColor: tile }}
transition={reduce ? { duration: 0 } : SPRING}
/>
)}
<button
type="button"
id={`${id}-q${i}`}
aria-expanded={on}
aria-controls={`${id}-a${i}`}
onClick={() => pick(i)}
className={`relative flex w-full items-center justify-between gap-4 rounded-xl px-4 py-3 text-left text-sm font-medium transition-opacity ${on ? "" : "opacity-70 hover:opacity-100"}`}
>
<span>{item.question}</span>
<span aria-hidden className="relative size-3 shrink-0">
<span className="absolute inset-x-0 top-[5px] h-0.5 rounded bg-current" />
<span className={`absolute inset-x-0 top-[5px] h-0.5 rounded bg-current transition-transform duration-300 motion-reduce:transition-none ${on ? "" : "rotate-90"}`} />
</span>
</button>
</div>
<AnimatePresence initial={false}>
{on && (
<motion.div
id={`${id}-a${i}`}
role="region"
aria-labelledby={`${id}-q${i}`}
className="overflow-hidden @3xl:hidden"
initial={reduce ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? { height: 0, transition: { duration: 0 } } : { height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
>
<div className="px-4 pt-2 pb-4 text-sm text-pretty opacity-80">{item.answer}</div>
</motion.div>
)}
</AnimatePresence>
</li>
);
})}
</ul>
<motion.div
ref={side}
aria-live="polite"
className="hidden overflow-hidden rounded-2xl @3xl:block"
style={{ backgroundColor: tile }}
initial={false}
animate={height ? { height } : undefined}
transition={reduce ? { duration: 0 } : SPRING}
>
<div ref={inner} className="p-6">
<AnimatePresence mode="wait" initial={false}>
{answer && (
<motion.div
key={shown}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -8 }}
transition={{ duration: 0.18 }}
>
<p aria-hidden className="text-3xl font-semibold tabular-nums" style={{ color }}>
{String(shown + 1).padStart(2, "0")}
</p>
<p className="mt-2 font-semibold text-balance">{answer.question}</p>
<div className="mt-2 text-sm leading-relaxed text-pretty opacity-80">{answer.answer}</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
</div>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/faq-morph.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/faq-morph-demo.tsx
import { FAQMorph } from "@/components/ui/faq-morph"; const ITEMS = [ { question: "Can I use this on client work?", answer: "Yes. Use it on as many client sites as you like. Only reselling the pieces themselves is not allowed." }, { question: "Do I need a design tool?", answer: "No. Paste, edit, done." }, { question: "What happens when my plan ends?", answer: "Everything you already copied stays yours. New pieces stop until you renew, and nothing on your live site changes. Your sites keep running as they are, you keep the code, and you can come back at any time without losing a thing you made while your plan was active." }, { question: "Does it work on phones?", answer: "Every piece sizes itself to the box it sits in, so it fits a phone, a sidebar or a full screen." }, ]; export default function FAQMorphExample() { return ( <div className="h-full overflow-y-auto"> <FAQMorph title="Good questions" items={ITEMS} headingAs="h3" className="min-h-full bg-[#0b0a14] p-4 text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | FAQMorphItem[] | — | Each has question and answer. |
| title | string | — | Heading above the questions. |
| eyebrow | string | — | Small line above the title. |
| subtitle | string | — | Line under the title. |
| defaultOpen | number | 0 | Which question starts open. |
| tile | string | "#15122a" | Colour of the marker and the answer panel. |
| color | string | "#a78bfa" | Colour of the number in the answer panel. |
| headingAs | ElementType | "h2" | Heading tag. |
| className | string | "bg-[#0b0a14] px-5 py-16 text-white" | Set padding and colours here. |