← All components

Accordion

Sections folded behind their headers, with hairlines between them rather than a box round each.

Anyone who can edit. Viewers are free, and a seat moves with the person, not the device.

The idea

The fold is Disclosure's: height animates while the content stays mounted, so every answer is in the served HTML, and a closed one is inert so the keyboard meets headers, not hidden prose. Opening eases out with the body rising into place; closing is quicker and flat. The rows are one list, ruled with hairlines — a card per row would turn a column of questions into a pile of tiles. Up and Down move between headers.

Composes Disclosure.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Accordion — usage14 lines
import { Accordion } from "mihirmodi-ui";

// Closed answers stay in the DOM — crawlable — and inert.
<Accordion
  type="single"
  defaultValue="seats"
  items={[
    { value: "seats", title: "What counts as a seat?", content: "Anyone who can edit." },
    { value: "pause", title: "Can I pause instead?", content: "Yes, for up to three months." },
  ]}
/>

// Several open at once, controlled.
<Accordion type="multiple" value={open} onChange={setOpen} items={sections} />

Props

PropNotes
items{ value, title, content, disabled? }[]The sections, in order.
type"single" | "multiple"One open at a time, or several.
value / defaultValue / onChangestring | null, or string[]Controlled or not; the shape follows `type`.
collapsiblebooleanSingle only: whether the open section can close to none.
headingLevel2–6The heading each header button sits in.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/layout/Accordion.tsx172 lines
"use client";

import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { ChevronDownIcon } from "../../lib/glyph.js";
import { EASE_OUT_EXPO } from "../../lib/motion.js";

export type AccordionItem = {
  value: string;
  /** The header's label. */
  title: ReactNode;
  /** The section's body. It stays in the DOM while closed. */
  content: ReactNode;
  disabled?: boolean;
};

type Single = {
  type?: "single";
  /** Controlled: the open section, or `null` for none. */
  value?: string | null;
  defaultValue?: string | null;
  onChange?: (value: string | null) => void;
  /** Let the open section close again, leaving none open. Default true. */
  collapsible?: boolean;
};
type Multiple = {
  type: "multiple";
  value?: string[];
  defaultValue?: string[];
  onChange?: (value: string[]) => void;
};

export type AccordionProps = (Single | Multiple) & {
  items: AccordionItem[];
  /** The level of each header's heading — match the outline of the page it sits in. */
  headingLevel?: 2 | 3 | 4 | 5 | 6;
  className?: string;
};

/**
 * A stack of sections, each folded behind its header.
 *
 * Hairline rules between the rows and nothing else — not a box per row. The
 * sections are one list on the page, and a card around each would turn a
 * column of questions into a pile of tiles. A header is a 14px row at 500
 * with a chevron that turns over as its section opens; the body is the muted
 * tier under it, so header and answer separate on weight and colour.
 *
 * The fold is Disclosure's: height animates while the content stays mounted,
 * so every answer is in the served HTML for a crawler, and a closed one is
 * `inert` so a tab press or a screen reader meets the headers, not hidden
 * prose. Opening eases out over 280ms with the body rising 4px into place;
 * closing is quicker and flat, because a section going away needs nothing
 * from the reader. Under reduced motion the fold is instant.
 *
 * `single` keeps one section open at a time, which suits a FAQ where answers
 * are read one by one; `multiple` lets several stay open for comparison. The
 * headers are buttons inside headings, and Up/Down, Home and End move between
 * them, so a long list is walkable without tabbing through every answer.
 */
export function Accordion(props: AccordionProps) {
  const { items, headingLevel = 3, className = "" } = props;
  const reduce = useReducedMotion();
  const base = useId();
  const multiple = props.type === "multiple";
  const headers = useRef<(HTMLButtonElement | null)[]>([]);

  const [inner, setInner] = useState<string[]>(() => {
    if (props.type === "multiple") return props.defaultValue ?? [];
    return props.defaultValue != null ? [props.defaultValue] : [];
  });
  const controlled = props.value !== undefined;
  const open: string[] = controlled
    ? props.type === "multiple"
      ? (props.value ?? [])
      : props.value != null
        ? [props.value]
        : []
    : inner;

  const toggle = (v: string) => {
    let next: string[];
    if (props.type === "multiple") {
      next = open.includes(v) ? open.filter((x) => x !== v) : [...open, v];
      props.onChange?.(next);
    } else {
      const collapsible = props.collapsible ?? true;
      const was = open.includes(v);
      if (was && !collapsible) return;
      next = was ? [] : [v];
      props.onChange?.(next[0] ?? null);
    }
    if (!controlled) setInner(next);
  };

  const onKey = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
    const live = items.map((it, n) => (it.disabled ? -1 : n)).filter((n) => n >= 0);
    const at = live.indexOf(i);
    let to: number | undefined;
    if (e.key === "ArrowDown") to = live[(at + 1) % live.length];
    else if (e.key === "ArrowUp") to = live[(at - 1 + live.length) % live.length];
    else if (e.key === "Home") to = live[0];
    else if (e.key === "End") to = live[live.length - 1];
    if (to == null) return;
    e.preventDefault();
    headers.current[to]?.focus();
  };

  const H = `h${headingLevel}` as "h3";

  return (
    <LazyMotion features={domAnimation} strict>
      <div data-type={multiple ? "multiple" : "single"} className={`flex flex-col divide-y-[0.5px] divide-gray-alpha-400 ${className}`}>
        {items.map((it, i) => {
          const isOpen = open.includes(it.value);
          const head = `${base}-h-${i}`;
          const body = `${base}-b-${i}`;
          return (
            <div key={it.value} data-open={isOpen || undefined}>
              <H className="m-0">
                <button
                  ref={(el) => {
                    headers.current[i] = el;
                  }}
                  id={head}
                  type="button"
                  aria-expanded={isOpen}
                  aria-controls={body}
                  disabled={it.disabled}
                  onClick={() => toggle(it.value)}
                  onKeyDown={(e) => onKey(e, i)}
                  className="group flex w-full items-center gap-3 py-3.5 text-left text-[14px] font-medium leading-[1.4] text-gray-1000 transition-opacity duration-150 ease-out hover:opacity-70 disabled:opacity-40"
                >
                  <span className="min-w-0 flex-1">{it.title}</span>
                  <ChevronDownIcon
                    className={`h-3.5 w-3.5 shrink-0 text-faint transition-transform duration-200 ease-geist motion-reduce:transition-none ${
                      isOpen ? "rotate-180" : ""
                    }`}
                  />
                </button>
              </H>
              <m.div
                id={body}
                role="region"
                aria-labelledby={head}
                // As in Disclosure: no animation on mount, so a section that
                // starts closed does not visibly fold shut on load.
                initial={false}
                animate={{ height: isOpen ? "auto" : 0 }}
                transition={
                  reduce ? { duration: 0 } : isOpen ? { duration: 0.28, ease: EASE_OUT_EXPO } : { duration: 0.18, ease: "easeIn" }
                }
                inert={!isOpen}
                aria-hidden={!isOpen}
                className="overflow-hidden"
              >
                <m.div
                  initial={false}
                  animate={isOpen ? { opacity: 1, y: 0 } : { opacity: 0, y: reduce ? 0 : 4 }}
                  transition={reduce ? { duration: 0 } : isOpen ? { duration: 0.24, delay: 0.04, ease: EASE_OUT_EXPO } : { duration: 0.12 }}
                  className="pb-4 pr-7 text-[14px] leading-[1.65] text-muted"
                >
                  {it.content}
                </m.div>
              </m.div>
            </div>
          );
        })}
      </div>
    </LazyMotion>
  );
}
packages/ui/src/components/Disclosure.tsx195 lines
"use client";

import { useCallback, useEffect, useId, useState, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { ChevronDownIcon } from "../lib/glyph.js";

/**
 * Expo-out. Deliberately not `--ease-geist`, whose curve overshoots past 1 —
 * fine on a transform, wrong here, where the same track drives a blur that
 * would have to pass through a negative radius to get back.
 */
const EASE = [0.16, 1, 0.3, 1] as const;

/** Per row, and short enough that three of them read as one movement. */
const STAGGER = 0.09;

export type DisclosureProps = {
  /** The label on the row you press. */
  summary: ReactNode;
  /** A mark before the label. Optional, and small — it shares the row. */
  icon?: ReactNode;
  /** The rows inside. Each arrives on its own beat the first time the panel opens. */
  items: ReactNode[];
  /** Anything under the rows: a provenance line, a link. Revealed with the panel, not staggered. */
  footer?: ReactNode;
  /**
   * Play the staggered reveal once per session, remembered under this key in
   * `sessionStorage`. Leave it out and every open gets the full performance —
   * right for a demo, wrong for a panel a reader will open on ten pages.
   */
  once?: string;
  defaultOpen?: boolean;
  /** On the outer panel — the border, the radius and the background live here. */
  className?: string;
};

/**
 * A collapsible panel whose contents arrive one row at a time.
 *
 * ── Why the rows stay in the DOM ──────────────────────────────────────────────
 *
 * The panel collapses by animating its height, not by unmounting — so what is
 * inside is in the served HTML, which is what makes it worth anything to a
 * crawler or to `llms.txt`. It is `inert` while closed, so a screen reader or a
 * tab press meets the button rather than three rows of hidden prose.
 *
 * ── Why the stagger plays once ────────────────────────────────────────────────
 *
 * Rows blurring in one after another is a welcome. It says "here is the short
 * one" the first time, and it would say "wait" every time after that, so with
 * `once` set it plays on the first open of a session and then gets out of the
 * way: later opens are a plain fold. The label takes one pass of light as it
 * opens for the same reason and with the same limit — a looping shimmer would
 * claim the thing is still thinking, which it is not and never was.
 *
 * Under reduced motion the fold is instant and the rows simply appear.
 *
 *     <Disclosure summary="The short version" icon={<Spark />} once="study:atlas"
 *       items={["Three claims.", "Side by side.", "Not a list of features."]}
 *       footer="Written by me, not a model." />
 */
export function Disclosure({
  summary,
  icon,
  items,
  footer,
  once,
  defaultOpen = false,
  className = "",
}: DisclosureProps) {
  const reduce = useReducedMotion();
  const [open, setOpen] = useState(defaultOpen);
  const [seen, setSeen] = useState(false);
  const [sheen, setSheen] = useState(false);
  const panelId = useId();

  // Read in an effect rather than during render: `sessionStorage` does not
  // exist on the server, and seeding state from it would hand React two
  // different first paints to reconcile.
  useEffect(() => {
    if (!once) return;
    try {
      if (sessionStorage.getItem(`disclosure:${once}`)) setSeen(true);
    } catch {
      // Private mode or blocked storage — the panel just plays every time.
    }
  }, [once]);

  // The full performance is for the first open only, and never against a
  // reduced-motion preference.
  const theatre = !reduce && !seen;

  const toggle = useCallback(() => {
    setOpen((was) => {
      const next = !was;
      if (next && theatre) setSheen(true);
      if (next && once) {
        try {
          sessionStorage.setItem(`disclosure:${once}`, "1");
        } catch {
          // ignore — it will simply play again next time
        }
      }
      return next;
    });
  }, [once, theatre]);

  return (
    <div
      data-open={open}
      className={`overflow-hidden rounded-[14px] border-[0.5px] border-gray-alpha-400 bg-background-100 ${className}`}
    >
      <button
        type="button"
        onClick={toggle}
        aria-expanded={open}
        aria-controls={panelId}
        className="flex w-full items-center gap-2.5 px-4 py-3 text-left text-[13px] font-medium text-gray-1000 transition-opacity duration-150 ease-geist hover:opacity-70"
      >
        {icon && <span className="flex shrink-0 items-center">{icon}</span>}
        <span
          // Remounted on each open so the sheen keyframe runs from the top.
          key={String(open)}
          className={sheen && open ? "disclosure-sheen" : ""}
          onAnimationEnd={() => setSheen(false)}
        >
          {summary}
        </span>
        <ChevronDownIcon
          className={`ml-auto h-3.5 w-3.5 shrink-0 text-faint transition-transform duration-200 ease-geist ${
            open ? "rotate-180" : ""
          }`}
        />
      </button>

      <LazyMotion features={domAnimation} strict>
        <m.div
          id={panelId}
          // `initial={false}` so a panel that starts closed does not animate shut
          // on mount — the reader would see a flicker of rows collapsing.
          initial={false}
          animate={{ height: open ? "auto" : 0 }}
          transition={{ duration: reduce ? 0 : 0.28, ease: EASE }}
          inert={!open}
          aria-hidden={!open}
          className="overflow-hidden"
        >
          <m.ul
            initial={false}
            animate={open ? "open" : "shut"}
            variants={{
              open: {
                transition: {
                  staggerChildren: theatre ? STAGGER : 0,
                  delayChildren: theatre ? 0.06 : 0,
                },
              },
            }}
            // Marked seen once the first open has finished rather than when it
            // starts — setting it any earlier lands in the same batch as `open`
            // and cancels the very animation it is recording. Only with `once`:
            // a panel without a key plays every time by design.
            onAnimationComplete={() => {
              if (open && once) setSeen(true);
            }}
            className="flex flex-col gap-3 px-4 pb-1"
          >
            {items.map((item, i) => (
              <m.li
                key={i}
                // The shut state is a constant, and it has to be. `theatre` is
                // false on the server and true on the client for most, so
                // branching the rendered styles on it would hand React two
                // different first paints. Only the transitions read it.
                variants={{
                  shut: { opacity: 0, y: 6, filter: "blur(4px)" },
                  open: {
                    opacity: 1,
                    y: 0,
                    filter: "blur(0px)",
                    transition: { duration: reduce ? 0 : theatre ? 0.34 : 0.16, ease: EASE },
                  },
                }}
                className="text-[13.5px] leading-[1.6] text-gray-1000"
              >
                {item}
              </m.li>
            ))}
          </m.ul>
          {footer && <div className="px-4 pb-3.5 pt-4 text-[12px] leading-[1.5] text-faint">{footer}</div>}
        </m.div>
      </LazyMotion>
    </div>
  );
}