← All parts

01 / Show & hide

Disclosure & accordion

A reusable native disclosure component with expand-all controls and a live count.

Try it.

Isolated preview using the source below. In the playground, click Run to start.

Copy. Register. Use.

Keep the template outside your mount region and register the definition on your existing app before mounting. Pass props for data and slots for custom markup. Adapt the example parent state to your app, or create one with brio.createApp({ state: exampleState }). Register once; use multiple instances.

Template + scoped CSS

<template id="brio-disclosure-template">
<section class="part-root" b-id="['title']" b-bind-aria-labelledby="$id.title"
b-bind-style="{ '--part-accent': $props.accent || null }">
<style>
@scope {
:scope { display: block; font: 15px/1.65 system-ui, sans-serif; color: light-dark(#152139, #edf3ff); }
* { box-sizing: border-box; }
h2 { margin: 0 0 12px; font-size: 1.5rem; line-height: 1.3; letter-spacing: -.035em; }
p { margin: 12px 0; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-block: 18px; }
button { cursor: pointer; font: inherit; color: inherit; background: transparent; border: 1px solid light-dark(#d5e0ef, #344863); border-radius: 7px; padding: 8px 14px; }
:focus-visible { outline: 3px solid var(--part-accent, light-dark(#245fe2, #9bc1ff)); outline-offset: 3px; }
[role="status"] { font-size: .85rem; }
details { border-bottom: 1px solid light-dark(#d5e0ef, #344863); padding: 14px 0; }
summary { cursor: pointer; font-weight: 650; }
}
</style>
<h2 b-bind-id="$id.title" b-text="$props.title || $data.content.title"></h2>
<p b-text="$data.content.description"></p>
<div class="actions">
<button type="button" b-on-click="$data.setAll(true)" b-text="$data.content.expandLabel"></button>
<button type="button" b-on-click="$data.setAll(false)" b-text="$data.content.collapseLabel"></button>
<span role="status" b-text="$data.status"></span>
</div>
<div b-on-toggle.capture="$data.sync()">
<details b-for="item in $data.content.items" b-key="$for.item.id"
b-bind-data-item-id="$for.item.id" b-bind-open="$data.isOpen($for.item.id)">
<summary b-text="$for.item.label"></summary>
<slot b-slot="{ item: $for.item }"><p b-text="$for.item.body"></p></slot>
</details>
</div>
</section>
</template>

Component definition

const disclosureComponent = {
template: '#brio-disclosure-template',
props: { title: String, accent: String },
setup({ el, $props }) {
return {
get content() {
const content = $props.content;
if (!content || !Array.isArray(content.items) || typeof content.countLabel !== 'string')
throw new Error('brio-disclosure requires a content object with items and countLabel.');
return content;
},
openIds: [],
isOpen(id) { return this.openIds.includes(id); },
get expanded() {
return this.content.items.filter(item => this.openIds.includes(item.id)).length;
},
get status() {
return this.content.countLabel
.replace('{expanded}', String(this.expanded))
.replace('{total}', String(this.content.items.length));
},
sync() {
this.openIds = [...el.querySelectorAll('details[open]')].map(item => item.dataset.itemId);
},
setAll(open) {
for (const item of el.querySelectorAll('details')) item.open = open;
this.sync();
},
};
},
};

Register on your app

app.component('brio-disclosure', disclosureComponent);

Example parent data

const exampleState = () => ({
content: {
"title": "A few good questions.",
"description": "Native disclosure, with a little help from Brio.",
"expandLabel": "Expand all",
"collapseLabel": "Collapse all",
"countLabel": "{expanded} of {total} expanded",
"items": [
{
"id": "build",
"label": "Do I need a build step?",
"body": "No. Register this component on your existing Brio app."
},
{
"id": "open",
"label": "Can several answers stay open?",
"body": "Yes. Each disclosure is independent, so you can compare answers."
},
{
"id": "style",
"label": "Can I change how it looks?",
"body": "Yes. The template includes ordinary, scoped CSS. No styling framework is needed."
}
]
}
});

Use in your app

<brio-disclosure b-prop-content="$data.content"></brio-disclosure>

Custom markup with slots

<brio-disclosure b-prop-content="$data.content">
<div>
<strong b-text="$slot.item.label"></strong>
<p b-if="$slot.item.id === 'build'" style="color: red;" b-text="$slot.item.body"></p>
<p b-else b-text="$slot.item.body"></p>
<!-- Add trusted custom markup or other components here. -->
</div>
</brio-disclosure>

Make it yours.

Supply content.items as { id, label, body } objects. Set the button labels and use {expanded} and {total} once each in content.countLabel. Counts adapt to the current items.

Content, props & slots

Replace the example data, not the template. Data-driven text renders as plain text. Use b-prop-content to pass the object; a static content="..." attribute is not parsed as JSON. Optional title overrides content.title.

Items need unique, nonempty string id values that stay stable across updates. Empty lists are supported.

Default slot: repeated custom answer markup. Access each item through $slot.item; use b-if for item-specific layouts. Without supplied content, answers use their body text. Summaries stay descriptive text.

Slotted markup uses the parent's state, refs, and handlers. Scoped outlets expose item data through $slot.item, without changing $data or $for. Keep slot templates trusted. Default content is used when no matching slot is supplied.

b-prop-* supplies component inputs; b-bind-* updates the host DOM. To spread inputs, use b-props: its keys override named props regardless of attribute order.

Styling & requirements

Requires Brio 5 core, JavaScript, and native CSS @scope and light-dark() support. CSS is included in the template; no add-ons or global stylesheet is required. No CSS polyfill is supplied. Colors follow the document's color-scheme; set it to light dark to follow system preference.

Set the accent prop with b-prop-accent="$data.accent", or bind --part-accent on the host or a wrapper. The accent prop takes precedence over an inherited custom property. Native scoped CSS does not block global selectors or inherited styles; check your app's resets and theme. Each instance has local state and unique generated IDs.

Integration & accessibility

Components render on mount; provide fallback markup if content must be available without JavaScript. Keep templates trusted. Use Brio structural directives for component removal and app.dispose() before removing the app. With the standard bundle, expressions require CSP unsafe-eval. The CSP build rejects #id templates, so pass the template markup as a string in the definition before using a Part there. See the integration guide for lifecycle and server boundaries.

Test your final labels, contrast, zoom, touch, and screen-reader behavior. These recipes are not an accessibility certification.