diff --git a/packages/visuals/package.json b/packages/visuals/package.json
index 7e2b13f3b..1e3445900 100644
--- a/packages/visuals/package.json
+++ b/packages/visuals/package.json
@@ -15,6 +15,10 @@
"types": "./src/ui/inline-notice.tsx",
"default": "./src/ui/inline-notice.tsx"
},
+ "./unfold": {
+ "types": "./src/ui/unfold.tsx",
+ "default": "./src/ui/unfold.tsx"
+ },
"./styles.css": "./src/styles.css",
"./theme.css": "./theme.css"
},
diff --git a/packages/visuals/src/index.ts b/packages/visuals/src/index.ts
index eb89c9474..d74271d55 100644
--- a/packages/visuals/src/index.ts
+++ b/packages/visuals/src/index.ts
@@ -35,3 +35,11 @@ export type {
TableRowProps,
TableVariant,
} from "./ui/table";
+export { UnfoldPanel, UnfoldSection, UnfoldToggle, useUnfold } from "./ui/unfold";
+export type {
+ Unfold,
+ UnfoldPanelProps,
+ UnfoldSectionProps,
+ UnfoldToggleProps,
+ UnfoldTriggerProps,
+} from "./ui/unfold";
diff --git a/packages/visuals/src/styles.css b/packages/visuals/src/styles.css
index 468b55e66..b3b2c3ad1 100644
--- a/packages/visuals/src/styles.css
+++ b/packages/visuals/src/styles.css
@@ -9,3 +9,4 @@
@import "./ui/select.css";
@import "./ui/sparkline.css";
@import "./ui/table.css";
+@import "./ui/unfold.css";
diff --git a/packages/visuals/src/ui/unfold.css b/packages/visuals/src/ui/unfold.css
new file mode 100644
index 000000000..c31e4dd9f
--- /dev/null
+++ b/packages/visuals/src/ui/unfold.css
@@ -0,0 +1,93 @@
+/* Two speeds, both tokens: plain accordions, and card previews at the linger pace. */
+.cv-unfold,
+.cv-unfold-toggle {
+ --cv-unfold-duration: var(--acc-expand);
+}
+
+[data-unfold-speed="card"] {
+ --cv-unfold-duration: var(--duration-linger);
+}
+
+/* Height folds through the grid row; content fades and tips open from its top edge. */
+.cv-unfold {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows var(--cv-unfold-duration) var(--ease-out);
+}
+
+.cv-unfold[data-open] {
+ grid-template-rows: 1fr;
+}
+
+/* The clip's padding holds the focus ring of full-width controls (an inset ring, as in a focus boundary, needs none); the matching negative
+ margin keeps the layout box (and the 0fr fold) unchanged. */
+.cv-unfold-clip {
+ --cv-unfold-ring-room: calc(var(--focus-ring-width) + max(var(--focus-ring-offset), 0px));
+ min-height: 0;
+ margin: calc(var(--cv-unfold-ring-room) * -1);
+ padding: var(--cv-unfold-ring-room);
+ overflow: hidden;
+ perspective: 50rem;
+}
+
+/* Visibility flips only after the fold, so closed content leaves find-in-page and focus. */
+.cv-unfold-content {
+ opacity: 0;
+ transform: rotateX(-40deg);
+ transform-origin: top center;
+ visibility: hidden;
+ transition:
+ opacity var(--cv-unfold-duration) var(--ease-out),
+ transform var(--cv-unfold-duration) var(--ease-out),
+ visibility 0s var(--cv-unfold-duration);
+}
+
+.cv-unfold[data-open] > .cv-unfold-clip > .cv-unfold-content {
+ opacity: 1;
+ transform: none;
+ visibility: visible;
+ transition-delay: 0s;
+}
+
+:where(.cv-unfold-toggle) {
+ display: inline-flex;
+ align-items: center;
+ gap: calc(var(--spacing) * 1.5);
+ cursor: pointer;
+ text-align: start;
+}
+
+.cv-unfold-chevron {
+ flex: none;
+ color: var(--color-text-tertiary);
+ transition: transform var(--cv-unfold-duration) var(--ease-out);
+}
+
+.cv-unfold-toggle[aria-expanded="true"] > .cv-unfold-chevron {
+ transform: rotate(90deg);
+}
+
+.cv-unfold-section-toggle {
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
+ color: var(--color-text-secondary);
+}
+
+.cv-unfold-section-body {
+ padding-block-start: calc(var(--spacing) * 2);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .cv-unfold {
+ transition: none;
+ }
+
+ .cv-unfold-content {
+ transform: none;
+ transition: opacity var(--duration-base) var(--ease-out), visibility 0s var(--duration-base);
+ }
+
+ .cv-unfold-chevron {
+ transition: none;
+ }
+}
diff --git a/packages/visuals/src/ui/unfold.test.tsx b/packages/visuals/src/ui/unfold.test.tsx
new file mode 100644
index 000000000..47d429625
--- /dev/null
+++ b/packages/visuals/src/ui/unfold.test.tsx
@@ -0,0 +1,113 @@
+import { expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { UnfoldPanel, UnfoldSection, UnfoldToggle, useUnfold } from "@chopin/visuals/unfold";
+
+const CSS = readFileSync(join(import.meta.dir, "unfold.css"), "utf8");
+
+function Harness({ defaultOpen, speed }: { defaultOpen?: boolean; speed?: "card" | "accordion" }) {
+ let unfold = useUnfold(defaultOpen, speed);
+ return (
+ <>
+
+ Preview
+
+
+ Source
+
+ >
+ );
+}
+
+function tagWithId(markup: string, id: string): string {
+ let escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ return markup.match(new RegExp(`
]*id="${escaped}"[^>]*>`))?.[0] ?? "";
+}
+
+test("a closed unfold names its panel and keeps the content mounted but inert", () => {
+ let markup = renderToStaticMarkup(
);
+ let id = markup.match(/aria-controls="([^"]+)"/)?.[1];
+
+ expect(id).toBeTruthy();
+ expect(markup).toContain('aria-expanded="false"');
+ expect(markup).toContain('type="button"');
+ expect(markup).toContain('aria-label="Preview report"');
+ expect(markup).toMatch(/
]*class="cv-unfold-toggle host"/);
+ let panel = tagWithId(markup, id!);
+ expect(panel).toContain('data-slot="unfold"');
+ expect(panel).toContain('class="cv-unfold panel"');
+ expect(panel).toContain('inert=""');
+ expect(panel).toContain('aria-hidden="true"');
+ expect(panel).not.toContain("data-open");
+ expect(markup).toContain('');
+ expect(markup).toContain('href="https://example.com/source"');
+});
+
+test("an open unfold exposes its panel", () => {
+ let markup = renderToStaticMarkup(
);
+ let id = markup.match(/aria-controls="([^"]+)"/)?.[1];
+ let panel = tagWithId(markup, id!);
+
+ expect(markup).toContain('aria-expanded="true"');
+ expect(panel).toContain('data-open=""');
+ expect(panel).not.toContain("inert");
+ expect(panel).not.toContain("aria-hidden");
+});
+
+test("the toggle carries one decorative chevron after its label", () => {
+ let markup = renderToStaticMarkup(
);
+
+ let chevron = markup.match(/Preview(<(?:svg)[^>]*>)/)?.[1] ?? "";
+ expect(chevron).toContain('class="cv-unfold-chevron"');
+ expect(chevron).toContain('aria-hidden="true"');
+});
+
+test("a section pairs a labelled toggle with its panel", () => {
+ let markup = renderToStaticMarkup(
+
+ Body
+ ,
+ );
+
+ expect(markup).toContain('data-slot="unfold-section"');
+ expect(markup).toMatch(/
]*aria-expanded="false"[^>]*>Report<(?:svg)/);
+ expect(markup).toContain('');
+});
+
+test("toggle and panel carry the same speed; accordion is the default", () => {
+ let plain = renderToStaticMarkup( );
+ let card = renderToStaticMarkup( );
+
+ expect(plain.match(/data-unfold-speed="accordion"/g)).toHaveLength(2);
+ expect(card.match(/data-unfold-speed="card"/g)).toHaveLength(2);
+});
+
+test("speeds map to tokens: accordion 250ms, card previews 600ms", () => {
+ expect(CSS).toMatch(
+ /\.cv-unfold,\s*\.cv-unfold-toggle\s*{\s*--cv-unfold-duration:\s*var\(--acc-expand\);/,
+ );
+ expect(CSS).toMatch(
+ /\[data-unfold-speed="card"\]\s*{\s*--cv-unfold-duration:\s*var\(--duration-linger\);/,
+ );
+});
+
+test("the fold uses jig row D motion and reverses through CSS transitions", () => {
+ expect(CSS).toMatch(
+ /\.cv-unfold\s*{[^}]*grid-template-rows:\s*0fr;[^}]*transition:\s*grid-template-rows var\(--cv-unfold-duration\) var\(--ease-out\)/s,
+ );
+ expect(CSS).toMatch(/\.cv-unfold\[data-open\]\s*{[^}]*grid-template-rows:\s*1fr/s);
+ expect(CSS).toMatch(/\.cv-unfold-content\s*{[^}]*transform:\s*rotateX\(-40deg\)/s);
+ expect(CSS).toMatch(/\.cv-unfold-content\s*{[^}]*visibility 0s var\(--cv-unfold-duration\)/s);
+});
+
+test("reduced motion snaps the fold and keeps a short crossfade", () => {
+ let reduced = CSS.match(/@media \(prefers-reduced-motion: reduce\)\s*{([\s\S]*)}\s*$/)?.[1] ?? "";
+
+ expect(reduced).toMatch(/\.cv-unfold\s*{\s*transition:\s*none;/);
+ expect(reduced).toMatch(
+ /\.cv-unfold-content\s*{[^}]*transform:\s*none;[^}]*opacity var\(--duration-base\)/s,
+ );
+ expect(reduced).toMatch(/\.cv-unfold-chevron\s*{\s*transition:\s*none;/);
+});
diff --git a/packages/visuals/src/ui/unfold.tsx b/packages/visuals/src/ui/unfold.tsx
new file mode 100644
index 000000000..aded3287e
--- /dev/null
+++ b/packages/visuals/src/ui/unfold.tsx
@@ -0,0 +1,121 @@
+import { useCallback, useId, useRef, useState } from "react";
+import { ChevronIcon } from "@chopin/icons";
+
+import type { ComponentProps, ReactNode, RefObject } from "react";
+
+export type UnfoldTriggerProps = {
+ "aria-controls": string;
+ "aria-expanded": boolean;
+ onClick: () => void;
+ ref: RefObject;
+ type: "button";
+};
+
+export type UnfoldSpeed = "card" | "accordion";
+
+export type Unfold = {
+ id: string;
+ open: boolean;
+ speed: UnfoldSpeed;
+ panelRef: RefObject;
+ setOpen: (open: boolean, options?: { focusTrigger?: boolean }) => void;
+ toggle: () => void;
+ trigger: UnfoldTriggerProps;
+};
+
+/** `card` previews fold at the linger pace; plain accordions use the accordion token. */
+export function useUnfold(defaultOpen = false, speed: UnfoldSpeed = "accordion"): Unfold {
+ let id = useId();
+ let [open, setState] = useState(defaultOpen);
+ let triggerRef = useRef(null);
+ let panelRef = useRef(null);
+ let setOpen = useCallback((next: boolean, options: { focusTrigger?: boolean } = {}) => {
+ let panel = panelRef.current;
+ // A closing panel turns inert; focus left inside it would fall to the page.
+ if (!next && (options.focusTrigger || panel?.contains(panel.ownerDocument.activeElement))) {
+ triggerRef.current?.focus({ preventScroll: true });
+ }
+ setState(next);
+ }, []);
+ let toggle = useCallback(() => setOpen(!open), [open, setOpen]);
+ return {
+ id,
+ open,
+ panelRef,
+ setOpen,
+ speed,
+ toggle,
+ trigger: {
+ "aria-controls": id,
+ "aria-expanded": open,
+ onClick: toggle,
+ ref: triggerRef,
+ type: "button",
+ },
+ };
+}
+
+function classes(...names: (string | undefined)[]): string {
+ return names.filter(Boolean).join(" ");
+}
+
+export type UnfoldToggleProps =
+ & Omit, "aria-controls" | "aria-expanded" | "onClick" | "ref" | "type">
+ & { unfold: Unfold };
+
+export function UnfoldToggle({ children, className, unfold, ...props }: UnfoldToggleProps) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+export type UnfoldPanelProps = Omit, "id" | "ref"> & { unfold: Unfold };
+
+/** Stays mounted so a fold can reverse from any frame; closed content is inert. */
+export function UnfoldPanel({ children, className, unfold, ...props }: UnfoldPanelProps) {
+ return (
+
+ );
+}
+
+export type UnfoldSectionProps = {
+ children: ReactNode;
+ className?: string;
+ defaultOpen?: boolean;
+ label: ReactNode;
+};
+
+export function UnfoldSection({ children, className, defaultOpen, label }: UnfoldSectionProps) {
+ let unfold = useUnfold(defaultOpen);
+ return (
+
+
{label}
+
+ {children}
+
+
+ );
+}
diff --git a/scripts/design-contract/exceptions/dynamic-packages.json b/scripts/design-contract/exceptions/dynamic-packages.json
index c066d2248..7c5b7a2a7 100644
--- a/scripts/design-contract/exceptions/dynamic-packages.json
+++ b/scripts/design-contract/exceptions/dynamic-packages.json
@@ -845,5 +845,68 @@
]
],
"sourceHash": "5c9709fd97d4f6777205da118ce8b24123537b0ae7e56d3fa4490db8538081bf"
+ },
+ {
+ "file": "packages/visuals/src/ui/unfold.tsx",
+ "reason": "Public visual primitive forwards typed DOM props. Literal style/class call sites remain scanned; runtime caller values need browser review. UnfoldToggle spreads caller button props (aria-controls, aria-expanded, onClick, ref and type are omitted from the type) and the useUnfold trigger object, which holds only ARIA state, a click handler, a ref and type=button; neither carries style.",
+ "cases": [
+ [
+ "dynamic",
+ "*",
+ "props",
+ "unresolved JSX props > UnfoldToggle > ",
+ 1
+ ],
+ [
+ "dynamic",
+ "*",
+ "unfold.trigger",
+ "unresolved JSX props > UnfoldToggle > ",
+ 1
+ ]
+ ],
+ "sourceHash": "098bf723d102ac3e909c8ad91445adbce4d7a76a37ba324956866cdde04f4d68"
+ },
+ {
+ "file": "packages/visuals/src/ui/unfold.tsx",
+ "reason": "Public visual primitive forwards typed DOM props. Literal style/class call sites remain scanned; runtime caller values need browser review. UnfoldPanel spreads caller div props (id and ref omitted); the panel sets its own aria-hidden, inert, id and data attributes after the spread.",
+ "cases": [
+ [
+ "dynamic",
+ "*",
+ "props",
+ "unresolved JSX props > UnfoldPanel > ",
+ 1
+ ]
+ ],
+ "sourceHash": "098bf723d102ac3e909c8ad91445adbce4d7a76a37ba324956866cdde04f4d68"
+ },
+ {
+ "file": "packages/visuals/src/ui/unfold.tsx",
+ "reason": "Component forwards or combines caller class names; static call-site classes are scanned. Runtime/cross-module values require review at this exact component boundary. Every current caller passes a static string literal (child-provenance, experiments, research widget), which the scanner reads at the call site; classes() only joins it to a fixed cv-unfold base.",
+ "cases": [
+ [
+ "dynamic",
+ "class",
+ "classes(\"cv-unfold-toggle\", className)",
+ "class > UnfoldToggle >
",
+ 1
+ ],
+ [
+ "dynamic",
+ "class",
+ "classes(\"cv-unfold\", className)",
+ "class > UnfoldPanel > ",
+ 1
+ ],
+ [
+ "dynamic",
+ "class",
+ "className",
+ "class > UnfoldSection >
",
+ 1
+ ]
+ ],
+ "sourceHash": "098bf723d102ac3e909c8ad91445adbce4d7a76a37ba324956866cdde04f4d68"
}
]