Navigation·06.7
Disclosure
One panel, measured, not guessed.
Panel closed
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionOr let the shadcn CLI do the copying: same file, landing in components/yugo.
bunx shadcn@latest add https://yugo.click/r/disclosure.jsonUsage
"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
"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
titleReactNodeThe row's label. It is the accessible name of the panel it controls.
childrenReactNodeThe panel's contents.
metaReactNodeAn optional right-aligned count or hint on the trigger row.
openbooleanControlled state. Supplying it makes the parent the source of truth.
defaultOpenfalsebooleanUncontrolled starting state.
onOpenChange(open: boolean) => voidFires on every toggle, controlled or not.
maxHeight220numberCeiling in pixels. Content past it scrolls inside the panel with overscroll contained.
headingLevel3numberaria-level for the header wrapper, so the row slots into the surrounding document outline.
className""stringAppended last to the outer frame, so callers can override the border, surface and radius.