The ruined keep broods over the moors — a crumbling fortress built for siege and betrayal.
The great hall opens onto a collapsed battlement. Below, three cellars share a flooded oubliette once used to hold political prisoners.
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.
pnpm add @agencecinq/disclosure-buttonImport 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.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();A single trigger toggles one region. The button label stays fixed — open/close events are logged below.
Fireball deals 1d6 per caster level (maximum 10d6) in a 20-foot-radius sphere. Creatures in the area may save vs. spell for half damage if they have Dexterity 15+ and room to dive for cover.
Last event: —
Listen to DISCLOSURE_BUTTON_OPEN / DISCLOSURE_BUTTON_CLOSE and update the trigger text yourself — useful for Show/Hide copy or i18n.
HD 6+6, THAC0 13, #AT 3, Dmg 1d4/1d4/1d8+6. Special: regeneration 3 hp/round unless burned or acid-treated. Treasure type: Q (×3), S.
Current label: Show stat block
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.
The great hall opens onto a collapsed battlement. Below, three cellars share a flooded oubliette once used to hold political prisoners.
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.
A forceful invocation ending in ignis!
Pinch sulfur, then thrust both hands toward the target.
A tiny ball of bat guano and sulfur.
Regions visible: 0 / 3
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:
Call open() or close() on the host element — from an external control, a dismiss button inside the region, or your own routing logic.
The party spotted a hidden door behind the tapestry. Roll for investigation before the torch burns out.
Region state: closed
Open and close events fire before the state changes. Callevent.preventDefault() to abort — useful for confirmations or gating access.
X marks a sunken vault beneath the old lighthouse — approach at low tide only.
Last attempt: —