Use it for
Reach for BbAccordion when the trigger is a title sitting over the body: an FAQ
entry, a settings section, a row in a list. You get the button and the
accessibility for free.
Use something else when
BbCollapsible, if the trigger cannot be a header: a switch in a settings row, a link inside a paragraphBbSmoothHeight, if you hide nothing and only want a height change to animateBbTabs, if the sections 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.
The API is the same as v2. Nothing here needs migrating.
Default
One BbAccordion is one panel, and one panel is one boolean.
Orders placed before 15:00 CET ship the same day. Delivery inside the EU takes 2 to 4 working days and is tracked from the moment it leaves the warehouse.
<template>
<div
class="max-w-md overflow-hidden rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
>
<!-- One panel, one boolean. Enter or Space on the header toggles it. -->
<BbAccordion v-model="open">
<template #header="{ value }">
<span class="flex w-full items-center justify-between gap-2 px-3 py-2">
<span class="text-sm font-medium">Shipping details</span>
<span
class="shrink-0 transition-transform duration-200"
:class="value ? 'rotate-180' : ''"
>
<BbIcon icon="lucide:chevron-down" size="sm" />
</span>
</span>
</template>
<!-- Padding goes on the body content, never on the panel. -->
<p class="m-0 px-3 pb-3 text-sm opacity-70">
Orders placed before 15:00 CET ship the same day. Delivery inside the EU
takes 2 to 4 working days and is tracked from the moment it leaves the
warehouse.
</p>
</BbAccordion>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbAccordion, BbIcon } from 'bitboss-ui';
const open = ref(true);
</script>
The header slot lands inside a real <button>, already carrying
aria-expanded and aria-controls. The body is the region that button labels.
You add none of it, and the keyboard comes with the button.
Never put a <button> or an <a> inside header. Actions belong in the body.
A list of panels
There is no BbAccordionGroup. Render several panels, one boolean each.
Yes. New seats are billed pro rata for the days left in the current cycle, and the full amount from the next invoice.
<template>
<div
class="max-w-md divide-y divide-[color:var(--bb-border)] overflow-hidden rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
>
<!--
No group component: one BbAccordion per question, each with its own
boolean, so several can be open at once.
-->
<BbAccordion
v-for="entry in faq"
:key="entry.id"
v-model="opened[entry.id]"
>
<template #header="{ value }">
<span class="flex w-full items-center gap-2 px-3 py-2 text-left">
<!-- Passive content in the header is click-transparent, so the
badge never swallows the toggle click. -->
<BbBadge size="sm" variant="secondary">{{ entry.topic }}</BbBadge>
<span class="flex-1 text-sm font-medium">{{ entry.question }}</span>
<span
class="shrink-0 transition-transform duration-200"
:class="value ? 'rotate-180' : ''"
>
<BbIcon icon="lucide:chevron-down" size="sm" />
</span>
</span>
</template>
<p class="m-0 px-3 pb-3 text-sm opacity-70">{{ entry.answer }}</p>
</BbAccordion>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { BbAccordion, BbBadge, BbIcon } from 'bitboss-ui';
const faq = [
{
id: 'seats',
topic: 'Billing',
question: 'Can I add seats mid-cycle?',
answer:
'Yes. New seats are billed pro rata for the days left in the current cycle, and the full amount from the next invoice.',
},
{
id: 'export',
topic: 'Data',
question: 'How do I export my data?',
answer:
'Every workspace can produce a JSON or CSV export from Settings. Exports are generated in the background and mailed to the workspace owner.',
},
{
id: 'sso',
topic: 'Security',
question: 'Is SSO available on the Team plan?',
answer:
'SAML and OIDC are included from the Team plan up. SCIM provisioning is Enterprise only.',
},
];
// One boolean per panel — independent, so opening one leaves the others alone.
const opened = reactive<Record<string, boolean>>({
seats: true,
export: false,
sso: false,
});
</script>
The header spans the panel and is the only click target. Whatever you slot into
it becomes click-transparent, so a badge, an icon or a <span> never swallows
the toggle. A :hover on those children never fires, though: hang it on the
header.
Panels nest with no extra wiring. Pad the nested body, or the hierarchy disappears.
One panel at a time
Track one active key and derive every panel from it.
- Unlimited workspaces
- SAML and OIDC
- Priority support
<template>
<div
class="max-w-md divide-y divide-[color:var(--bb-border)] overflow-hidden rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
>
<!--
Not v-model: the state is derived from one key, and the event decides
what that key becomes. `false` means the open panel was closed.
-->
<BbAccordion
v-for="plan in plans"
:key="plan.key"
:model-value="openKey === plan.key"
@update:model-value="openKey = $event ? plan.key : null"
>
<template #header="{ value }">
<span class="flex w-full items-center justify-between gap-2 px-3 py-2">
<span class="text-sm font-medium">{{ plan.name }}</span>
<span class="flex items-center gap-1.5 text-sm opacity-70">
{{ plan.price }}
<span
class="shrink-0 transition-transform duration-200"
:class="value ? 'rotate-180' : ''"
>
<BbIcon icon="lucide:chevron-down" size="sm" />
</span>
</span>
</span>
</template>
<ul class="m-0 grid list-none gap-1 px-3 pb-3 text-sm opacity-70">
<li v-for="feature in plan.features" :key="feature">{{ feature }}</li>
</ul>
</BbAccordion>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbAccordion, BbIcon } from 'bitboss-ui';
type PlanKey = 'starter' | 'team' | 'enterprise';
const plans: Array<{
key: PlanKey;
name: string;
price: string;
features: string[];
}> = [
{
key: 'starter',
name: 'Starter',
price: 'Free',
features: ['1 workspace', 'Community support', '7 days of history'],
},
{
key: 'team',
name: 'Team',
price: '29 € / month',
features: ['Unlimited workspaces', 'SAML and OIDC', 'Priority support'],
},
{
key: 'enterprise',
name: 'Enterprise',
price: 'On request',
features: ['SCIM provisioning', 'Audit log export', 'Custom SLA'],
},
];
// A stable key, not an index: a reordered list would move the open panel.
const openKey = ref<PlanKey | null>('team');
</script>
Clicking the open header emits false. Reset the key to null in that branch,
or the panel shuts visually while the key still claims it is open.
Use a stable key rather than an index when the list can reorder or filter. And do not write a wrapper that forces siblings closed.
The slots
header and the body receive the same two things, value and toggle.
<template>
<div
class="max-w-md overflow-hidden rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
>
<BbAccordion v-model="open">
<!-- `value` mirrors the open state — read it, never assign to it. -->
<template #header="{ value }">
<span class="flex w-full items-center justify-between gap-2 px-3 py-2">
<span class="text-sm font-medium">Order {{ order.reference }}</span>
<span class="text-sm opacity-70">
{{ value ? 'Hide lines' : `${order.lines.length} lines` }}
</span>
</span>
</template>
<!-- `toggle` earns its keep here: a way out that does not send the
reader back up to the header. -->
<template #default="{ toggle }">
<div class="grid gap-2 px-3 pb-3">
<div
v-for="line in order.lines"
:key="line.productId"
class="flex justify-between gap-4 text-sm"
>
<span>{{ productById[line.productId]?.name }}</span>
<span class="opacity-70">× {{ line.quantity }}</span>
</div>
<BbButton
class="justify-self-start"
size="sm"
variant="ghost"
@click="toggle"
>
Show less
</BbButton>
</div>
</template>
</BbAccordion>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbAccordion, BbButton } from 'bitboss-ui';
import { orders, productById } from '~/demo-data';
const order = orders[0]!;
const open = ref(true);
</script>
value says whether the panel is open. Use it to rotate a chevron or swap the
header copy. Assigning to it does nothing.
toggle() earns its place in the body, as a "Show less" at the end of a long
panel. In the header it is redundant, since the button already toggles.
Listen to update:modelValue only for side effects: analytics, a fetch on first
open. The open or close has already happened.
When the body renders
The body does not exist until the first open, and it stays mounted from then on.
<template>
<BbForm class="flex max-w-md flex-col gap-4" @submit="save">
<BbTextInput
id="accordion-eager-company"
v-model="draft.company"
label="Company"
name="company"
:rules="requiredRule"
/>
<div
class="overflow-hidden rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
>
<!--
`eager` mounts the body up front, so the field inside registers with
the form even while the panel has never been opened. Without it the
panel would submit as if the field did not exist.
-->
<BbAccordion id="accordion-eager-billing" v-model="open" eager>
<template #header>
<span class="flex w-full px-3 py-2 text-sm font-medium">
Billing reference
</span>
</template>
<div class="px-3 pb-3">
<BbTextInput
id="accordion-eager-reference"
v-model="draft.reference"
compact
label="Purchase order"
name="reference"
:rules="requiredRule"
/>
</div>
</BbAccordion>
</div>
<BbButton class="self-start" type="submit" variant="primary">Save</BbButton>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</BbForm>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { BbAccordion, BbButton } from 'bitboss-ui';
import { BbForm, BbTextInput } from 'bitboss-ui/validated';
const requiredRule = (value: unknown): boolean | string =>
(value !== null && value !== undefined && value !== '') || 'Required.';
const draft = reactive({ company: 'Northwind Traders', reference: 'PO-4417' });
const open = ref(false);
const status = ref(
'Open the panel, clear the purchase order, close it again — Save is still blocked.'
);
function save() {
status.value = `Saved ${draft.company} against ${draft.reference}.`;
}
</script>
Add eager when the content has to exist while the panel is shut. A validated
field takes part in the form only once it has rendered. Without eager the form
submits as though the field were not there, and it starts blocking submission the
first time somebody opens the panel. The same goes for text a prerendered page
has to carry.
If a collapsed panel can hold an invalid field, open it when validation fails.
Closed content is aria-hidden and inert, eager or not: out of the tab order,
unclickable, invisible to screen readers. The mechanism is documented on
BbCollapsible.
Pass id when the markup has to be predictable. The body takes your id and the
header takes <id>_header, already cross-wired.
Spacing and styling
Put padding and borders on the content you slot in, never on the body.
The body zeroes padding, borders and gaps in both states, and the header button
carries none of its own. That is why every demo above pads the <span> inside
the header and the element inside the body.
transition-duration (milliseconds, 250 by default) drives the height and the
fade together. Under prefers-reduced-motion: reduce both drop to near-instant
on their own.
Three classes are yours: .bb-accordion on the panel, .bb-accordion__header on
the button, .bb-accordion__content on the body. If you repaint the header, keep
a visible focus ring: the browser supplies none.
In a list, the borders and dividers belong to the container that renders the panels. That is what keeps a collapsed panel from leaving a stray line.