Collapsible
Shows and hides a block of content from a button above it.
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.
<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
forceMountfor 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;
MagicAccordionin vue-equipment does.
Anatomy
Three parts, the trigger above the content.
| Name | Required | Description |
|---|---|---|
| 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
Options
Emits
Slot props
Collapsible.Trigger
Renders a <button> carrying aria-expanded and aria-controls.
Props
Slot props
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
Slot props
Composable
useMirrorCollapsible(id) reaches a collapsible from anywhere in the app.
A disabled collapsible ignores all four, so the same guard covers a click, a key and the composable.
Data attributes
CSS variables
Errors
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.
Closed content is behind hidden, or not in the DOM at all, so nothing inside
it is reachable by Tab either way.