Skip to content
Seamless UI

Expandable Panel

Save

A 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

Installation

npx @seamlessui/cli add expandable-panel

Pre-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-run

Usage

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

PropTypeDefaultDescription
titleReactNode—Text of the button that shows and hides the content.
openboolean—Controlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseInitial open state when uncontrolled.
onOpenChange(open: boolean) => void—Called with the next open state when the button is pressed.
disabledbooleanfalseDisables the button so the panel cannot be toggled.
triggerClassNamestring—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.
contentClassNamestring—Classes for the padded content wrapper (indented to the title text). className styles the outer element.
...propsComponentProps<"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.tsxShow
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>
  );
}