mm.
← All components

Disclosure

A panel that folds open by height, with rows that arrive one at a time — once.

The idea

Collapsing by animating height rather than unmounting keeps the rows in the served HTML, which is what makes them worth anything to a crawler, and `inert` keeps them out of the tab order while closed. The staggered blur-in is a welcome: with a key it plays on the first open of a session and then gets out of the way, because the second time it would only be a delay.

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

Disclosure — usage18 lines
import { Disclosure } from "@mihirmodi/ui";

// The rows stay in the DOM when closed — they fold by height, not by
// unmounting — so they are in the served HTML. With `once` the staggered
// reveal plays on the first open of a session and never again; leave it
// out and it plays every time.
<Disclosure
  summary="The short version"
  icon={<SparkIcon />}
  once={`study:${slug}`}
  items={study.summary.map((line) => (
    <span key={line} className="flex gap-2.5">
      <span aria-hidden className="mt-[0.68em] h-px w-2.5 shrink-0 bg-gray-500" />
      {line}
    </span>
  ))}
  footer="Written by me, not a model."
/>

Props

PropNotes
summaryReactNodeThe label on the row you press.
iconReactNodeA mark before the label. Small — it shares the row.
itemsReactNode[]The rows inside. Each arrives on its own beat the first time the panel opens.
footerReactNodeUnder the rows, revealed with the panel but not staggered. A provenance line, a link.
oncestringPlay the stagger once per session, remembered under this key. Omit it and every open gets the full performance.
defaultOpenbooleanStart open. It still folds by height, so nothing animates shut on mount.
classNamestringOn the outer panel. The border, radius and background live there, so this is where to restyle them.

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/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";

/**
 * 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>
  );
}