Componentdata2.64 KB gzipZero dependencies

Collapsible

Standalone expandable disclosure card with notch header, animated height reveal and status mark.

Preview

Live Preview
CONTROL DISCLOSURE:
PASS #0942-VIPALL ACCESS

BACKSTAGE VIP PASS

Gate 4 · Soundcheck access & Artist lounge

VALID
TIERVIP GOLD
LOUNGEROOM 3B
SCAN ID#99201
SHOWTIME: 20:30 CSTDOORS OPEN 18:30

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add collapsible

Or install the full npm package:

npm install bigbullui
import { Collapsible } from "@/components/ui/collapsible";

<Collapsible
  title="VIP ADMISSION PASS"
  subtitle="Gate 4 · Section B · Row 12"
  serial="STUB #8841-A"
  status="valid"
  defaultOpen={false}
>
  <p className="text-xs font-mono text-muted-foreground">
    Admit one attendee to the main floor amphitheater with backstage lounge credentials.
  </p>
</Collapsible>

Usage guidance

Use Collapsible when a ticket stub surface needs Standalone expandable disclosure card with notch header, animated height reveal and status mark. Keep props minimal and prefer composition with sibling stubs.

Avoid Collapsible for long form flows or dense data grids where a dedicated layout block fits better.

Source code

collapsible.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

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

export type CollapsibleStatus = "valid" | "admitted" | "void" | "pending" | "vip";

export interface CollapsibleContextValue {
  open: boolean;
  toggle: () => void;
  setOpen: (open: boolean) => void;
  disabled?: boolean;
  contentId: string;
}

const CollapsibleCtx = React.createContext<CollapsibleContextValue | null>(null);

export function useCollapsible(): CollapsibleContextValue {
  const context = React.useContext(CollapsibleCtx);
  if (!context) {
    throw new Error("useCollapsible must be used within a Collapsible");
  }
  return context;
}

export interface CollapsibleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  disabled?: boolean;
  title?: React.ReactNode;
  subtitle?: React.ReactNode;
  serial?: string;
  badge?: string;
  status?: CollapsibleStatus;
  stampText?: string;
  notch?: boolean;
}

export function Collapsible({
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  disabled = false,
  title,
  subtitle,
  serial = "STUB #08421",
  badge,
  status = "valid",
  stampText,
  notch = true,
  className,
  children,
  ...props
}: CollapsibleProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState<boolean>(defaultOpen);
  const isControlled = controlledOpen !== undefined;
  const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
  const contentId = React.useId();

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

  const toggle = React.useCallback(() => {
    setOpen(!isOpen);
  }, [isOpen, setOpen]);

  const contextValue = React.useMemo<CollapsibleContextValue>(
    () => ({
      open: isOpen,
      toggle,
      setOpen,
      disabled,
      contentId,
    }),
    [isOpen, toggle, setOpen, disabled, contentId]
  );

  // Standalone disclosure card mode when title is provided
  if (title) {
    return (
      <CollapsibleCtx.Provider value={contextValue}>
        <div
          data-state={isOpen ? "open" : "closed"}
          className={cn(
            "group relative overflow-hidden rounded-lg border-2 border-foreground bg-card text-card-foreground shadow-sm transition-shadow duration-200 hover:shadow-md",
            isOpen && "ring-1 ring-border",
            className
          )}
          {...props}
        >
          {/* Ticket Header */}
          <div className="p-4 sm:p-5">
            {/* Meta micro row */}
            <div className="flex items-center justify-between gap-2">
              <span className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-accent">
                {serial}
              </span>
              {badge && (
                <span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
                  {badge}
                </span>
              )}
            </div>

            {/* Title & Action row */}
            <div className="mt-2.5 flex flex-wrap items-center justify-between gap-3">
              <div className="min-w-0 flex-1">
                <h3 className="font-mono text-sm sm:text-base font-bold uppercase tracking-wider text-foreground">
                  {title}
                </h3>
                {subtitle && (
                  <p className="mt-0.5 font-mono text-xs text-muted-foreground">
                    {subtitle}
                  </p>
                )}
              </div>

              {/* Stamp & Trigger */}
              <div className="flex items-center gap-2.5 shrink-0">
                {status && (
                  <CollapsibleStamp status={status}>
                    {stampText}
                  </CollapsibleStamp>
                )}
                <CollapsibleTrigger />
              </div>
            </div>
          </div>

          {/* Perforation Divider with Punch Notches */}
          {notch && (
            <div className="relative flex items-center w-full my-0" aria-hidden="true">
              <div className="absolute -left-3 size-5 rounded-full border-2 border-foreground bg-background shrink-0 z-10" />
              <div className="w-full border-t-2 border-dashed border-border" />
              <div className="absolute -right-3 size-5 rounded-full border-2 border-foreground bg-background shrink-0 z-10" />
            </div>
          )}

          {/* Animated Height Reveal Content */}
          <CollapsibleContent>
            <div className="p-4 sm:p-5 bg-secondary/15">
              {children}
            </div>
          </CollapsibleContent>
        </div>
      </CollapsibleCtx.Provider>
    );
  }

  // Compound component container mode
  return (
    <CollapsibleCtx.Provider value={contextValue}>
      <div
        data-state={isOpen ? "open" : "closed"}
        className={cn("relative", className)}
        {...props}
      >
        {children}
      </div>
    </CollapsibleCtx.Provider>
  );
}

export interface CollapsibleHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
  notch?: boolean;
}

export function CollapsibleHeader({
  notch = false,
  className,
  children,
  ...props
}: CollapsibleHeaderProps) {
  return (
    <div className={cn("relative", className)} {...props}>
      {children}
      {notch && (
        <div className="relative flex items-center w-full my-0" aria-hidden="true">
          <div className="absolute -left-3 size-5 rounded-full border-2 border-foreground bg-background shrink-0 z-10" />
          <div className="w-full border-t-2 border-dashed border-border" />
          <div className="absolute -right-3 size-5 rounded-full border-2 border-foreground bg-background shrink-0 z-10" />
        </div>
      )}
    </div>
  );
}

export interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  showChevron?: boolean;
  showStateLabel?: boolean;
  expandedLabel?: string;
  collapsedLabel?: string;
}

export function CollapsibleTrigger({
  showChevron = true,
  showStateLabel = true,
  expandedLabel = "COLLAPSE",
  collapsedLabel = "EXPAND",
  className,
  children,
  onClick,
  ...props
}: CollapsibleTriggerProps) {
  const { open, toggle, disabled, contentId } = useCollapsible();

  return (
    <button
      type="button"
      aria-expanded={open}
      aria-controls={contentId}
      disabled={disabled}
      onClick={(event) => {
        toggle();
        onClick?.(event);
      }}
      className={cn(
        "inline-flex cursor-pointer items-center justify-center gap-1.5 rounded border border-border bg-secondary/80 px-2.5 py-1 font-mono text-[11px] font-bold uppercase tracking-wider text-secondary-foreground transition-all duration-150 select-none hover:bg-secondary hover:border-foreground active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
        className
      )}
      {...props}
    >
      {children ? (
        children
      ) : (
        <>
          {showStateLabel && (
            <span>{open ? expandedLabel : collapsedLabel}</span>
          )}
          {showChevron && (
            <svg
              width="14"
              height="14"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="2.5"
              strokeLinecap="round"
              strokeLinejoin="round"
              className={cn(
                "shrink-0 transition-transform duration-300 ease-out",
                open && "rotate-180"
              )}
              aria-hidden="true"
            >
              <polyline points="6 9 12 15 18 9" />
            </svg>
          )}
        </>
      )}
    </button>
  );
}

export type CollapsibleContentProps = React.HTMLAttributes<HTMLDivElement>;

export function CollapsibleContent({
  className,
  children,
  ...props
}: CollapsibleContentProps) {
  const { open, contentId } = useCollapsible();

  return (
    <div
      id={contentId}
      role="region"
      aria-hidden={!open}
      data-state={open ? "open" : "closed"}
      className={cn(
        "grid transition-[grid-template-rows,opacity] duration-300 ease-out motion-reduce:transition-none",
        open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0 pointer-events-none",
        className
      )}
      {...props}
    >
      <div className="overflow-hidden">
        {children}
      </div>
    </div>
  );
}

export interface CollapsibleStampProps extends React.HTMLAttributes<HTMLSpanElement> {
  status?: CollapsibleStatus;
  rotate?: number;
}

export function CollapsibleStamp({
  status = "valid",
  rotate = -6,
  className,
  children,
  style,
  ...props
}: CollapsibleStampProps) {
  return (
    <span
      style={{
        transform: `rotate(${rotate}deg)`,
        ...style,
      }}
      className={cn(
        "inline-flex items-center justify-center rounded-sm border px-2 py-0.5 font-mono text-[10px] font-black uppercase tracking-widest select-none shadow-xs transition-transform duration-200 hover:scale-105",
        status === "valid" && "border-accent text-accent bg-accent/10",
        status === "admitted" && "border-primary text-primary bg-primary/10",
        status === "void" && "border-destructive text-destructive bg-destructive/10 line-through decoration-destructive/80",
        status === "pending" && "border-muted-foreground text-muted-foreground bg-muted/40",
        status === "vip" && "border-accent-strong text-accent-strong bg-accent-strong/15",
        className
      )}
      {...props}
    >
      {children ?? status.toUpperCase()}
    </span>
  );
}

Props

PropTypeDescription
open?boolean
defaultOpen?boolean
onOpenChange?(open: boolean) => void
disabled?boolean
title?React.ReactNode
subtitle?React.ReactNode
serial?string
badge?string
status?CollapsibleStatus
stampText?string
notch?boolean