# Collapsible

Show and hide a panel, preserve its contents, and animate its height.

Use a collapsible to show and hide one panel of content. For a set of panels, use an [accordion](/docs/vue/components/accordion).

```vue title="basic.vue"
<script setup lang="ts">
import { ChevronDownIcon } from "@lucide/vue"
import {
  Collapsible,
  CollapsiblePanel,
  CollapsibleTrigger,
} from "@ui-sdk/vue/collapsible"

const items = [
  { name: "Linen shirt", price: "$48.00" },
  { name: "Canvas tote", price: "$18.00" },
  { name: "Wool socks", price: "$18.00" },
]
</script>

<template>
  <Collapsible data-variant="card">
    <CollapsibleTrigger>
      <span data-slot="stack">
        <span>Order #4189</span>
        <span data-slot="hint">3 items, $84.00</span>
      </span>
      <ChevronDownIcon />
    </CollapsibleTrigger>
    <CollapsiblePanel>
      <div v-for="item in items" :key="item.name" data-slot="row">
        <span>{{ item.name }}</span>
        <span>{{ item.price }}</span>
      </div>
    </CollapsiblePanel>
  </Collapsible>
</template>
```

Press Order #4189, or focus it and press Enter or Space, to show the items.

## Installation

```bash
npm install @ui-sdk/vue @ui-sdk/core
```

## Composition

Use the following composition to build a `Collapsible`:

```text
Collapsible
├── CollapsibleTrigger
└── CollapsiblePanel
```

The root and panel render divs, and the trigger renders a native button. Put other content around them, like a heading or a summary that always shows.

## Usage

Import the parts and put them together:

```vue
<script setup lang="ts">
import {
  Collapsible,
  CollapsiblePanel,
  CollapsibleTrigger,
} from "@ui-sdk/vue/collapsible"
</script>

<template>
  <Collapsible>
    <CollapsibleTrigger>Order details</CollapsibleTrigger>
    <CollapsiblePanel>2 × Linen shirt</CollapsiblePanel>
  </Collapsible>
</template>
```

## Build Your Own

`@ui-sdk/vue/collapsible` comes with a ready-made Collapsible. If you'd like to build your own, with your own names, start from the `collapsible` primitive in `@ui-sdk/core`:

```ts title="spoiler.ts"
import { collapsible } from "@ui-sdk/core/collapsible"
import { createParts } from "@ui-sdk/vue/primitive"

const {
  Root: Spoiler,
  Trigger: SpoilerToggle,
  Panel: SpoilerContent,
} = createParts(collapsible, {
  root: "div",
  trigger: "button",
  panel: "div",
})

export { Spoiler, SpoilerContent, SpoilerToggle }
```

```vue
<template>
  <Spoiler>
    <SpoilerToggle>Show the ending</SpoilerToggle>
    <SpoilerContent>The butler did it.</SpoilerContent>
  </Spoiler>
</template>
```

Each part keeps the behavior, state and data attributes of its ready-made counterpart. See [Rendering](/docs/vue/rendering) and [Custom Parts](/docs/vue/custom-parts) for more.

## Disabled

Set `disabled` on the root to stop its trigger from opening or closing the panel. The trigger stays in the tab order and reports `aria-disabled="true"`. A disabled `<fieldset>` around the collapsible disables it too.

```vue
<Collapsible disabled>
  <CollapsibleTrigger>Gift options</CollapsibleTrigger>
  <CollapsiblePanel>Add a gift message and wrapping.</CollapsiblePanel>
</Collapsible>
```

## Keep Mounted

Panels unmount when they close, so Tab skips their contents. Set `keep-mounted` on the panel to keep it mounted and hidden, so a field keeps what you typed into it.

```vue title="keep-mounted.vue"
<script setup lang="ts">
import { ChevronDownIcon } from "@lucide/vue"
import {
  Collapsible,
  CollapsiblePanel,
  CollapsibleTrigger,
} from "@ui-sdk/vue/collapsible"
import { Field, FieldLabel } from "@ui-sdk/vue/field"
import { Textarea } from "@ui-sdk/vue/textarea"
</script>

<template>
  <Collapsible data-variant="card">
    <CollapsibleTrigger>
      <span data-slot="stack">
        <span>Add a delivery note</span>
        <span data-slot="hint">Optional, for the courier</span>
      </span>
      <ChevronDownIcon />
    </CollapsibleTrigger>
    <CollapsiblePanel keep-mounted>
      <Field>
        <FieldLabel>Delivery note</FieldLabel>
        <Textarea placeholder="Leave it with the front desk…" />
      </Field>
    </CollapsiblePanel>
  </Collapsible>
</template>
```

Open Add a delivery note, type a note, then close and reopen the panel. Your note is still there.

## Find in Page

Set `hidden-until-found` on the panel to keep it in the DOM as `hidden="until-found"`. Browser Find and links to content inside the panel can reveal it. The panel opens without an animation so the match is visible immediately.

Vue renders `hidden="until-found"` on the server, so the closed panel is findable before hydration.

```vue title="find-in-page.vue"
<script setup lang="ts">
import { ChevronDownIcon } from "@lucide/vue"
import {
  Collapsible,
  CollapsiblePanel,
  CollapsibleTrigger,
} from "@ui-sdk/vue/collapsible"
</script>

<template>
  <Collapsible data-variant="card">
    <CollapsibleTrigger>
      <span data-slot="stack">
        <span>Ingredients</span>
        <span data-slot="hint">Morning buns, makes 12</span>
      </span>
      <ChevronDownIcon />
    </CollapsibleTrigger>
    <CollapsiblePanel hidden-until-found>
      <p>Flour, butter, brown sugar, eggs, a pinch of cardamom and sea salt.</p>
    </CollapsiblePanel>
  </Collapsible>
</template>
```

Leave Ingredients closed. Open your browser's Find command and search for cardamom to reveal the panel.

## Controlled

Use `v-model:open` to own the open state. `@open-change` also gets the change details: call `details.cancel()` in it to cancel the change.

For a button elsewhere on the page, create a [controller](#controller) with `createCollapsibleController()` and pass it as `controller`. Create one controller per mounted collapsible.

```vue title="controlled.vue"
<script setup lang="ts">
import { ChevronDownIcon } from "@lucide/vue"
import type { ChangeDetails } from "@ui-sdk/core"
import {
  Collapsible,
  CollapsiblePanel,
  CollapsibleTrigger,
  createCollapsibleController,
} from "@ui-sdk/vue/collapsible"
import { ref } from "vue"
import { useLog } from "./log"

const recoveryCodes = ["7KQ2-M9XD", "P4TR-8HZC", "W3NB-6JUE", "C5YF-2LSA"]

const log = useLog()
const collapsible = createCollapsibleController()
const open = ref(false)
</script>

<template>
  <div data-slot="stack">
    <div data-slot="controls">
      <button type="button" @click="collapsible.toggle()">Toggle</button>
    </div>
    <Collapsible
      v-model:open="open"
      data-variant="card"
      :controller="collapsible"
      @open-change="
        (next: boolean, details: ChangeDetails) =>
          log(`${next ? 'Opened' : 'Closed'} (${details.reason})`)
      "
    >
      <CollapsibleTrigger>
        <span data-slot="stack">
          <span>Recovery codes</span>
          <span data-slot="hint">Each code signs you in once</span>
        </span>
        <ChevronDownIcon />
      </CollapsibleTrigger>
      <CollapsiblePanel>
        <div data-slot="stack">
          <ul>
            <li v-for="code in recoveryCodes" :key="code">{{ code }}</li>
          </ul>
          <button type="button" @click="collapsible.close()">Done</button>
        </div>
      </CollapsiblePanel>
    </Collapsible>
  </div>
</template>
```

Open Recovery codes with Toggle or its trigger, then press Done. The console logs each change and its reason, and Done returns focus to the trigger.

## Animation

Animate the measured `--collapsible-panel-height` between zero and the content's height. `data-starting-style` marks the opening frame, and `data-ending-style` stays on while it closes. The panel stays mounted until its closing animation finishes, and a panel open on its first paint doesn't animate.

```css
[data-slot="collapsible-panel"] {
  height: var(--collapsible-panel-height);
  overflow: hidden;
  transition: height 0.2s ease-out;

  &:is([data-starting-style], [data-ending-style]) {
    height: 0;
  }
}
```

`@open-change-complete` reports when opening or closing has finished.

```vue title="animation.vue"
<script setup lang="ts">
import { ChevronDownIcon } from "@lucide/vue"
import {
  Collapsible,
  CollapsiblePanel,
  CollapsibleTrigger,
} from "@ui-sdk/vue/collapsible"
import { useLog } from "./log"

const notes = [
  "Search now matches file contents, not only names.",
  "Shared links remember the folder they were opened from.",
  "Uploads resume after the connection drops.",
]

const log = useLog()
</script>

<template>
  <Collapsible
    data-variant="card"
    @open-change-complete="
      (open: boolean) => log(open ? 'Finished opening' : 'Finished closing')
    "
  >
    <CollapsibleTrigger>
      <span data-slot="stack">
        <span>Release notes</span>
        <span data-slot="hint">Version 2.4, released October 28</span>
      </span>
      <ChevronDownIcon />
    </CollapsibleTrigger>
    <CollapsiblePanel>
      <ul>
        <li v-for="note in notes" :key="note">{{ note }}</li>
      </ul>
    </CollapsiblePanel>
  </Collapsible>
</template>
```

Open and close Release notes. The console logs when each animation finishes.

## Custom Rendering

Use a part's `#render` slot to choose its element, and read its state from that slot or the default slot. The trigger below renders inside a heading, and the panel renders as an ordered list.

```vue title="render.vue"
<script setup lang="ts">
import { ChevronDownIcon } from "@lucide/vue"
import {
  Collapsible,
  CollapsiblePanel,
  CollapsibleTrigger,
} from "@ui-sdk/vue/collapsible"

const updates = [
  { label: "Out for delivery", time: "07:40" },
  { label: "Arrived at the local depot", time: "05:12" },
  { label: "Left the sorting center", time: "Wed 21:03" },
]
</script>

<template>
  <Collapsible data-variant="card">
    <CollapsibleTrigger>
      <template #render="{ props, children }">
        <h3>
          <button v-bind="props"><component :is="children" /></button>
        </h3>
      </template>
      <template #default="{ state }">
        <span data-slot="stack">
          <span>Track parcel</span>
          <span data-slot="hint">{{
            state.open ? "3 updates" : "Arrives Thursday"
          }}</span>
        </span>
        <ChevronDownIcon />
      </template>
    </CollapsibleTrigger>
    <CollapsiblePanel>
      <template #render="{ props }">
        <ol v-bind="props">
          <li v-for="update in updates" :key="update.label" data-slot="row">
            <span>{{ update.label }}</span>
            <span>{{ update.time }}</span>
          </li>
        </ol>
      </template>
    </CollapsiblePanel>
  </Collapsible>
</template>
```

Press Track parcel to open the ordered list. The summary changes from Arrives Thursday to 3 updates.

## Styling

Use `data-open`, `data-closed` and `data-disabled` to style each state. The examples turn their chevron from the trigger's `data-open`.

Each part has a `data-slot`, such as `collapsible-panel`, and its state attributes are listed under [Data Attributes](#data-attributes).

### CollapsiblePanel
- `--collapsible-panel-height`: The panel's content height in pixels while it animates, auto once open and 0px while closed.
- `--collapsible-panel-width`: The panel's content width, the same way. For panels that open sideways.

## Data Attributes

### Collapsible
`[data-slot="collapsible"]`

- `data-open`: Present while the panel is open.
- `data-closed`: Present while the panel is closed, including while it animates out.
- `data-disabled`: Present when the collapsible can't open or close.

### CollapsibleTrigger
`[data-slot="collapsible-trigger"]`

- `data-open`: Present while the panel is open.
- `data-closed`: Present while the panel is closed, including while it animates out.
- `data-disabled`: Present when the collapsible can't open or close.

### CollapsiblePanel
`[data-slot="collapsible-panel"]`

- `data-open`: Present while the panel is open.
- `data-closed`: Present while the panel is closed, including while it animates out.
- `data-disabled`: Present when the collapsible can't open or close.
- `data-starting-style`: Present for the panel's first frame. Style the state you animate from.
- `data-ending-style`: Present while the panel animates closed. Style the state you animate to.

## API Reference

Props are the same as below. `onOpenChange` is `v-model:open`, or `@open-change` for the details, and
`onOpenChangeComplete` is `@open-change-complete`.

### Collapsible

- `open?` (`boolean`): Whether the panel is open. Pass it to control the collapsible.
- `defaultOpen?` (`boolean`): Whether the panel is open at first, when uncontrolled.
- `@open-change` (`((open: boolean, details: ChangeDetails<CollapsibleOpenChangeReason>) => void)`): Runs when the panel wants to open or close. `details.reason` says why, and `details.cancel()` stops it.
- `@open-change-complete` (`((open: boolean) => void)`): Runs once the panel has finished opening or closing, after any animation.
- `disabled?` (`boolean`): Stops the trigger from opening or closing the panel. The trigger stays focusable.
- `controller?` (`object`): Opens and closes the panel from anywhere. Create one with `createCollapsibleController()`.
- `debug?` (`boolean`): Logs every state change with its reason to the console, in development builds.

### CollapsibleTrigger

The trigger takes no options of its own, only the props of the element it renders.

### CollapsiblePanel

- `keepMounted?` (`boolean`): Whether the panel stays in the page, hidden, while closed.
- `hiddenUntilFound?` (`boolean`): Keeps the closed panel in the page as `hidden="until-found"`, so the browser's find in page can match its text and open it.

### State

What render functions, state-based class names and slots receive. The trigger and the panel receive the same
state as the collapsible.

- `open` (`boolean`): Whether the panel is open.
- `disabled` (`boolean`): Whether the trigger ignores presses, by its own `disabled` or a disabled fieldset around it. The `open` option and the controller can still open and close it.

### Reasons

`details.reason` says why the panel opened or closed.

- `trigger-press` (`MouseEvent | KeyboardEvent | TouchEvent`): Click, tap, Enter or Space on the part that opens, closes or toggles. Arrow keys that open a menu or submenu count too. A tap on a combobox's input that opens a sheet reports its `touchend`.
- `find-in-page` (`Event`): The browser's find in page matched hidden content.
- `imperative` (`none`): A controller or `api` call, or a property set on a web component.

### Controller

Create one with `createCollapsibleController()` and pass it as `controller`.

Every write method takes an optional `{ reason, event }`, and returns `false` when the change was canceled or changed nothing.

- `isOpen()` (`() => boolean`): Whether the panel is open.
- `setOpen(open, options?)` (`(open: boolean, options?: CollapsibleChangeOptions | undefined) => boolean`): Opens or closes the panel. Returns false when the change was canceled or not possible.
- `open(options?)` (`(options?: CollapsibleChangeOptions | undefined) => boolean`): Opens the panel.
- `close(options?)` (`(options?: CollapsibleChangeOptions | undefined) => boolean`): Closes the panel.
- `toggle(options?)` (`(options?: CollapsibleChangeOptions | undefined) => boolean`): Opens the panel when it's closed, and closes it when it's open.
- `focus()` (`() => void`): Moves focus to the trigger.

### Events

Events get the new state first, then the details. `v-model` only updates when `@open-change` wasn't canceled.

| Binding                 | On            | Receives                                                     |
| ----------------------- | ------------- | ------------------------------------------------------------ |
| `v-model:open`          | `Collapsible` | Whether the panel is open                                    |
| `@open-change`          | `Collapsible` | `(open, details)`: the new state, and the [reason](#reasons) |
| `@open-change-complete` | `Collapsible` | `(open)`, after the panel's animation                        |

## Accessibility

The collapsible follows the [WAI-ARIA disclosure pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/). The trigger says whether the panel is open with `aria-expanded`, and points to the panel with `aria-controls` while it's mounted. Every line below is an automated test.

### Keyboard

| Key          | Does                                                   |
| ------------ | ------------------------------------------------------ |
| Enter, Space | Opens or closes the panel. Focus stays on the trigger. |
| Tab          | Moves from the trigger into an open panel              |

Closing a panel while focus is inside it moves focus back to the trigger.

### Tested Behavior

**Opening and closing**

- Starts closed, with the panel out of the page.
- Clicking the trigger opens the panel, and clicking again closes it.
- The trigger controls the panel.
- Generated ids start with ui-sdk-.
- It can start open.
- An opening can be canceled.
- A disabled collapsible doesn't open.
- A disabled trigger stays focusable and says it's disabled.
- A controller opens and closes it from anywhere.

**Keyboard**

- Enter toggles the panel and keeps focus on the trigger.
- Space toggles the panel and keeps focus on the trigger.
- Tab moves from the open trigger into the panel.
- Tab skips a closed panel.

**Mounting**

- With keepMounted, the closed panel stays in the page, hidden.
- With keepMounted, a class that sets display doesn't show the closed panel.
- With hiddenUntilFound, the closed panel stays findable.
- With hiddenUntilFound, a class that sets display keeps the closed panel findable.
- Finding text in the closed panel opens it.
- Following a link into the closed panel opens it.
- The browser's own find opens the closed panel.

Runs in React, Vue and web components.

Beyond the pattern's baseline:

**Enhancements**

- Closing the panel while focus is inside it moves focus to the trigger.
- OnOpenChangeComplete runs once the animation has finished.
- Find in page reports its own reason and opens without an animation.
- A canceled find in page keeps the panel hidden until found.
- An infinite exit animation still unmounts the panel.
- Parts report open and closed through data attributes.

Runs in React, Vue and web components.

Screen readers hear the same thing in each tested framework, recorded by a virtual screen reader:

- Announcements while using the collapsible with a keyboard.

Runs in React, Vue and web components.
