yugodesign

Navigation·06.7

Disclosure

One panel, measured, not guessed.

Panel closed

disclosure

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Or let the shadcn CLI do the copying: same file, landing in components/yugo.

terminal
bunx shadcn@latest add https://yugo.click/r/disclosure.json

Usage

stats.tsx
"use client";

import { useState } from "react";
import { Disclosure } from "@/components/yugo/disclosure";

export function LogPolicy() {
  const [open, setOpen] = useState(false);

  return (
    <Disclosure
      title="What we keep"
      meta="4 fields"
      open={open}
      onOpenChange={setOpen}
      maxHeight={200}
    >
      <p>
        Request path, response status, duration and the region that served it.
        Nothing that identifies a person, and nothing from the body.
      </p>
    </Disclosure>
  );
}

Source

components/yugo/disclosure.tsx
"use client";

import { useCallback, useId, useRef, useState } from "react";
import {
  motion,
  useIsomorphicLayoutEffect,
  useReducedMotion,
} from "motion/react";

const EASE = [0.23, 1, 0.32, 1] as const;
const LEAVE = [0.4, 0, 1, 1] as const;

const DISCLOSE = {
  type: "spring",
  stiffness: 480,
  damping: 40,
  mass: 0.6,
} as const;

const CHEVRON = {
  type: "spring",
  stiffness: 700,
  damping: 46,
  mass: 0.5,
} as const;

const INSTANT = { duration: 0 } as const;

export type DisclosureTriggerProps = {
  id: string;
  type: "button";
  onClick: () => void;
  "aria-expanded": boolean;
  "aria-controls": string;
};

export type DisclosurePanelProps = {
  id: string;
  role: "region";
  "aria-labelledby": string;
  "aria-hidden": true | undefined;
};

export type UseDisclosureOptions = {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
};

export type UseDisclosureResult = {
  open: boolean;
  toggle: () => void;
  setOpen: (open: boolean) => void;
  /** goes on the panel's own content box: this is what gets measured */
  contentRef: React.RefObject<HTMLDivElement | null>;
  height: number;
  ready: boolean;
  triggerProps: DisclosureTriggerProps;
  panelProps: DisclosurePanelProps;
};

/**
 * One panel, opened and shut. The height is measured from the content's own
 * box with a ResizeObserver rather than read once on open, so a panel that
 * rewraps on resize — or grows when a font or an image lands — never animates
 * to a number that stopped being true.
 */
export function useDisclosure({
  open: controlled,
  defaultOpen = false,
  onOpenChange,
}: UseDisclosureOptions = {}): UseDisclosureResult {
  const base = useId();
  const [uncontrolled, setUncontrolled] = useState(defaultOpen);
  const [height, setHeight] = useState(0);
  const [ready, setReady] = useState(false);

  const contentRef = useRef<HTMLDivElement>(null);
  const open = controlled ?? uncontrolled;

  const emit = useRef(onOpenChange);
  emit.current = onOpenChange;

  const setOpen = useCallback(
    (next: boolean) => {
      if (controlled === undefined) setUncontrolled(next);
      emit.current?.(next);
    },
    [controlled],
  );

  const toggle = useCallback(() => setOpen(!open), [setOpen, open]);

  useIsomorphicLayoutEffect(() => {
    const el = contentRef.current;
    if (!el) return;

    const read = () => {
      const next = el.getBoundingClientRect().height;
      // An epsilon, so a subpixel layout cannot feed itself. §21
      setHeight((prev) => (Math.abs(prev - next) < 0.5 ? prev : next));
    };

    read();
    setReady(true);

    const observer = new ResizeObserver(read);
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  return {
    open,
    toggle,
    setOpen,
    contentRef,
    height,
    ready,
    triggerProps: {
      id: `${base}-trigger`,
      type: "button",
      onClick: toggle,
      "aria-expanded": open,
      "aria-controls": `${base}-panel`,
    },
    panelProps: {
      id: `${base}-panel`,
      role: "region",
      "aria-labelledby": `${base}-trigger`,
      "aria-hidden": open ? undefined : true,
    },
  };
}

export type DisclosureProps = UseDisclosureOptions & {
  title: React.ReactNode;
  children: React.ReactNode;
  meta?: React.ReactNode;
  maxHeight?: number;
  headingLevel?: number;
  className?: string;
};

export function Disclosure({
  title,
  children,
  meta,
  maxHeight = 220,
  headingLevel = 3,
  className = "",
  ...options
}: DisclosureProps) {
  const reduced = useReducedMotion();
  const { open, contentRef, height, ready, triggerProps, panelProps } =
    useDisclosure(options);

  return (
    <div
      className={`overflow-hidden rounded-[11px] border border-stone-200 bg-white shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)] ${className}`}
    >
      <div role="heading" aria-level={headingLevel}>
        <button
          {...triggerProps}
          className="flex w-full items-center gap-3 px-3.5 py-3 text-left outline-none transition-colors duration-150 hover:bg-stone-100 focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:hover:bg-white/10 dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]"
        >
          <span
            className={`min-w-0 flex-1 truncate text-[13px] font-medium transition-colors duration-150 ${
              open
                ? "text-stone-900 dark:text-stone-50"
                : "text-stone-700 dark:text-stone-200"
            }`}
          >
            {title}
          </span>

          {meta ? (
            <span className="shrink-0 text-[11.5px] tabular-nums text-stone-400 dark:text-stone-500">
              {meta}
            </span>
          ) : null}

          <motion.svg
            width="13"
            height="13"
            viewBox="0 0 256 256"
            fill="none"
            aria-hidden="true"
            className="shrink-0 text-stone-500 dark:text-stone-400"
            initial={false}
            animate={{ rotate: open ? 180 : 0 }}
            transition={reduced ? INSTANT : CHEVRON}
          >
            <path
              d="M208 96l-80 80-80-80"
              stroke="currentColor"
              strokeWidth="16"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </motion.svg>
        </button>
      </div>

      <motion.div
        initial={false}
        animate={ready ? { height: open ? height : 0 } : {}}
        transition={reduced ? INSTANT : DISCLOSE}
        style={{
          overflow: "hidden",
          height: ready ? undefined : open ? "auto" : 0,
        }}
      >
        <div
          {...panelProps}
          ref={contentRef}
          inert={!open}
          className="border-t border-stone-200 bg-stone-50 shadow-[inset_0_1px_2px_rgba(28,25,23,0.05)] dark:border-white/[0.16] dark:bg-white/[0.05] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]"
          style={{
            maxHeight,
            overflowY: "auto",
            overscrollBehavior: "contain",
            scrollbarGutter: "stable",
          }}
        >
          {/* Opacity finishes before the height does, which is what hides the
              reflow at the bottom edge. §5 */}
          <motion.div
            initial={false}
            animate={{ opacity: open ? 1 : 0 }}
            transition={
              reduced
                ? INSTANT
                : open
                  ? { duration: 0.18, ease: EASE }
                  : { duration: 0.14, ease: LEAVE }
            }
            className="px-3.5 pb-3.5 pt-3 text-[12.5px] leading-relaxed text-stone-500 dark:text-stone-400"
          >
            {children}
          </motion.div>
        </div>
      </motion.div>
    </div>
  );
}

Props

title
ReactNode

The row's label. It is the accessible name of the panel it controls.

children
ReactNode

The panel's contents.

meta
ReactNode

An optional right-aligned count or hint on the trigger row.

open
boolean

Controlled state. Supplying it makes the parent the source of truth.

defaultOpenfalse
boolean

Uncontrolled starting state.

onOpenChange
(open: boolean) => void

Fires on every toggle, controlled or not.

maxHeight220
number

Ceiling in pixels. Content past it scrolls inside the panel with overscroll contained.

headingLevel3
number

aria-level for the header wrapper, so the row slots into the surrounding document outline.

className""
string

Appended last to the outer frame, so callers can override the border, surface and radius.