Skip to content

CollapsiblePanel

SM · 950B gzip budget

Single collapsible content panel with smooth animation.

Category
Media & layout
Budget
sm, single-purpose control
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale

Content

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/collapsible-panel.json

Copies the source into your project.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import React, { useCallback, useRef, useState } from "react";

export interface CollapsiblePanelProps {
  trigger: React.ReactNode;
  children: React.ReactNode;
  defaultOpen?: boolean;
  className?: string;
  triggerClassName?: string;
  contentClassName?: string;
}

export function CollapsiblePanel({
  trigger,
  children,
  defaultOpen = false,
  className = "",
  triggerClassName = "",
  contentClassName = "",
}: CollapsiblePanelProps) {
  const [isOpen, setIsOpen] = useState(defaultOpen);
  const contentRef = useRef<HTMLDivElement>(null);
  const [height, setHeight] = useState<number | undefined>(
    defaultOpen ? undefined : 0
  );

  const toggle = useCallback(() => {
    setIsOpen((prev) => {
      const next = !prev;
      if (next) {
        const el = contentRef.current;
        if (el) {
          setHeight(el.scrollHeight);
          requestAnimationFrame(() => setHeight(undefined));
        }
      } else {
        const el = contentRef.current;
        if (el) {
          setHeight(el.scrollHeight);
          requestAnimationFrame(() => setHeight(0));
        }
      }
      return next;
    });
  }, []);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === "Enter" || e.key === " ") {
        e.preventDefault();
        toggle();
      }
    },
    [toggle]
  );

  return (
    <div className={className}>
      <button
        type="button"
        aria-expanded={isOpen}
        className={triggerClassName}
        onClick={toggle}
        onKeyDown={handleKeyDown}
        style={{
          display: "flex",
          alignItems: "center",
          gap: "0.5rem",
          background: "none",
          border: "none",
          cursor: "pointer",
          fontWeight: 500,
          padding: "0.5rem 0",
        }}
      >
        <span
          aria-hidden="true"
          style={{
            display: "inline-block",
            transition: "transform 0.2s ease",
            transform: isOpen ? "rotate(90deg)" : "rotate(0deg)",
          }}
        >
          ▸
        </span>
        {trigger}
      </button>
      <div
        ref={contentRef}
        role="region"
        className={contentClassName}
        style={{
          display: "grid",
          gridTemplateRows: isOpen ? "1fr" : "0fr",
          transition: "grid-template-rows 0.3s ease",
        }}
      >
        <div style={{ overflow: "hidden" }}>
          <div style={{ paddingBlock: isOpen ? "0.5rem" : 0 }}>
            {children}
          </div>
        </div>
      </div>
    </div>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation