Skip to content

Collapsible

Shows and hides a block of content from a button above it.

View source View as Markdown

Collapsible is a button and the content it shows and hides. Useful for detail a reader can ask for rather than has to scroll past, like an advanced settings block or the long half of a summary.

App.vue
<template>
  <Collapsible.Root v-model:open="open" class="w-80">
    <Collapsible.Trigger :class="trigger">
      <span>Show details</span>
      <svg :class="chevron" viewBox="0 0 16 16" aria-hidden="true">
        <path
          d="m4 6.5 4 4 4-4"
          fill="none"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </Collapsible.Trigger>

    <Collapsible.Content>
      <p :class="body">
        The content animates to the height it needs, so nothing here sets a
        height, and the text can grow or shrink without anyone measuring it.
      </p>
    </Collapsible.Content>
  </Collapsible.Root>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Collapsible } from '@maas/mirror/vue'

const open = ref(false)

const trigger = [
  'group flex h-10 w-full items-center justify-between gap-2 px-3',
  'rounded-component-md border-2 border-[transparent] type-component-md',
  'text-surface transition-all duration-100 ease-linear',
  'outline-4 outline-transparent focus-visible:focus-ring',
  'hover:bg-primary-subtle',
  'active:bg-primary-subtle-active',
].join(' ')

const chevron = [
  'size-4.5 shrink-0 text-surface-muted',
  'transition-transform duration-200 ease-out',
  'group-data-[content-open=true]:rotate-180',
].join(' ')

const body = 'type-surface-body-md text-surface-muted px-3 pt-2 pb-3'
</script>

Usage guidelines

  • Put the padding on something inside the content rather than on the content itself. Content behind hidden="until-found" keeps a box of its own, so its padding is height the collapsed box never loses.
  • The height animates whether the closed content is unmounted or kept, so reach for forceMount for what is inside rather than for the motion.
  • For several of these that share a heading structure, or where only one should be open at a time, an accordion is the pattern you want. Mirror does not ship one; MagicAccordion in vue-equipment does.

Anatomy

Three parts, the trigger above the content.

NameRequiredDescription
Collapsible.Root true Renders a <div> and owns the open state.
Collapsible.Trigger true Renders a <button> with aria-expanded and aria-controls.
Collapsible.Content true Renders a <div> inside the box that animates the height.
<script setup lang="ts">
import { Collapsible } from '@maas/mirror/vue'
</script>

<template>
  <Collapsible.Root v-model:open="open">
    <Collapsible.Trigger>Show details</Collapsible.Trigger>
    <Collapsible.Content>…</Collapsible.Content>
  </Collapsible.Root>
</template>

Examples

Controlled and uncontrolled

Leave open out and the collapsible keeps its own state, seeded by defaultOpen. Bind v-model:open and you own it instead, which is decided once at mount.

<template>
  <Collapsible.Root default-open>
    <Collapsible.Trigger>Show details</Collapsible.Trigger>
    <Collapsible.Content>…</Collapsible.Content>
  </Collapsible.Root>

  <Collapsible.Root v-model:open="open">
    <Collapsible.Trigger>Show details</Collapsible.Trigger>
    <Collapsible.Content>…</Collapsible.Content>
  </Collapsible.Root>
</template>

<script setup lang="ts">
const open = ref(false)
</script>

Animating the height

The content sits in a box that interpolates its own height towards whatever the content is, through @maas/vue-autosize. Opening and closing animate on their own: there is no height to write in CSS and nothing to measure.

animation sets the timing. duration is in milliseconds, easing takes a progress between 0 and 1 and returns one, which is the shape of the easing functions @maas/vue-autosize exports.

<template>
  <Collapsible.Root
    :options="{ animation: { duration: 250, easing: easeOutCubic } }"
  >
  </Collapsible.Root>
</template>

<script setup lang="ts">
import { easeOutCubic } from '@maas/vue-autosize'
</script>

duration has to be greater than zero. The height is interpolated over it, and an interpolation with no length never runs, which leaves the content where it was. For a reader who asked for reduced motion, set 1: it takes two frames and reads as instant.

The box clips what hangs out of it while it grows, so a shadow or a focus ring on the content is cut off at the edge until there is room for it. --mirror-collapsible-content-clip-path is where that is turned off.

The content’s own leave transition is separate from the height. Since the height belongs to the box outside it, content that is still fading keeps its own height and the box only follows it down afterwards. Take the content’s box down in the leave class of its transition to have both run at once.

.collapsible-content-leave-to {
  height: 0;
  overflow: visible;
}

Content that is kept in the DOM never leaves, so it styles off data-open instead, and the hidden attribute is only set once its animation has finished.

Keeping the content mounted

Without forceMount the content is unmounted while it is closed, which is the cheaper default and the one to keep for anything expensive. With it the content stays in the DOM behind the hidden attribute, so state inside it survives. The height animates either way.

<template>
  <Collapsible.Root :options="{ forceMount: true }">…</Collapsible.Root>
</template>

Collapsible.Content takes forceMount as a direct prop too, which is the way to keep one instance out of what the option says.

<template>
  <Collapsible.Content :force-mount="false">…</Collapsible.Content>
</template>

Letting the browser find the content

hiddenUntilFound renders hidden="until-found" instead, so the browser’s in-page search reaches the text and opens the collapsible when it matches. It keeps the content mounted whatever forceMount says, since content that is not there cannot be found.

<template>
  <Collapsible.Root :options="{ hiddenUntilFound: true }">…</Collapsible.Root>
</template>

Browsers that do not know until-found fall back to a plain hidden, so the content behaves as if only forceMount were set.

until-found hides content by skipping it rather than the element itself, so the content keeps a box of its own while the box around it collapses. Everything on it that has a size of its own, padding and borders above all, is height the collapsed box keeps. Put both on something inside it.

Transitioning the swap

Where the content unmounts, transition is a Vue transition name and the timing lives in CSS under that name.

<template>
  <Collapsible.Root :options="{ transition: 'collapsible-content' }">
  </Collapsible.Root>
</template>

<style>
.collapsible-content-enter-active,
.collapsible-content-leave-active {
  transition: opacity 150ms ease;
}

.collapsible-content-enter-from,
.collapsible-content-leave-to {
  opacity: 0;
}
</style>

The height is the box’s to animate either way. The transition is what the content itself does while the box grows or shrinks around it.

Reaching it from elsewhere

Give the root an id and useMirrorCollapsible(id) opens and closes it from anywhere in the app.

<script setup lang="ts">
import { useMirrorCollapsible } from '@maas/mirror/vue'

const { isOpen, open, close, toggle } = useMirrorCollapsible('details')
</script>

API reference

Module. A bundled options object, and useMirrorCollapsible(id) as the programmatic API. Every part takes id to resolve a Collapsible it is not nested inside.

Collapsible.Root

Renders a <div>.

Props

PropTypeDefault
id
stringgenerated
open
boolean | undefinedundefined
defaultOpen
booleanfalse
options
CollapsibleOptionssee below

Options

OptionTypeDefault
animation
Partial<CollapsibleAnimation>150ms, easeOutQuad
disabled
booleanfalse
forceMount
booleanfalse
hiddenUntilFound
booleanfalse
transition
stringundefined

Emits

EmitPayload
update:open
boolean

Slot props

PropType
open
boolean
disabled
boolean

Collapsible.Trigger

Renders a <button> carrying aria-expanded and aria-controls.

Props

PropTypeDefault
id
stringinjected
elementId
stringderived
disabled
booleanoptions.disabled

Slot props

PropType
contentOpen
boolean
disabled
boolean

Collapsible.Content

Renders a <div>, inside the box whose height is animated and inside the box that box measures. Everything a consumer passes is applied to the content itself.

Props

PropTypeDefault
id
stringinjected
elementId
stringderived
forceMount
booleanoptions.forceMount

Slot props

PropType
open
boolean

Composable

useMirrorCollapsible(id) reaches a collapsible from anywhere in the app.

KeyType
isOpen
ComputedRef<boolean>
disabled
ComputedRef<boolean>
triggerId
ComputedRef<string>
contentId
ComputedRef<string>
setOpen
(open: boolean) => void
open
() => void
close
() => void
toggle
() => void

A disabled collapsible ignores all four, so the same guard covers a click, a key and the composable.

Data attributes

PartAttributeValue
Collapsible.Root, Collapsible.Content
data-state
open | closed
Collapsible.Root, Collapsible.Content
data-open
true
Collapsible.Trigger
data-content-open
true
Collapsible.Root, Collapsible.Trigger, Collapsible.Content
data-disabled
true

CSS variables

VariableDefault
--mirror-collapsible-content-clip-path
inset(0)
--mirror-collapsible-trigger-cursor
pointer
--mirror-collapsible-trigger-disabled-cursor
not-allowed

Errors

Code
missing_collapsible_context

Accessibility

The trigger is a <button> with aria-expanded and an aria-controls naming the content, whether or not the content is mounted, so forceMount changes nothing about how the pair is announced. A non-native trigger gets role="button", tabindex="0" and aria-disabled rather than leaving the tab order.

KeyAction
EnterOpens or closes the content, on keydown.
SpaceOpens or closes the content, on keyup. The keydown default is prevented.

Closed content is behind hidden, or not in the DOM at all, so nothing inside it is reachable by Tab either way.