Expandable Panel
SaveA compact disclosure with a leading chevron that reveals a region below it, with a CSS-only height animation and content fade.
- Category
- Core
- Version
- 0.2.0
- Frameworks
- Next.js, Vite + React
- Styling
- Tailwind CSS v4
Preview
Expandable Panel preview
- Method
- POST
- Path
- /v1/messages
- Status
- 200 OK
- Duration
- 412 ms
Installation
npx @seamlessui/cli add expandable-panelPre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add expandable-panel --dry-runUsage
expandable-panel-example.tsx
import { ExpandablePanel } from "@/components/seamless/ui/expandable-panel";
const rows = [
{ term: "Method", value: "POST" },
{ term: "Path", value: "/v1/messages" },
{ term: "Status", value: "200 OK" },
{ term: "Duration", value: "412 ms" },
];
export default function ExpandablePanelExample() {
return (
<ExpandablePanel title="Show request details" className="max-w-md">
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 rounded-lg border border-zinc-950/[0.07] bg-zinc-950/[0.02] p-3 font-mono text-xs leading-relaxed dark:border-white/[0.08] dark:bg-transparent">
{rows.map((row) => (
<div key={row.term} className="contents">
<dt className="text-zinc-500">{row.term}</dt>
<dd className="text-zinc-950 tabular-nums dark:text-white">{row.value}</dd>
</div>
))}
</dl>
</ExpandablePanel>
);
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| title | ReactNode | — | Text of the button that shows and hides the content. |
| open | boolean | — | Controlled open state. Pair with onOpenChange. |
| defaultOpen | boolean | false | Initial open state when uncontrolled. |
| onOpenChange | (open: boolean) => void | — | Called with the next open state when the button is pressed. |
| disabled | boolean | false | Disables the button so the panel cannot be toggled. |
| triggerClassName | string | — | Classes for the toggle button. It is a compact, content-width button with a hover tint, offset 6px left so the chevron lines up with the content edge. |
| contentClassName | string | — | Classes for the padded content wrapper (indented to the title text). className styles the outer element. |
| ...props | ComponentProps<"div"> | — | Any native div attribute for the outer element. |
Accessibility
- The toggle is a native <button> with aria-expanded and aria-controls pointing at the content region.
- The content has role=region and is labelled by the button.
- Enter and Space toggle the panel.
- Closed content is hidden with visibility: hidden after the animation ends, so it is neither focusable nor announced.
- The chevron is decorative and rotates from right to down; the open state is conveyed by aria-expanded.
- Opens in 220ms and closes in 180ms; with reduced motion the panel opens and closes instantly and the chevron turns without rotating.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/ui/expandable-panel.tsxexpandable-panel- npm packages: noneReact and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/expandable-panel/0.2.0.json.
ui/expandable-panel.tsxShowHide
ui/expandable-panel.tsx
"use client";
import { type ComponentProps, type ReactNode, useId, useState } from "react";
function cx(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
export interface ExpandablePanelProps extends Omit<ComponentProps<"div">, "title"> {
/** Text of the button that shows and hides the content. */
title: ReactNode;
/** Controlled open state. Pair with `onOpenChange`. */
open?: boolean;
/** Initial open state when uncontrolled. */
defaultOpen?: boolean;
/** Called with the next open state when the button is pressed. */
onOpenChange?: (open: boolean) => void;
/** Prevents opening and closing. */
disabled?: boolean;
/** Classes for the toggle button. */
triggerClassName?: string;
/** Classes for the padded content wrapper. `className` styles the outer element. */
contentClassName?: string;
}
/**
* A disclosure: a compact button with a leading chevron that shows and hides a region below it.
* The height animates with CSS grid rows (220ms quint ease-out, 180ms to close) while the content
* fades; both are instant with reduced motion.
*/
export function ExpandablePanel({
title,
open: openProp,
defaultOpen = false,
onOpenChange,
disabled = false,
triggerClassName,
contentClassName,
className,
children,
...props
}: ExpandablePanelProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const controlled = openProp !== undefined;
const open = controlled ? openProp : uncontrolledOpen;
const id = useId();
const triggerId = `${id}-trigger`;
const regionId = `${id}-region`;
function toggle() {
const next = !open;
if (!controlled) setUncontrolledOpen(next);
onOpenChange?.(next);
}
return (
<div data-open={open ? "" : undefined} className={cx("w-full", className)} {...props}>
<button
type="button"
id={triggerId}
aria-expanded={open}
aria-controls={regionId}
disabled={disabled}
onClick={toggle}
className={cx(
"group -ml-1.5 inline-flex min-h-8 max-w-[calc(100%+0.375rem)] cursor-pointer items-center gap-1.5 rounded-lg py-1.5 pr-2.5 pl-1.5 text-left text-sm font-medium",
"text-zinc-600 transition-[background-color,color] duration-150 ease-out motion-reduce:transition-none",
"hover:bg-zinc-950/[0.04] hover:text-zinc-950 aria-expanded:text-zinc-950",
"dark:text-zinc-400 dark:hover:bg-white/[0.06] dark:hover:text-white dark:aria-expanded:text-white",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
"disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-transparent",
triggerClassName,
)}
>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
fill="none"
className={cx(
"size-4 shrink-0 text-zinc-400 dark:text-zinc-500",
"transition-[rotate,color] duration-220 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
"group-hover:text-current group-aria-expanded:rotate-90 group-aria-expanded:text-current",
)}
>
<path d="m6.25 4.5 3.5 3.5-3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="min-w-0">{title}</span>
</button>
{/*
Rows animate between 0fr and 1fr. `visibility` is transitioned too, so closed content
leaves the tab order and accessibility tree only after the closing animation ends.
*/}
<div
id={regionId}
role="region"
aria-labelledby={triggerId}
className={cx(
"grid transition-[grid-template-rows,visibility] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
open ? "visible grid-rows-[1fr] duration-220" : "invisible grid-rows-[0fr] duration-180",
)}
>
<div className="min-h-0 overflow-hidden">
<div
className={cx(
"pt-1.5 pb-2 pl-5.5 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400",
"transition-opacity ease-out motion-reduce:transition-none",
open ? "opacity-100 duration-220" : "opacity-0 duration-120",
contentClassName,
)}
>
{children}
</div>
</div>
</div>
</div>
);
}