Collapsible

An interactive component that expands and collapses a panel.

Order #4189

StatusShipped
"use client"

import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { HStack, VStack } from "@/components/ui/stack"
import { color, leading, radius, space, text } from "@/theme/tokens.stylex"
import { ChevronsUpDown } from "lucide-react"

const styles = stylex.create({
  root: { width: "100%", maxWidth: "22rem" },
  title: { fontSize: text.sm, lineHeight: leading.sm, fontWeight: 600 },
  row: {
    borderRadius: radius.md,
    borderWidth: 1,
    borderStyle: "solid",
    paddingInline: space.s4,
    paddingBlock: space.s2,
    fontSize: text.sm,
    lineHeight: leading.sm,
  },
  muted: { color: color.mutedForeground },
  medium: { fontWeight: 500 },
  content: { paddingBlockStart: space.s2 },
})

export default function CollapsibleDemo() {
  const [open, setOpen] = useState(false)

  return (
    <Collapsible open={open} onOpenChange={setOpen} xstyle={styles.root}>
      <VStack gap={2}>
        <HStack gap={4} align="center" justify="between" paddingInline={4}>
          <h4 {...stylex.props(styles.title)}>Order #4189</h4>
          <CollapsibleTrigger
            render={<Button variant="ghost" size="icon" aria-label="Toggle details" />}
          >
            <ChevronsUpDown />
          </CollapsibleTrigger>
        </HStack>
        <HStack align="center" justify="between" xstyle={styles.row}>
          <span {...stylex.props(styles.muted)}>Status</span>
          <span {...stylex.props(styles.medium)}>Shipped</span>
        </HStack>
      </VStack>
      <CollapsibleContent xstyle={styles.content}>
        <VStack gap={2}>
          <VStack xstyle={styles.row}>
            <p {...stylex.props(styles.medium)}>Shipping address</p>
            <p {...stylex.props(styles.muted)}>100 Market St, San Francisco</p>
          </VStack>
          <VStack xstyle={styles.row}>
            <p {...stylex.props(styles.medium)}>Items</p>
            <p {...stylex.props(styles.muted)}>2x Studio Headphones</p>
          </VStack>
        </VStack>
      </CollapsibleContent>
    </Collapsible>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste state-styles.ts from the same page. The trigger reads its open state and the panel reads its transition state through it. The examples render the trigger as a Button.

Copy and paste the following code into your project.

components/ui/collapsible.tsx
"use client"

import type { CSSProperties } from "react"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { stateStyles } from "@/lib/state-styles"
import { duration, ease } from "@/theme/tokens.stylex"

const styles = stylex.create({
  trigger: {
    "--carbon-collapsible-open": 0,
  },
  triggerOpen: { "--carbon-collapsible-open": 1 },
  content: {
    overflow: "hidden",
    height: "var(--collapsible-panel-height)",
    transitionProperty: "height",
    transitionDuration: {
      default: duration.panel,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.spring,
  },
  contentHidden: { height: 0 },
})

const chevrons = stylex.create({
  chevron: {
    transform: "rotate(calc(180deg * var(--carbon-collapsible-open, 0)))",
    transitionProperty: "transform",
    transitionDuration: {
      default: duration.move,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.standard,
  },
  chevronInline: {
    transform: "rotate(calc(90deg * var(--carbon-collapsible-open, 0)))",
    transitionProperty: "transform",
    transitionDuration: {
      default: duration.move,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.standard,
  },
})

const collapsibleStyles = { chevron: chevrons.chevron, chevronInline: chevrons.chevronInline }

type Overrides = { xstyle?: StyleXStyles; className?: string }

type CollapsibleProps = Omit<CollapsiblePrimitive.Root.Props, "className"> & Overrides

function Collapsible({ xstyle, className, ...props }: CollapsibleProps) {
  const sx = stylex.props(xstyle)
  return (
    <CollapsiblePrimitive.Root
      data-slot="collapsible"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type CollapsibleTriggerProps = Omit<CollapsiblePrimitive.Trigger.Props, "className" | "style"> &
  Overrides & { style?: CSSProperties }

function CollapsibleTrigger({ xstyle, style, className, ...props }: CollapsibleTriggerProps) {
  return (
    <CollapsiblePrimitive.Trigger
      data-slot="collapsible-trigger"
      {...props}
      {...stateStyles((state) => [styles.trigger, state.open && styles.triggerOpen, xstyle], {
        className,
        style,
      })}
    />
  )
}

type CollapsibleContentProps = Omit<CollapsiblePrimitive.Panel.Props, "className" | "style"> &
  Overrides & { style?: CSSProperties }

function CollapsibleContent({
  xstyle,
  style,
  className,
  children,
  ...props
}: CollapsibleContentProps) {
  const sx = stylex.props(xstyle)
  return (
    <CollapsiblePrimitive.Panel
      data-slot="collapsible-content"
      {...props}
      {...stateStyles(
        (state) => [
          styles.content,
          (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
            styles.contentHidden,
        ],
        {
          style,
        },
      )}
    >
      <div data-slot="collapsible-content-inner" {...sx} className={clsx(sx.className, className)}>
        {children}
      </div>
    </CollapsiblePrimitive.Panel>
  )
}

export { Collapsible, CollapsibleContent, CollapsibleTrigger, collapsibleStyles }

Update the import paths to match your project setup.

Usage

import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
<Collapsible>
  <CollapsibleTrigger>Can I use this in my project?</CollapsibleTrigger>
  <CollapsibleContent>
    Yes. Free to use for personal and commercial projects. No attribution required.
  </CollapsibleContent>
</Collapsible>

Composition

Collapsible
├── CollapsibleTrigger
└── CollapsibleContent

CollapsibleTrigger is unstyled. Pass render={<Button />} to make it a button. CollapsibleContent renders the Base UI Panel around an inner element that holds the content box. The panel animates its height over 300ms when it opens and closes.

Put the space above or below the panel inside it, as padding on CollapsibleContent, not as a gap or margin around it. A gap outside stays at full size while the panel shrinks to nothing, then disappears when the panel unmounts, so the close ends with a jump.

Controlled state

Use the open and onOpenChange props to control the state.

import { useState } from "react"

export function Example() {
  const [open, setOpen] = useState(false)

  return (
    <Collapsible open={open} onOpenChange={setOpen}>
      <CollapsibleTrigger>Toggle</CollapsibleTrigger>
      <CollapsibleContent>Content</CollapsibleContent>
    </Collapsible>
  )
}

Basic

Spread collapsibleStyles.chevron on an icon inside the trigger to turn it with the panel.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  collapsibleStyles,
} from "@/components/ui/collapsible"
import { VStack } from "@/components/ui/stack"
import { space } from "@/theme/tokens.stylex"
import { ChevronDown } from "lucide-react"

const styles = stylex.create({
  card: { width: "100%", maxWidth: "24rem" },
  trigger: { width: "100%", justifyContent: "space-between" },
  content: { paddingInline: space.s2_5, paddingBlockEnd: space.s2_5 },
})

export default function CollapsibleBasic() {
  return (
    <Card xstyle={styles.card}>
      <CardContent>
        <Collapsible>
          <CollapsibleTrigger render={<Button variant="ghost" xstyle={styles.trigger} />}>
            Product details
            <ChevronDown {...stylex.props(collapsibleStyles.chevron)} />
          </CollapsibleTrigger>
          <CollapsibleContent xstyle={styles.content}>
            <VStack gap={2} align="start">
              <p>This panel can be expanded or collapsed to reveal additional content.</p>
              <Button size="xs">Learn More</Button>
            </VStack>
          </CollapsibleContent>
        </Collapsible>
      </CardContent>
    </Card>
  )
}

Settings panel

Use a trigger button to reveal additional settings.

Radius
Set the corner radius of the element.
"use client"

import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Input } from "@/components/ui/input"
import { HStack, VStack } from "@/components/ui/stack"
import { space } from "@/theme/tokens.stylex"
import { Maximize, Minimize } from "lucide-react"

const styles = stylex.create({
  card: { width: "100%", maxWidth: "20rem" },
  content: { paddingBlockStart: space.s2 },
})

export default function CollapsibleSettings() {
  const [open, setOpen] = useState(false)

  return (
    <Card size="sm" xstyle={styles.card}>
      <CardHeader>
        <CardTitle>Radius</CardTitle>
        <CardDescription>Set the corner radius of the element.</CardDescription>
      </CardHeader>
      <CardContent>
        <Collapsible open={open} onOpenChange={setOpen}>
          <HStack gap={2} align="start">
            <VStack grow>
              <HStack gap={2}>
                <Input aria-label="Top left radius" placeholder="0" defaultValue={0} />
                <Input aria-label="Top right radius" placeholder="0" defaultValue={0} />
              </HStack>
              <CollapsibleContent xstyle={styles.content}>
                <HStack gap={2}>
                  <Input aria-label="Bottom left radius" placeholder="0" defaultValue={0} />
                  <Input aria-label="Bottom right radius" placeholder="0" defaultValue={0} />
                </HStack>
              </CollapsibleContent>
            </VStack>
            <CollapsibleTrigger
              render={
                <Button
                  variant="outline"
                  size="icon"
                  aria-label={open ? "Fewer options" : "More options"}
                />
              }
            >
              {open ? <Minimize /> : <Maximize />}
            </CollapsibleTrigger>
          </HStack>
        </Collapsible>
      </CardContent>
    </Card>
  )
}

File tree

Use nested collapsibles to build a file tree. collapsibleStyles.chevronInline turns a right-pointing chevron down.

Explorer
import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
  collapsibleStyles,
} from "@/components/ui/collapsible"
import { VStack } from "@/components/ui/stack"
import { color, space } from "@/theme/tokens.stylex"
import { ChevronRight, File, Folder } from "lucide-react"

const styles = stylex.create({
  card: { width: "100%", maxWidth: "16rem" },
  row: { width: "100%", justifyContent: "flex-start" },
  file: { color: color.foreground },
  children: { marginBlockStart: space.s1, marginInlineStart: space.s5 },
})

type FileTreeItem = { name: string; items?: FileTreeItem[] }

const fileTree: FileTreeItem[] = [
  {
    name: "components",
    items: [
      {
        name: "ui",
        items: [{ name: "button.tsx" }, { name: "card.tsx" }, { name: "input.tsx" }],
      },
      { name: "login-form.tsx" },
      { name: "register-form.tsx" },
    ],
  },
  {
    name: "lib",
    items: [{ name: "utils.ts" }, { name: "api.ts" }],
  },
  {
    name: "hooks",
    items: [{ name: "use-media-query.ts" }, { name: "use-debounce.ts" }],
  },
  { name: "app.tsx" },
  { name: "layout.tsx" },
  { name: "package.json" },
]

function TreeItem({ item }: { item: FileTreeItem }) {
  if (item.items) {
    return (
      <Collapsible>
        <CollapsibleTrigger render={<Button variant="ghost" size="sm" xstyle={styles.row} />}>
          <ChevronRight {...stylex.props(collapsibleStyles.chevronInline)} />
          <Folder />
          {item.name}
        </CollapsibleTrigger>
        <CollapsibleContent xstyle={styles.children}>
          <VStack gap={1}>
            {item.items.map((child) => (
              <TreeItem key={child.name} item={child} />
            ))}
          </VStack>
        </CollapsibleContent>
      </Collapsible>
    )
  }
  return (
    <Button variant="link" size="sm" xstyle={[styles.row, styles.file]}>
      <File />
      {item.name}
    </Button>
  )
}

export default function CollapsibleFileTree() {
  return (
    <Card size="sm" xstyle={styles.card}>
      <CardHeader>
        <CardTitle>Explorer</CardTitle>
      </CardHeader>
      <CardContent>
        <VStack gap={1}>
          {fileTree.map((item) => (
            <TreeItem key={item.name} item={item} />
          ))}
        </VStack>
      </CardContent>
    </Card>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI Collapsible part of the same name and accepts its props. See the Base UI reference for the full list. Each part also takes xstyle and className. CollapsibleTrigger and CollapsibleContent take style as real inline CSS, merged over the compiled styles.

Collapsible

PropTypeDefault
openboolean—
defaultOpenbooleanfalse
onOpenChange(open: boolean, details) => void—
disabledbooleanfalse

CollapsibleContent

PropTypeDefault
keepMountedbooleanfalse
hiddenUntilFoundbooleanfalse

hiddenUntilFound hides the closed panel with hidden="until-found" instead of unmounting it, which lets the browser's find-in-page reach the text and open the panel. It overrides keepMounted.

CollapsibleContent applies xstyle and className to the inner element and style to the panel. The panel animates its height between 0 and the measured content height, and a padded box of height 0 still draws its padding.

collapsibleStyles

Styles for an icon inside the trigger. Each reads the open state from the trigger and has its own transition.

KeyWhat it is
chevronTurns a down-pointing chevron up when the panel opens
chevronInlineTurns a right-pointing chevron down when the panel opens

Data attributes

AttributeOnEffect
data-slot="collapsible-content"the panelTargets the animated panel
data-starting-style, data-ending-stylethe panelBase UI writes them while the panel animates
data-open, data-disabledroot, trigger, panelBase UI writes them
data-panel-openthe triggerBase UI writes it

CSS variables

VariableOnEffect
--collapsible-panel-heightthe panelBase UI writes the measured height; the panel transitions to it
--carbon-collapsible-openthe trigger0 closed, 1 open. Inherited, so anything inside the trigger can read it