# Collapsible

> A single disclosure with smooth height animation.

Category: Layout. Docs: https://neonkit.heyaadi.com/components/collapsible

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/collapsible.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/collapsible`.

npm dependencies: `motion`. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

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

<Collapsible>
  <CollapsibleTrigger>Build steps</CollapsibleTrigger>
  <CollapsibleContent>…</CollapsibleContent>
</Collapsible>
```

## Examples

### Build steps

As the content opens, a line of light draws down its edge and cools off.

```tsx
"use client";

import { Check } from "@/components/ui/icons";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";

const steps = [
  { label: "Cloning github.com/acme/web", detail: "main · 9f3c2ab", time: "1.2s" },
  { label: "Restoring build cache", detail: "412 MB", time: "0.8s" },
  { label: "Installing dependencies", detail: "1,284 packages", time: "9.4s" },
  { label: "Running next build", detail: "38 routes", time: "24.1s" },
  { label: "Uploading static files", detail: "214 files", time: "3.9s" },
  { label: "Assigning acme.dev", detail: "fra1, iad1", time: "0.6s" },
];

export default function CollapsibleDemo() {
  return (
    <div className="w-full max-w-md overflow-hidden rounded-xl border border-white/10 bg-white/[0.03]">
      <div className="flex items-center gap-3 px-4 py-3.5">
        <span className="size-2 shrink-0 rounded-full bg-[rgb(52_211_153)] shadow-[0_0_8px_rgb(52_211_153/0.8)]" />
        <div className="min-w-0 flex-1">
          <p className="truncate text-sm font-medium text-white/90">acme-web · Production</p>
          <p className="truncate text-xs text-white/50">Ready · deployed 4 minutes ago</p>
        </div>
        <span className="font-mono text-xs tabular-nums text-white/55">41.0s</span>
      </div>

      <Collapsible className="border-t border-white/[0.08]">
        <CollapsibleTrigger className="flex h-11 w-full gap-3 rounded-none px-4 text-[13px] focus-visible:ring-inset focus-visible:ring-offset-0">
          <span className="flex-1 text-left">Build steps</span>
          <span className="font-normal text-white/40">{steps.length} passed</span>
        </CollapsibleTrigger>
        <CollapsibleContent>
          <ol className="grid gap-2.5 px-4 pb-4 pt-1">
            {steps.map((step) => (
              <li key={step.label} className="flex items-center gap-3 text-[13px]">
                <Check aria-hidden className="size-3.5 shrink-0 text-[rgb(var(--neon))]" />
                <span className="min-w-0 flex-1 truncate text-white/80">
                  {step.label}
                  <span className="ml-2 text-white/35">{step.detail}</span>
                </span>
                <span className="font-mono text-xs tabular-nums text-white/50">{step.time}</span>
              </li>
            ))}
          </ol>
        </CollapsibleContent>
      </Collapsible>
    </div>
  );
}
```

### Show more

Controlled, so the trigger's label can follow the state. The trigger can sit after the content.

```tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";

const files = [
  { path: "src/components/ui/sheet.tsx", added: 412, removed: 0 },
  { path: "src/registry/docs/sheet.tsx", added: 96, removed: 0 },
  { path: "src/registry/registry.json", added: 12, removed: 1 },
  { path: "src/registry/examples/sheet/demo.tsx", added: 151, removed: 0 },
  { path: "src/registry/examples/sheet/bottom.tsx", added: 82, removed: 0 },
  { path: "src/registry/examples/sheet/navigation.tsx", added: 104, removed: 0 },
  { path: "tests/ui/sheet.test.tsx", added: 139, removed: 0 },
  { path: "CONTRIBUTING.md", added: 3, removed: 2 },
];

function FileRow({ file }: { file: (typeof files)[number] }) {
  return (
    <li className="flex items-center gap-3 py-1.5 font-mono text-xs">
      <span className="min-w-0 flex-1 truncate text-white/75">{file.path}</span>
      <span className="tabular-nums text-[rgb(52_211_153)]">+{file.added}</span>
      <span className={cn("w-6 text-right tabular-nums", file.removed ? "text-[#ff4d5e]" : "text-white/30")}>
        −{file.removed}
      </span>
    </li>
  );
}

export default function CollapsibleShowMore() {
  const [open, setOpen] = React.useState(false);
  const shown = files.slice(0, 3);
  const rest = files.slice(3);

  return (
    <div className="w-full max-w-md">
      <p className="mb-2 text-sm font-medium text-white/85">
        {files.length} files changed
      </p>
      <Collapsible open={open} onOpenChange={setOpen}>
        <ul>
          {shown.map((file) => (
            <FileRow key={file.path} file={file} />
          ))}
        </ul>
        <CollapsibleContent>
          <ul className="pl-3">
            {rest.map((file) => (
              <FileRow key={file.path} file={file} />
            ))}
          </ul>
        </CollapsibleContent>
        <CollapsibleTrigger className="mt-2 text-xs text-white/55">
          {open ? "Show fewer" : `Show ${rest.length} more files`}
        </CollapsibleTrigger>
      </Collapsible>
    </div>
  );
}
```

### Advanced options

forceMount keeps the fields mounted while closed, so edits survive and still submit with the form.

```tsx
"use client";

import * as React from "react";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";

export default function CollapsibleAdvancedOptions() {
  const [saved, setSaved] = React.useState("");

  return (
    <form
      className="grid w-full max-w-sm gap-4"
      onSubmit={(event) => {
        event.preventDefault();
        const data = new FormData(event.currentTarget);
        setSaved(`Building with “${data.get("command")}” into ${data.get("output")}/`);
      }}
    >
      <Input name="repository" label="Repository" defaultValue="acme/web" spellCheck={false} />

      {/* forceMount keeps the fields mounted while closed, so edits survive and still submit. */}
      <Collapsible>
        <CollapsibleTrigger>Build and output settings</CollapsibleTrigger>
        <CollapsibleContent forceMount>
          <div className="grid gap-3 pt-3">
            <Input
              name="command"
              size="sm"
              label="Build command"
              defaultValue="next build"
              spellCheck={false}
              className="font-mono"
            />
            <Input
              name="output"
              size="sm"
              label="Output directory"
              defaultValue=".next"
              description="Relative to the repository root."
              spellCheck={false}
              className="font-mono"
            />
          </div>
        </CollapsibleContent>
      </Collapsible>

      <div className="flex items-center justify-between gap-3">
        <p aria-live="polite" className="min-w-0 truncate text-xs text-white/50">
          {saved}
        </p>
        <Button type="submit" variant="solid" size="sm">
          Deploy
        </Button>
      </div>
    </form>
  );
}
```

## API reference

### Collapsible

Holds the open state. Renders a div and accepts its native attributes.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` | — | Controlled open state. |
| `defaultOpen` | `boolean` | `false` | Initial state when uncontrolled. |
| `onOpenChange` | `(open: boolean) => void` | — | Called when the trigger asks to toggle. |
| `disabled` | `boolean` | `false` | Blocks toggling from the trigger. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

### CollapsibleTrigger

Renders a button with a chevron that turns and lights up while open. Also accepts every native button attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Merges trigger behaviour and ARIA onto its single child element instead. No chevron is added. |

### CollapsibleContent

The part that opens and closes, with a height animation. Accepts native div attributes.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `forceMount` | `boolean` | `false` | Keep the content in the DOM while closed, hidden once the collapse finishes. |

## Accessibility

- The trigger is a button with aria-expanded, and aria-controls pointing at the content while it's open.
- Closed content is unmounted (or hidden with forceMount), so its links and fields leave the tab order.
- Overflow is clipped only during the animation, so focus rings and shadows inside aren't cut off once it's open.
- With reduced motion it opens and closes instantly, and the edge light is skipped.

## Source

`components/ui/collapsible.tsx`

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

/** The single element in `node`. Server Components can pass it as a lazy reference, which Children resolves. */
function resolveElement<P = Record<string, unknown>>(node: React.ReactNode): React.ReactElement<P> | null {
  const [child] = React.Children.toArray(node);
  return React.isValidElement<P>(child) ? child : null;
}

type RenderProp = { asChild?: boolean; render?: React.ReactElement; nativeButton?: boolean; children?: React.ReactNode };

/**
 * Base UI spells asChild as `render={<Link />}` plus `nativeButton`, and the shadcn CLI rewrites
 * asChild into that form in base-* projects. Folds it back into asChild and children.
 */
function fromRenderProp<P extends RenderProp>({ render, nativeButton, ...props }: P): Omit<P, "render" | "nativeButton"> {
  void nativeButton; // Only meaningful to Base UI.
  if (!render) return props;
  const children = props.children === undefined ? render : React.cloneElement(render, undefined, props.children);
  return { ...props, asChild: true, children };
}

const EASE = [0.23, 1, 0.32, 1] as const;

// Overflow is clipped only while the height moves, so focus rings inside aren't cut off at rest.
const variants: Variants = {
  open: { height: "auto", opacity: 1, transitionEnd: { overflow: "visible" } },
  closed: { height: 0, opacity: 0, overflow: "hidden" },
};

type PossibleRef<T> = React.Ref<T> | undefined;

function setRef<T>(ref: PossibleRef<T>, value: T | null) {
  if (typeof ref === "function") ref(value);
  else if (ref) (ref as React.MutableRefObject<T | null>).current = value;
}

function useComposedRefs<T>(a: PossibleRef<T>, b: PossibleRef<T>) {
  return React.useCallback(
    (node: T | null) => {
      setRef(a, node);
      setRef(b, node);
    },
    [a, b],
  );
}

// React 19 reads `ref` from props; React 18 keeps it on the element.
function getElementRef(element: React.ReactElement) {
  if (Number.parseInt(React.version, 10) >= 19) {
    return (element.props as { ref?: React.Ref<HTMLElement> }).ref;
  }
  return (element as unknown as { ref?: React.Ref<HTMLElement> }).ref;
}

type AnyProps = Record<string, unknown>;

/** Merges props and refs onto its single child element. The child's handlers run first. */
const Slot = React.forwardRef<HTMLElement, AnyProps & { children?: React.ReactNode }>(
  ({ children, ...props }, forwardedRef) => {
    const child = resolveElement<AnyProps>(children as React.ReactNode);
    const ref = useComposedRefs(forwardedRef, child ? getElementRef(child) : undefined);
    if (!child) return null;

    const merged: AnyProps = { ...props };
    for (const key of Object.keys(child.props)) {
      const ours = props[key];
      const theirs = child.props[key];
      if (/^on[A-Z]/.test(key) && typeof ours === "function" && typeof theirs === "function") {
        merged[key] = (event: { defaultPrevented?: boolean }) => {
          (theirs as (event: unknown) => void)(event);
          if (!event?.defaultPrevented) (ours as (event: unknown) => void)(event);
        };
      } else if (key === "className") {
        merged.className = cn(theirs as string, ours as string);
      } else if (key === "style") {
        merged.style = { ...(theirs as object), ...(ours as object) };
      } else if (ours === undefined) {
        merged[key] = theirs;
      }
    }
    return React.cloneElement(child, { ...merged, ref } as AnyProps);
  },
);
Slot.displayName = "CollapsibleSlot";

interface CollapsibleContextValue {
  open: boolean;
  setOpen: (open: boolean) => void;
  disabled: boolean;
  contentId: string;
  reduceMotion: boolean;
  /** Counts closed-to-open changes so the edge light replays, but not for content that starts open. */
  openings: number;
}

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

function useCollapsible(component: string) {
  const context = React.useContext(CollapsibleContext);
  if (!context) throw new Error(`<${component}> must be rendered inside <Collapsible>.`);
  return context;
}

export interface CollapsibleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Blocks toggling from the trigger. */
  disabled?: boolean;
  /** Palette name or hex. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
}

const Collapsible = React.forwardRef<HTMLDivElement, CollapsibleProps>(
  (
    { open: openProp, defaultOpen = false, onOpenChange, disabled = false, color, className, style, ...props },
    ref,
  ) => {
    const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
    const isControlled = openProp !== undefined;
    const open = isControlled ? openProp : uncontrolledOpen;
    const id = React.useId();
    const reduceMotion = useReducedMotion() ?? false;

    const [openings, setOpenings] = React.useState(0);
    const [previous, setPrevious] = React.useState(open);
    if (previous !== open) {
      setPrevious(open);
      if (open) setOpenings((count) => count + 1);
    }

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

    const value = React.useMemo<CollapsibleContextValue>(
      () => ({ open, setOpen, disabled, contentId: `${id}-content`, reduceMotion, openings }),
      [open, setOpen, disabled, id, reduceMotion, openings],
    );

    return (
      <CollapsibleContext.Provider value={value}>
        <div
          ref={ref}
          data-state={open ? "open" : "closed"}
          data-disabled={disabled ? "" : undefined}
          style={neonVars(color, style)}
          className={cn("[--neon:var(--neon-accent,34_211_238)]", className)}
          {...props}
        />
      </CollapsibleContext.Provider>
    );
  },
);
Collapsible.displayName = "Collapsible";

export interface CollapsibleTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Merge trigger behaviour onto the single child element instead of rendering a button. */
  asChild?: boolean;
  /** Base UI-style asChild: `render={<Link href="/" />}`. */
  render?: React.ReactElement;
  /** Accepted for Base UI compatibility; no effect. */
  nativeButton?: boolean;
}

const CollapsibleTrigger = React.forwardRef<HTMLButtonElement, CollapsibleTriggerProps>(
  (raw, ref) => {
    const { asChild = false, className, onClick, children, ...props } = fromRenderProp(raw);
    const { open, setOpen, disabled, contentId } = useCollapsible("CollapsibleTrigger");

    const triggerProps = {
      ...props,
      "aria-expanded": open,
      "aria-controls": open ? contentId : undefined,
      "aria-disabled": disabled || undefined,
      "data-state": open ? "open" : "closed",
      "data-disabled": disabled ? "" : undefined,
      onClick: (event: React.MouseEvent<HTMLButtonElement>) => {
        onClick?.(event);
        if (!event.defaultPrevented && !disabled) setOpen(!open);
      },
    };

    if (asChild) {
      return (
        <Slot ref={ref} {...triggerProps} className={className}>
          {children}
        </Slot>
      );
    }

    return (
      <button
        ref={ref}
        type="button"
        disabled={disabled}
        className={cn(
          "inline-flex cursor-pointer select-none items-center gap-2 rounded-md text-sm font-medium text-white/80 outline-none",
          "transition-colors duration-150 ease-out hover:text-white",
          "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
          "disabled:cursor-not-allowed disabled:opacity-50",
          className,
        )}
        {...triggerProps}
      >
        {children}
        <svg
          aria-hidden
          viewBox="0 0 16 16"
          fill="none"
          stroke="currentColor"
          strokeWidth={1.5}
          strokeLinecap="round"
          strokeLinejoin="round"
          className={cn(
            "size-4 shrink-0 transition-[transform,color,filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
            open
              ? "rotate-180 text-[rgb(var(--neon))] [filter:drop-shadow(0_0_4px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1))))]"
              : "text-white/40",
          )}
        >
          <path d="M4 6l4 4 4-4" />
        </svg>
      </button>
    );
  },
);
CollapsibleTrigger.displayName = "CollapsibleTrigger";

export interface CollapsibleContentProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Keep the content in the DOM while closed (hidden), e.g. to preserve form state. */
  forceMount?: boolean;
}

const CollapsibleContent = React.forwardRef<HTMLDivElement, CollapsibleContentProps>(
  ({ forceMount = false, className, children, ...props }, ref) => {
    const { open, contentId, reduceMotion, openings } = useCollapsible("CollapsibleContent");
    const transition = reduceMotion ? { duration: 0 } : { duration: 0.22, ease: EASE };

    // With forceMount, `hidden` lands only after the collapse finishes so it stays visible.
    const [settledClosed, setSettledClosed] = React.useState(!open);
    if (open && settledClosed) setSettledClosed(false);

    const content = (
      <div ref={ref} id={contentId} data-state={open ? "open" : "closed"} className={className} {...props}>
        {children}
      </div>
    );

    // A line of light draws down the revealed edge as it opens, then cools off.
    const edge = open && openings > 0 && !reduceMotion && (
      <motion.span
        key={openings}
        aria-hidden
        data-collapsible-edge=""
        initial={{ scaleY: 0, opacity: 1 }}
        animate={{ scaleY: 1, opacity: 0 }}
        transition={{
          scaleY: { duration: 0.26, ease: EASE },
          opacity: { duration: 0.7, delay: 0.3, ease: "easeOut" },
        }}
        className="pointer-events-none absolute inset-y-0 left-0 w-px origin-top bg-[linear-gradient(180deg,rgb(var(--neon)/0.15),rgb(var(--neon)))] shadow-[0_0_8px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1)))]"
      />
    );

    if (forceMount) {
      return (
        <motion.div
          initial={false}
          animate={open ? "open" : "closed"}
          variants={variants}
          transition={transition}
          onAnimationComplete={() => {
            if (!open) setSettledClosed(true);
          }}
          hidden={!open && settledClosed}
          className="relative"
        >
          {content}
          {edge}
        </motion.div>
      );
    }

    return (
      <AnimatePresence initial={false}>
        {open && (
          <motion.div
            key="content"
            initial="closed"
            animate="open"
            exit="closed"
            variants={variants}
            transition={transition}
            className="relative"
          >
            {content}
            {edge}
          </motion.div>
        )}
      </AnimatePresence>
    );
  },
);
CollapsibleContent.displayName = "CollapsibleContent";

export { Collapsible, CollapsibleTrigger, CollapsibleContent };
```
