Skip to content

Disclosure Button

A disclosure button shows or hides a section of content. <cinq-disclosure-button> wraps a <button>, wires up aria-expanded / aria-controls, toggles hidden on the controlled regions, and dispatches open/close events.

Follows the WAI-ARIA Authoring Practices disclosure pattern. Inspired by @19h47/disclosure-button.

Terminal window
pnpm add @agencecinq/disclosure-button

Import once:

import "@agencecinq/disclosure-button";

Then write the markup — the component only adds interactivity.

HTML is the source of truth. The component will not auto-set role, auto-migrate attributes, or warn about missing labels. Run an a11y linter (axe, Lighthouse) to catch invalid markup.

<cinq-disclosure-button>
<button
type="button"
aria-expanded="false"
aria-controls="details-1"
>
Show spell details
</button>
</cinq-disclosure-button>
<div id="details-1" hidden>
Range: 150 yards. Area of effect: 20-foot radius.
</div>

Controlled regions live outside the host — typically siblings elsewhere in the document, referenced by aria-controls. They are resolved at connect time via Element.ariaControlsElements.

Attribute / element Required Role
Inner <button> Yes Focusable trigger inside <cinq-disclosure-button>.
aria-expanded Yes Current disclosure state on the trigger.
aria-controls Yes Space-separated ID(s) of the controlled region(s).
hidden Yes On each controlled region when collapsed.

The component toggles the native hidden attribute. Layout is yours — for example:

.foo[hidden] {
display: none;
}
.foo {
display: flex;
}
Method Description
open(emit?) Shows every controlled element. Dispatches an event by default.
close(emit?) Hides every controlled element. Dispatches an event by default.
toggle() Closes if any controlled element is visible; otherwise opens all. Returns false if a cancelable event was aborted.
update() Syncs aria-expanded from the DOM (e.g. after an external dismiss).
destroy() Removes listeners. Called automatically from disconnectedCallback.

open() / close() take an optional emit flag (default true). Pass false to change state without dispatching an event.

Property Description
$button The inner <button>.
elements Controlled elements from ariaControlsElements at connect time.
expanded Reads aria-expanded on the trigger. Call update() after you mutate the DOM yourself.

When aria-controls lists several IDs:

  • aria-expanded is true while at least one controlled element is visible.
  • A trigger click closes all if any region is still open.

There is no mirrored state attribute on the host — style the trigger:

cinq-disclosure-button button[aria-expanded="true"] {
/* open styles */
}
/* or */
cinq-disclosure-button:has([aria-expanded="true"]) {
/* open styles */
}

On focus / blur, a .focus class is toggled on the trigger:

cinq-disclosure-button button.focus {
outline: 2px solid currentColor;
outline-offset: 2px;
}
Event Cancelable Detail Description
disclosure-button:open Yes { ids, elements, el, open: true } Fired on the trigger before open. Cancel to abort.
disclosure-button:close Yes { ids, elements, el, open: false } Fired on the trigger before close. Cancel to abort.

Events bubble through <cinq-disclosure-button>. Prefer the constants from @agencecinq/utils:

import { EVENTS } from "@agencecinq/utils";
$host.addEventListener(EVENTS.DISCLOSURE_BUTTON_OPEN, (event) => {
console.log(event.detail.elements);
});
$host.addEventListener(EVENTS.DISCLOSURE_BUTTON_OPEN, (event) => {
if (!userMayOpen()) {
event.preventDefault();
}
});

event.detail:

Field Meaning
ids IDs of the resolved controlled elements.
elements Those DOM nodes.
el The trigger that started the action.
open Intended state (true / false) — use this rather than reading hidden yet (events fire before the DOM mutates).

The trigger must be a native <button type="button">. Enter and Space activate it through the browser; the component listens for click.

Key Function
Enter Toggle (native button behaviour).
Space Toggle (native button behaviour).

Focus stays on the trigger when opening or closing — matching the APG disclosure pattern. Once open, users can tab into focusable content inside the region. While collapsed, hidden keeps that content out of the tab order and accessibility tree.

The component never changes the trigger’s visible text. Listen to open/close and update copy yourself:

import { EVENTS } from "@agencecinq/utils";
const labels = { closed: "Show stat block", open: "Hide stat block" };
$button.addEventListener(EVENTS.DISCLOSURE_BUTTON_OPEN, () => {
$button.textContent = labels.open;
});
$button.addEventListener(EVENTS.DISCLOSURE_BUTTON_CLOSE, () => {
$button.textContent = labels.closed;
});

Keep a short excerpt in the flow and hide the rest:

<article>
<p>
The ruined keep broods over the moors — a crumbling fortress built for siege
and betrayal.
</p>
<div id="listing-details" hidden>
<p>
The great hall opens onto a collapsed battlement. Below, three cellars share
a flooded oubliette once used to hold political prisoners.
</p>
</div>
<cinq-disclosure-button>
<button
type="button"
class="read-more"
aria-expanded="false"
aria-controls="listing-details"
>
Read full entry
</button>
</cinq-disclosure-button>
</article>

aria-controls is an ID reference list — one control may reference several elements (see the ariaControlsElements MDN example).

<cinq-disclosure-button id="components">
<button
type="button"
aria-expanded="false"
aria-controls="verbal-component somatic-component material-component"
>
Show material components
</button>
</cinq-disclosure-button>
<div id="verbal-component" hidden></div>
<div id="somatic-component" hidden></div>
<div id="material-component" hidden></div>

Dismiss one region yourself, then call update():

region.hidden = true;
document.getElementById("components").update();

Several triggers can share the same ID. Use one <cinq-disclosure-button> per trigger, and sync sibling aria-expanded values in your app:

<cinq-disclosure-button>
<button type="button" aria-expanded="false" aria-controls="lore-excerpt">
Inspect glyph
</button>
</cinq-disclosure-button>
<cinq-disclosure-button>
<button type="button" aria-expanded="false" aria-controls="lore-excerpt">
Read journal entry
</button>
</cinq-disclosure-button>
<div id="lore-excerpt" hidden></div>
import { EVENTS } from "@agencecinq/utils";
const triggers = [
...document.querySelectorAll('[aria-controls="lore-excerpt"]'),
];
const sync = (event) => {
const { el, open } = event.detail;
triggers.forEach((trigger) => {
if (trigger !== el) trigger.setAttribute("aria-expanded", String(open));
});
};
triggers.forEach((trigger) => {
trigger.addEventListener(EVENTS.DISCLOSURE_BUTTON_OPEN, sync);
trigger.addEventListener(EVENTS.DISCLOSURE_BUTTON_CLOSE, sync);
});
const $host = document.querySelector("cinq-disclosure-button");
$host.open();
$host.close();
$host.toggle();
$openLink.addEventListener("click", () => {
$host.open();
});
$dismissButton.addEventListener("click", () => {
$host.close();
});
// Partial dismiss without going through close()
$region.hidden = true;
$host.update();

Basic disclosure

A single trigger toggles one region. The button label stays fixed — open/close events are logged below.

Last event:

Updating the button label

Listen to DISCLOSURE_BUTTON_OPEN / DISCLOSURE_BUTTON_CLOSE and update the trigger text yourself — useful for Show/Hide copy or i18n.

Current label: Show stat block

Read more / Read less

A short excerpt stays visible; the rest lives in a hidden region. Style the trigger as an inline text control and swap the label on open/close.

The ruined keep broods over the moors — a crumbling fortress built for siege and betrayal.

One button, multiple targets

aria-controls is an ID reference list — one trigger can open every listed region. Each region can be dismissed on its own, then update() keepsaria-expanded honest.

Note: multi-target is valid ARIA (see MDN’sariaControlsElements example). Here aria-expanded istrue while at least one region is visible; a click closes if any remains open.

Regions visible: 0 / 3

Multiple buttons, one target

Several triggers can share the same aria-controls ID. Listen to the open/close events and keep each trigger’s aria-expanded in sync yourself — use event.detail.open and event.detail.el.

Lore excerpt: hidden

Programmatic open & close

Call open() or close() on the host element — from an external control, a dismiss button inside the region, or your own routing logic.

Region state: closed

Cancellable events

Open and close events fire before the state changes. Callevent.preventDefault() to abort — useful for confirmations or gating access.

Last attempt: