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
Passthrough
Hover or tap a part to outline it. The toggles turn on loading, errors and warnings where the component has them; only parts get a row.
Refunds go back to the original payment method within five days.
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">
<!--
One panel, one boolean. Enter or Space on the header toggles it.
`title` draws the header: the text, a chevron that turns, a rule below.
-->
<BbAccordion v-model="open" title="Shipping details">
<p class="m-0 text-[color:var(--bb-text-muted)]">
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 } from 'bitboss-ui';
const open = ref(true);
</script>
title gives the header its look:
the text, a chevron that turns as the panel opens, padding, and a rule under the
panel. The title slot replaces only
the text and keeps the rest, as One panel at a time does with a
price beside each name. The header
slot replaces the whole trigger, chevron included, for a header of your own.
Each of them 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 the 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="{ open }">
<span class="flex w-full items-center gap-2 px-3 py-2 text-left">
<span class="flex-1 text-sm font-medium">{{ entry.question }}</span>
<!-- Passive content in the header is click-transparent, so the
badge never swallows the toggle click. -->
<BbBadge class="shrink-0" size="sm" variant="secondary">{{ entry.topic }}</BbBadge>
<span
class="shrink-0 transition-transform duration-200"
:class="open ? 'rotate-180' : ''"
>
<BbIcon icon="lucide:chevron-down" size="sm" />
</span>
</span>
</template>
<p class="m-0 px-3 pb-3 text-sm text-[color:var(--bb-text-muted)]">{{ 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: 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, for example pt:header="hover:underline".
Panels nest with no extra wiring. Pad the nested body, or the hierarchy disappears.
When the headers are section titles, such as an FAQ or a settings page, pass
heading-level so a screen
reader user can jump between them. The button then sits inside an <h1> to
<h6>. Match the page outline: under an <h2>, the panels take 3.
<h2>Billing questions</h2>
<BbAccordion v-model="open" :heading-level="3" title="Can I change plans?">
Yes, at any time.
</BbAccordion>
Leave it out inside a form, a card or a side panel, where the headers are controls and not titles.
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">
<!--
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"
>
<!-- `#title` replaces only the text: the chevron and the look stay. -->
<template #title>
<span class="flex items-center justify-between gap-2">
{{ plan.name }}
<span class="font-normal text-[color:var(--bb-text-muted)]">
{{ plan.price }}
</span>
</span>
</template>
<ul
class="m-0 grid list-none gap-1 p-0 text-[color:var(--bb-text-muted)]"
>
<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 } 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. Set the key to null in that branch,
or the open panel refuses to close.
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.
A close action in the body
header and the
body receive the same two things,
open and toggle.
<template>
<div
class="max-w-md overflow-hidden rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
>
<BbAccordion v-model="open">
<!-- `open` mirrors the state: read it, never assign to it. -->
<template #header="{ open: expanded }">
<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="flex items-center gap-1.5 text-sm text-[color:var(--bb-text-muted)]">
{{ order.lines.length }} lines
<BbIcon
class="shrink-0 transition-transform duration-200"
:class="expanded ? 'rotate-180' : ''"
icon="lucide:chevron-down"
size="sm"
/>
</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="text-[color:var(--bb-text-muted)]">× {{ line.quantity }}</span>
</div>
<BbButton
class="-ml-2 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, BbIcon } from 'bitboss-ui';
import { orders, productById } from '~/demo-data';
const order = orders[0]!;
const open = ref(true);
</script>
open 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.
Coming from v2The slot scope says open, not value
In v2 both slots received { value, toggle }. The flag is now open, the word
BbPopover, BbDropdown and the pickers use for the same thing. With vue-tsc
checking templates the old name is a compile error; without it, value reads
undefined.
- <template #header="{ value }">
+ <template #header="{ open }">
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" @invalid="reveal" @submit="save">
<BbTextInput
id="accordion-eager-company"
v-model="draft.company"
label="Company"
name="company"
:rules="requiredRule"
/>
<div class="border-y 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
form would submit as if the field did not exist.
-->
<BbAccordion id="accordion-eager-billing" v-model="open" eager>
<template #header="{ open: expanded }">
<span class="flex w-full items-center gap-2 py-2.5 text-sm">
<BbIcon
class="shrink-0 transition-transform duration-200"
:class="expanded ? 'rotate-90' : ''"
icon="lucide:chevron-right"
size="sm"
/>
<span class="flex-1 font-medium">Billing reference</span>
<span class="text-[color:var(--bb-text-muted)]">
{{ draft.reference || 'Not set' }}
</span>
</span>
</template>
<div class="pb-3 pl-6">
<BbTextInput
id="accordion-eager-reference"
v-model="draft.reference"
compact
hint="Printed on every invoice for this company."
label="Purchase order"
name="reference"
:rules="requiredRule"
/>
</div>
</BbAccordion>
</div>
<BbButton class="self-start" type="submit" variant="primary">Save</BbButton>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]" role="status">{{ status }}</p>
</BbForm>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { BbAccordion, BbButton, BbForm, BbIcon, BbTextInput } from 'bitboss-ui';
const requiredRule = (value: unknown): boolean | string =>
(value !== null && value !== undefined && value !== '') || 'Required.';
// The purchase order starts empty: press Save and the closed panel blocks it.
const draft = reactive({ company: 'Northwind Traders', reference: '' });
const open = ref(false);
const status = ref('');
// The invalid field may sit in a closed panel: open it, so the error is seen.
function reveal() {
open.value = true;
status.value = 'Not saved: the purchase order is required.';
}
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 field
joins its BbForm 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 from the form's
invalid event, as the demo does.
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
Pad the body with pt:panel (or .bb-accordion__panel) or on what you slot
in: the panel sits inside the
collapsing region, so its padding collapses with it. With title the header
and the body pad themselves; the header slot gets a button with no padding of
its own, so pad the <span> you put in it.
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__panel on the body, plus .bb-accordion__title and
.bb-accordion__icon (the chevron) under title. The same parts take pt, with
an open state: pt:header:open="font-semibold", pt:icon="text-(--bb-primary)". If you repaint the header, keep a
visible focus ring: the browser supplies none.
Under title every panel draws its own rule underneath, so a list needs no
divider. With the header slot the borders and dividers belong to the container
that renders the panels, which keeps a collapsed panel from leaving a stray line.
Coming from v2.bb-accordion__content → .bb-accordion__panel
v2's .bb-accordion__content sat on the collapsible's root. It now matches
nothing; npx bitboss-ui check reports it in your stylesheets. The body is wrapped in a new div,
.bb-accordion__panel (pt:panel), inside the collapsing region, so padding,
borders and backgrounds on it collapse with the body instead of holding a closed
panel open.
- .faq .bb-accordion__content { color: var(--bb-text-muted); }
+ .faq .bb-accordion__panel { color: var(--bb-text-muted); }
Find every use with rg -n -- 'bb-accordion__content'.