Collapsible

An interactive component that expands/collapses a panel.

The Collapsible component is an interactive element that allows users to expand or collapse a panel of content. It features bold borders and shadows that match the Things design system. Built from scratch using React and native HTML elements. No dependencies on any UI library.

Code

TypeScript: Copy this code into components/ui/collapsible.tsx:

tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

interface CollapsibleContextValue {
  open: boolean
  onOpenChange: (open: boolean) => void
}

const CollapsibleContext = React.createContext<CollapsibleContextValue | undefined>(undefined)

const useCollapsible = () => {
  const context = React.useContext(CollapsibleContext)
  if (!context) {
    throw new Error("Collapsible components must be used within Collapsible")
  }
  return context
}

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

const Collapsible = React.forwardRef<HTMLDivElement, CollapsibleProps>(
  ({ open: controlledOpen, defaultOpen = false, onOpenChange, disabled = false, children, className, ...props }, ref) => {
    const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
    const isControlled = controlledOpen !== undefined
    const open = isControlled ? controlledOpen : uncontrolledOpen

    const handleOpenChange = React.useCallback((newOpen: boolean) => {
      if (disabled) return
      if (!isControlled) {
        setUncontrolledOpen(newOpen)
      }
      onOpenChange?.(newOpen)
    }, [disabled, isControlled, onOpenChange])

    return (
      <CollapsibleContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
        <div ref={ref} className={cn("w-full", className)} {...props}>
          {children}
        </div>
      </CollapsibleContext.Provider>
    )
  }
)
Collapsible.displayName = "Collapsible"

interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}

const CollapsibleTrigger = React.forwardRef<HTMLButtonElement, CollapsibleTriggerProps>(
  ({ className, children, ...props }, ref) => {
    const { open, onOpenChange } = useCollapsible()

    return (
      <button
        ref={ref}
        type="button"
        onClick={() => onOpenChange(!open)}
        className={cn(
          "flex w-full items-center justify-between bg-primary text-primary-foreground p-4 font-bold transition-colors hover:bg-primary/90 border-2 border-foreground rounded-lg neobrutalism-shadow",
          className
        )}
        {...props}
      >
        {children}
        <svg
          className={cn(
            "h-4 w-4 shrink-0 transition-transform duration-200",
            open && "rotate-180"
          )}
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2}
            d="M19 9l-7 7-7-7"
          />
        </svg>
      </button>
    )
  }
)
CollapsibleTrigger.displayName = "CollapsibleTrigger"

interface CollapsibleContentProps extends React.HTMLAttributes<HTMLDivElement> {}

const CollapsibleContent = React.forwardRef<HTMLDivElement, CollapsibleContentProps>(
  ({ className, children, ...props }, ref) => {
    const { open } = useCollapsible()

    return (
      <div
        ref={ref}
        className={cn(
          "overflow-hidden transition-all duration-200",
          open ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0"
        )}
        {...props}
      >
        <div className={cn("p-4 bg-background border-2 border-foreground border-t-0 rounded-b-lg neobrutalism-shadow mt-2", className)}>
          {children}
        </div>
      </div>
    )
  }
)
CollapsibleContent.displayName = "CollapsibleContent"

export { Collapsible, CollapsibleTrigger, CollapsibleContent }

JavaScript: Copy this code into components/ui/collapsible.jsx:

jsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const CollapsibleContext = React.createContext(undefined)

const useCollapsible = () => {
  const context = React.useContext(CollapsibleContext)
  if (!context) {
    throw new Error("Collapsible components must be used within Collapsible")
  }
  return context
}

const Collapsible = React.forwardRef(
  ({ open: controlledOpen, defaultOpen = false, onOpenChange, disabled = false, children, className, ...props }, ref) => {
    const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
    const isControlled = controlledOpen !== undefined
    const open = isControlled ? controlledOpen : uncontrolledOpen

    const handleOpenChange = React.useCallback((newOpen) => {
      if (disabled) return
      if (!isControlled) {
        setUncontrolledOpen(newOpen)
      }
      onOpenChange?.(newOpen)
    }, [disabled, isControlled, onOpenChange])

    return (
      <CollapsibleContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
        <div ref={ref} className={cn("w-full", className)} {...props}>
          {children}
        </div>
      </CollapsibleContext.Provider>
    )
  }
)
Collapsible.displayName = "Collapsible"

const CollapsibleTrigger = React.forwardRef(
  ({ className, children, ...props }, ref) => {
    const { open, onOpenChange } = useCollapsible()

    return (
      <button
        ref={ref}
        type="button"
        onClick={() => onOpenChange(!open)}
        className={cn(
          "flex w-full items-center justify-between bg-primary text-primary-foreground p-4 font-bold transition-colors hover:bg-primary/90 border-2 border-foreground rounded-lg neobrutalism-shadow",
          className
        )}
        {...props}
      >
        {children}
        <svg
          className={cn(
            "h-4 w-4 shrink-0 transition-transform duration-200",
            open && "rotate-180"
          )}
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2}
            d="M19 9l-7 7-7-7"
          />
        </svg>
      </button>
    )
  }
)
CollapsibleTrigger.displayName = "CollapsibleTrigger"

const CollapsibleContent = React.forwardRef(
  ({ className, children, ...props }, ref) => {
    const { open } = useCollapsible()

    return (
      <div
        ref={ref}
        className={cn(
          "overflow-hidden transition-all duration-200",
          open ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0"
        )}
        {...props}
      >
        <div className={cn("p-4 bg-background border-2 border-foreground border-t-0 rounded-b-lg neobrutalism-shadow mt-2", className)}>
          {children}
        </div>
      </div>
    )
  }
)
CollapsibleContent.displayName = "CollapsibleContent"

export { Collapsible, CollapsibleTrigger, CollapsibleContent }

Usage

TypeScript:

tsx
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"

function MyComponent() {
  return (
    <Collapsible>
      <CollapsibleTrigger>@marvellousz starred 3 repositories</CollapsibleTrigger>
      <CollapsibleContent>
        <div className="space-y-2">
          <div className="p-2 border-2 border-foreground rounded neobrutalism-shadow-sm">@radix-ui/primitives</div>
          <div className="p-2 border-2 border-foreground rounded neobrutalism-shadow-sm">@radix-ui/colors</div>
          <div className="p-2 border-2 border-foreground rounded neobrutalism-shadow-sm">@stitches/react</div>
        </div>
      </CollapsibleContent>
    </Collapsible>
  )
}

JavaScript:

jsx
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"

function MyComponent() {
  return (
    <Collapsible>
      <CollapsibleTrigger>@marvellousz starred 3 repositories</CollapsibleTrigger>
      <CollapsibleContent>
        <div className="space-y-2">
          <div className="p-2 border-2 border-foreground rounded neobrutalism-shadow-sm">@radix-ui/primitives</div>
          <div className="p-2 border-2 border-foreground rounded neobrutalism-shadow-sm">@radix-ui/colors</div>
          <div className="p-2 border-2 border-foreground rounded neobrutalism-shadow-sm">@stitches/react</div>
        </div>
      </CollapsibleContent>
    </Collapsible>
  )
}

Make sure you also have the lib/utils.ts file with the cn helper function.

Examples

@radix-ui/primitives
@radix-ui/colors
@stitches/react

This collapsible is open by default.