Use it for
Reach for BbCollapsible when the trigger cannot be a header sitting over the
body. A switch in a settings row, a "Show more" at the end of a paragraph, a
button in a toolbar.
Use something else when
BbAccordion, if the trigger is a title over the body: it writes the button and the ARIA for youBbSmoothHeight, if you hide nothing and only want a height change to animateBbTabs, if they are peer views of one subject and only one stays visible
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
BbAccordion is this component plus a wired header button. Everything on this
page is true there too.
Default
Bind a boolean and supply the control that flips it.
<template>
<div class="flex max-w-md flex-col items-start gap-2">
<!--
The trigger is yours, and so is its ARIA: `aria-expanded` says whether
the region is open, `aria-controls` says which region it is.
-->
<BbButton
aria-controls="collapsible-basic-panel"
:aria-expanded="open"
prepend:icon="lucide:settings-2"
variant="outline"
@click="open = !open"
>
Advanced options
</BbButton>
<!-- One-way on purpose: the component renders `open`, it never writes it. -->
<BbCollapsible id="collapsible-basic-panel" :model-value="open">
<div class="grid gap-3 pt-3">
<BbTextInput
id="collapsible-basic-retries"
v-model="retries"
compact
label="Retry attempts"
name="retries"
/>
<BbSwitch
id="collapsible-basic-signature"
v-model="signed"
label="Sign the payload"
name="signed"
/>
</div>
</BbCollapsible>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbCollapsible, BbSwitch, BbTextInput } from 'bitboss-ui';
// Initialise the boolean. `model-value` is required and there is no event to
// listen to — every change comes from the trigger above.
const open = ref(false);
const retries = ref('3');
const signed = ref(true);
</script>
The component cannot open or close itself. It renders model-value and never
writes it back, and it emits nothing. v-model compiles and works, but only as a
prop binding, so do not wait for an update:modelValue. Put side effects on the
trigger.
model-value is required. Initialize it with ref(false): an undefined binding
renders as closed, which looks like the component ignoring you.
You own the trigger
The component manages the region. The semantics of the control that opens it are yours.
If the trigger is a button, give it aria-expanded and aria-controls pointing
at the collapsible's id. Without them a screen-reader user gets a button whose
effect is announced nowhere. That is what the Default demo above
wires.
When the trigger is already a control with a state, do not add a second one:
<template>
<div class="flex max-w-md flex-col gap-2">
<!--
No `aria-expanded` here: the switch's own checked state already says
whether the dependent fields are in play. Adding it would announce the
same fact twice, in two vocabularies.
-->
<BbSwitch
id="collapsible-switch-custom-domain"
v-model="customDomain"
label="Use a custom domain"
name="customDomain"
/>
<BbCollapsible :model-value="customDomain">
<div class="grid gap-3 pt-3">
<BbTextInput
id="collapsible-switch-host"
v-model="host"
compact
label="Hostname"
name="host"
placeholder="app.example.com"
/>
<BbTextInput
id="collapsible-switch-cname"
v-model="cname"
compact
label="CNAME target"
name="cname"
/>
</div>
</BbCollapsible>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCollapsible, BbSwitch, BbTextInput } from 'bitboss-ui';
// One ref drives the switch and the region. Turning the option on reveals the
// fields it needs; there is nothing else to wire.
const customDomain = ref(false);
const host = ref('');
const cname = ref('edge.bitboss.io');
</script>
A switch or a checkbox bound to the same ref already says "on / off", and the
fields that appear are the consequence of the setting. Adding aria-expanded
there announces the same fact twice.
If wiring this is not work you want, step up to
BbAccordion: it writes the header button for
you.
Closed content is out of reach
Hiding something visually is not hiding it. Here the component does that work.
Tab from the trigger while the panel is closed: focus skips straight past the button inside it. Requested: false
<template>
<div class="flex max-w-md flex-col items-start gap-2">
<BbButton
aria-controls="collapsible-inert-panel"
:aria-expanded="open"
variant="outline"
@click="open = !open"
>
Refund policy
</BbButton>
<!--
`eager` renders the body immediately, so this text is in the static HTML
before anyone opens the panel. It still cannot be reached while closed:
the component marks it aria-hidden and inert.
-->
<BbCollapsible id="collapsible-inert-panel" eager :model-value="open">
<div class="flex flex-col items-start gap-2 pt-3 text-sm">
<p class="m-0">
Unopened orders can be returned within 30 days of delivery. Refunds
reach the original payment method in 5 to 7 working days.
</p>
<BbButton size="sm" variant="ghost" @click="requested = true">
Request a refund
</BbButton>
</div>
</BbCollapsible>
<p class="text-sm opacity-70">
Tab from the trigger while the panel is closed: focus skips straight past
the button inside it. Requested: {{ requested }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbCollapsible } from 'bitboss-ui';
const open = ref(false);
const requested = ref(false);
</script>
Closed content is aria-hidden and inert: unfocusable, unclickable, out of the
tab order and out of the accessibility tree. Do not wrap it in a v-if: that
would take the animation and the mounted state with it.
Nothing inside the slot renders until the first open, and it stays mounted from
then on. eager renders it immediately. Reach for it when the content has to
exist before anyone opens the region. A form field that must register and
validate, text a prerendered page has to carry, anything measured on mount. The
demo above is eager, which is why its text sits in this page's static HTML.
One region at a time
Several collapsibles become an exclusive group when you derive each state from one key.
Lumen Sit-Stand Desk 160
FUR-DSK-1187 · Furniture
Rated 4.8 / 5 · 12 units on hand · last updated 2026-08-21.
<template>
<div class="flex max-w-md flex-col gap-3">
<div>
<p class="m-0 text-sm font-medium">{{ product.name }}</p>
<p class="m-0 text-sm opacity-70">
{{ product.sku }} · {{ product.category }}
</p>
</div>
<div class="flex flex-wrap gap-1.5">
<BbButton
v-for="panel in panels"
:key="panel.key"
:aria-controls="`collapsible-group-${panel.key}`"
:aria-expanded="active === panel.key"
size="sm"
:variant="active === panel.key ? 'secondary' : 'outline'"
@click="toggle(panel.key)"
>
{{ panel.label }}
</BbButton>
</div>
<BbCollapsible
id="collapsible-group-specs"
:model-value="active === 'specs'"
>
<p class="m-0 text-sm opacity-70">
Rated {{ product.rating }} / 5 · {{ product.stock }} units on hand ·
last updated {{ product.updatedAt }}.
</p>
</BbCollapsible>
<BbCollapsible
id="collapsible-group-pricing"
:model-value="active === 'pricing'"
>
<p class="m-0 text-sm opacity-70">
{{ product.price }} {{ product.currency }} per unit, excluding VAT.
Volume pricing from 25 units.
</p>
</BbCollapsible>
<BbCollapsible
id="collapsible-group-shipping"
:model-value="active === 'shipping'"
>
<p class="m-0 text-sm opacity-70">
Ships in 2 to 3 working days from the Rotterdam warehouse. Free returns
within 30 days.
</p>
</BbCollapsible>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbCollapsible } from 'bitboss-ui';
import { products } from '~/demo-data';
type PanelKey = 'specs' | 'pricing' | 'shipping';
const product = products[1]!;
const panels: Array<{ key: PanelKey; label: string }> = [
{ key: 'specs', label: 'Specs' },
{ key: 'pricing', label: 'Pricing' },
{ key: 'shipping', label: 'Shipping' },
];
// One active key, every model derived from it. Clicking the open panel closes
// it, which is why the key is nullable.
const active = ref<PanelKey | null>('specs');
function toggle(key: PanelKey) {
active.value = active.value === key ? null : key;
}
</script>
One ref holds the open key, each region compares against it, and each trigger
assigns it. No group wrapper, and no watcher forcing siblings shut. Make the key
nullable if clicking the open trigger should close everything.
When the triggers are stacked headers, you are describing an accordion: see One panel at a time.
Spacing and timing
Do not put padding, borders or gaps on the collapsible itself.
While closed they are forced to zero, so they show up only when it is open and
the collapse leaks the difference. The inner wrapper,
.bb-collapsible__content, is zeroed in both states. Space the element you slot
in, as every demo on this page does.
transition-duration (milliseconds, 250 by default) drives the height animation
and the opacity fade together. Under prefers-reduced-motion: reduce both drop
to near-instant.
Coming from v2tag removed
The root is always a span laid out as a grid now. That is phrasing content,
which is what keeps an inline "Show more" valid inside a paragraph. If you passed
tag="section", wrap the component instead.
- <BbCollapsible v-model="open" tag="section">…</BbCollapsible>
+ <section>
+ <BbCollapsible v-model="open">…</BbCollapsible>
+ </section>
Three classes are yours: .bb-collapsible on the root, with
.bb-collapsible--open / .bb-collapsible--closed tracking the state, and
.bb-collapsible__content on the wrapper around your slot. Style the state
modifiers, not the animation: the height is a grid-template-rows transition
between 0fr and 1fr, and overriding it is how you lose the collapse.