Collapsible
A simpler expand/collapse primitive compared to Accordion. For single-section toggling without the accordion group behavior.
Install
$
npx react-principles add collapsible01
Features
- ✓Controlled & Uncontrolled: Works with both
openprop anddefaultOpenprop - ✓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.TriggerandCollapsible.Contentsub-components
02
Live Demo
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
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
| Collapsible | open | boolean | — | Controlled open state |
| defaultOpen | boolean | false | Initial open state (uncontrolled) | |
| onOpenChange | (open: boolean) => void | — | Callback when open state changes | |
| disabled | boolean | false | Prevents toggling when true | |
| className | string | — | Additional CSS classes | |
| Collapsible.Trigger | children | ReactNode | — | Trigger content |
| className | string | — | Additional CSS classes | |
| Collapsible.Content | children | ReactNode | — | Collapsible content |
| className | string | — | Additional CSS classes |