GitHub

Collapsible

A simpler expand/collapse primitive compared to Accordion. For single-section toggling without the accordion group behavior.

Install

$npx react-principles add collapsible
01

Features

  • Controlled & Uncontrolled: Works with both open prop and defaultOpen prop
  • Smooth Animation: CSS Grid transition for 200ms height animation
  • Keyboard Accessible: Native button element with Enter/Space support
  • Disabled State: Prevents toggling with visual feedback
  • Compound Component: Collapsible.Trigger and Collapsible.Content sub-components
02

Live Demo

This is an uncontrolled collapsible. It manages its own open/closed state internally. Click the trigger to toggle.

03

Code Snippet

Collapsible.tsx
import { Collapsible } from "@/ui/Collapsible";

// Uncontrolled
<Collapsible defaultOpen>
  <Collapsible.Trigger>Toggle</Collapsible.Trigger>
  <Collapsible.Content>
    <div>Content that animates</div>
  </Collapsible.Content>
</Collapsible>

// Controlled
function MyComponent() {
  const [open, setOpen] = useState(false);
  return (
    <Collapsible open={open} onOpenChange={setOpen}>
      <Collapsible.Trigger>Toggle</Collapsible.Trigger>
      <Collapsible.Content>
        <div>Content</div>
      </Collapsible.Content>
    </Collapsible>
  );
}

// With chevron icon
<Collapsible>
  <Collapsible.Trigger className="flex items-center gap-2">
    <span>Section</span>
    <ChevronDownIcon className="h-4 w-4 transition-transform duration-200" />
  </Collapsible.Trigger>
  <Collapsible.Content>
    <div>Content</div>
  </Collapsible.Content>
</Collapsible>

// Disabled
<Collapsible disabled>
  <Collapsible.Trigger>Cannot Toggle</Collapsible.Trigger>
  <Collapsible.Content>
    <div>Locked content</div>
  </Collapsible.Content>
</Collapsible>
04

Copy-Paste (Single File)

Collapsible.tsx
"use client";

import React, { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import { cn } from "@/shared/utils/cn";

// ─── Types ────────────────────────────────────────────────────────────────────

export interface CollapsibleProps {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  disabled?: boolean;
  children: ReactNode;
  className?: string;
}

export interface CollapsibleTriggerProps {
  children: ReactNode;
  className?: string;
}

export interface CollapsibleContentProps {
  children: ReactNode;
  className?: string;
}

// ─── Context ───────────────────────────────────────────────────────────────────

interface CollapsibleContextValue {
  open: boolean;
  toggle: () => void;
  disabled: boolean;
}

const CollapsibleContext = createContext<CollapsibleContextValue | null>(null);

function useCollapsibleContext() {
  const context = useContext(CollapsibleContext);
  if (!context) {
    throw new Error("Collapsible sub-components must be used inside <Collapsible>");
  }
  return context;
}

// ─── Main Component ───────────────────────────────────────────────────────────

export function Collapsible({
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  disabled = false,
  children,
  className,
}: CollapsibleProps) {
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
  const isControlled = controlledOpen !== undefined;
  const open = isControlled ? controlledOpen : internalOpen;

  const toggle = useCallback(() => {
    if (disabled) return;

    const next = !open;
    if (!isControlled) {
      setInternalOpen(next);
    }
    if (onOpenChange) {
      onOpenChange(next);
    }
  }, [open, isControlled, disabled, onOpenChange]);

  return (
    <CollapsibleContext.Provider value={{ open, toggle, disabled }}>
      <div className={className}>{children}</div>
    </CollapsibleContext.Provider>
  );
}

// ─── Trigger Sub-Component ─────────────────────────────────────────────────────

Collapsible.Trigger = function CollapsibleTrigger({ children, className }: CollapsibleTriggerProps) {
  const { open, toggle, disabled } = useCollapsibleContext();

  return (
    <button
      type="button"
      aria-expanded={open}
      aria-disabled={disabled}
      disabled={disabled}
      onClick={toggle}
      className={cn(
        "flex w-full items-center justify-between",
        "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-primary/40",
        disabled && "opacity-50 cursor-not-allowed",
        !disabled && "cursor-pointer",
        className
      )}
    >
      {children}
    </button>
  );
};

// ─── Content Sub-Component ─────────────────────────────────────────────────────

Collapsible.Content = function CollapsibleContent({ children, className }: CollapsibleContentProps) {
  const { open } = useCollapsibleContext();

  return (
    <div
      style={{
        display: "grid",
        gridTemplateRows: open ? "1fr" : "0fr",
        transition: "grid-template-rows 0.2s ease",
      }}
    >
      <div style={{ overflow: "hidden" }}>
        <div className={className}>{children}</div>
      </div>
    </div>
  );
};
05

Usage Examples

Uncontrolled with defaultOpen

<Collapsible defaultOpen>
  <Collapsible.Trigger>
    <span>Section Title</span>
  </Collapsible.Trigger>
  <Collapsible.Content>
    <div className="p-4">
      Content that starts open
    </div>
  </Collapsible.Content>
</Collapsible>

Controlled with external state

function MyComponent() {
  const [open, setOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setOpen(!open)}>
        External Toggle: {open ? "Close" : "Open"}
      </button>
      <Collapsible open={open} onOpenChange={setOpen}>
        <Collapsible.Trigger>
          <span>Section Title</span>
        </Collapsible.Trigger>
        <Collapsible.Content>
          <div className="p-4">
            Controlled content
          </div>
        </Collapsible.Content>
      </Collapsible>
    </div>
  );
}

With animated chevron icon

<Collapsible>
  <Collapsible.Trigger className="flex items-center gap-2">
    <span>Toggle Section</span>
    <svg
      className="h-4 w-4 transition-transform duration-200"
      style={{ transform: open ? 'rotate(180deg)' : 'none' }}
      viewBox="0 0 16 16"
      fill="none"
    >
      <path
        d="M4 6l4 4 4-4"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  </Collapsible.Trigger>
  <Collapsible.Content>
    <div className="p-4">
      Content with animated icon
    </div>
  </Collapsible.Content>
</Collapsible>

Disabled state

<Collapsible disabled>
  <Collapsible.Trigger>
    <span>Cannot Toggle</span>
  </Collapsible.Trigger>
  <Collapsible.Content>
    <div className="p-4">
      This section is locked
    </div>
  </Collapsible.Content>
</Collapsible>
06

Props

ComponentPropTypeDefaultDescription
CollapsibleopenbooleanControlled open state
defaultOpenbooleanfalseInitial open state (uncontrolled)
onOpenChange(open: boolean) => voidCallback when open state changes
disabledbooleanfalsePrevents toggling when true
classNamestringAdditional CSS classes
Collapsible.TriggerchildrenReactNodeTrigger content
classNamestringAdditional CSS classes
Collapsible.ContentchildrenReactNodeCollapsible content
classNamestringAdditional CSS classes
React Principles