← Back
UI

Accordion

Smooth expand/collapse using the grid-template-rows 0fr → 1fr trick — no max-height hacks.

backdrop-filter applies graphical effects like blur or brightness to the area behind an element. It's what makes the frosted glass look possible — the element itself can be semi-transparent while the background blurs through it.

requestAnimationFrame schedules a callback before the browser's next repaint, syncing naturally with the display refresh rate (usually 60fps). It's the preferred way to run smooth animations since the browser can pause it when the tab is hidden.

Animating grid-template-rows from 0fr to 1fr achieves a smooth height transition without knowing the element's exact height in advance — a clean modern alternative to the old max-height hack.

IntersectionObserver efficiently detects when elements enter the viewport without attaching scroll event listeners, making scroll-triggered animations both performant and battery-friendly.

Accordion.tsx72 lines
"use client";

import { useState } from "react";

const items = [
  {
    question: "What is CSS backdrop-filter?",
    answer:
      "backdrop-filter applies graphical effects like blur or brightness to the area behind an element. It's what makes the frosted glass look possible — the element itself can be semi-transparent while the background blurs through it.",
  },
  {
    question: "How does requestAnimationFrame work?",
    answer:
      "requestAnimationFrame schedules a callback before the browser's next repaint, syncing naturally with the display refresh rate (usually 60fps). It's the preferred way to run smooth animations since the browser can pause it when the tab is hidden.",
  },
  {
    question: "What is the grid-template-rows trick?",
    answer:
      "Animating grid-template-rows from 0fr to 1fr achieves a smooth height transition without knowing the element's exact height in advance — a clean modern alternative to the old max-height hack.",
  },
  {
    question: "Why use IntersectionObserver for animations?",
    answer:
      "IntersectionObserver efficiently detects when elements enter the viewport without attaching scroll event listeners, making scroll-triggered animations both performant and battery-friendly.",
  },
];

function AccordionItem({ question, answer }: { question: string; answer: string }) {
  const [open, setOpen] = useState(false);

  return (
    <div className="border-b border-zinc-100 dark:border-zinc-800 last:border-0">
      <button
        onClick={() => setOpen(!open)}
        className="flex items-center justify-between w-full py-4 text-left gap-4 cursor-pointer"
      >
        <span className="font-medium text-zinc-900 dark:text-zinc-50 text-sm leading-relaxed">
          {question}
        </span>
        <span
          className="shrink-0 text-lg text-zinc-400 dark:text-zinc-500 transition-transform duration-300 leading-none"
          style={{ transform: open ? "rotate(45deg)" : "rotate(0deg)" }}
        >
          +
        </span>
      </button>

      {/* grid-template-rows trick for smooth height animation */}
      <div
        className="grid transition-[grid-template-rows] duration-300 ease-in-out"
        style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
      >
        <div className="overflow-hidden">
          <p className="text-sm text-zinc-500 dark:text-zinc-400 leading-relaxed pb-4">
            {answer}
          </p>
        </div>
      </div>
    </div>
  );
}

export default function Accordion() {
  return (
    <div className="w-full rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-5">
      {items.map((item) => (
        <AccordionItem key={item.question} {...item} />
      ))}
    </div>
  );
}

More in UI