Expander
Summary
A disclosure pair: a trigger button and the content it shows or
hides, linked by aria-controls. State lives where
ARIA wants it: the trigger's aria-expanded and the
content's aria-hidden; auto-init binds every
trigger, so server-rendered markup works with no wiring. Stack
pairs inside .soma-accordion for divided sections,
or use the inline reveal-text form to expand a
paragraph's tail in place ("… Read more").
When to use
| Pattern | Use it for |
|---|---|
| Lone expander | A single "show more" disclosure — advanced options, long help text. |
| Accordion | Independent sections the user opens as needed — settings groups, FAQs. Several can be open at once. |
| Reveal text | Truncated prose that finishes inline: descriptions, changelog entries. The tail flows with the paragraph. |
| Tabs | Peer views of one entity where exactly one is visible at a time. |
Examples
Keyboard & auto-init
| Key | Action |
|---|---|
| Tab | Reaches the trigger, a native <button> (or a link in the reveal-text form), so it needs no extra wiring. |
| Enter / Space | Toggle. Native button semantics; the reveal-text anchor toggles on Enter. |
Auto-init contract: every
.soma-expander-trigger[aria-controls] in the DOM at
DOMContentLoaded is bound (triggers whose content
id doesn't resolve are skipped). The initial state comes from
the markup: a pair starts collapsed when the content has
aria-hidden="true" or the trigger has
aria-expanded="false"; binding then normalises both
attributes so they always agree.
HTML
One expander: a button and its content, linked by
aria-controls (swap the two ARIA values to start
expanded):
<button class="soma-expander-trigger" aria-controls="more" aria-expanded="false">Read more</button>
<div id="more" class="soma-expander-content" aria-hidden="true">…</div>
Accordion: stack pairs inside the wrapper; it adds dividers between sections, nothing more (sections stay independent):
<div class="soma-accordion">
<button class="soma-expander-trigger" aria-controls="a1" aria-expanded="true">Billing</button>
<div id="a1" class="soma-expander-content" aria-hidden="false">…</div>
<button class="soma-expander-trigger" aria-controls="a2" aria-expanded="false">Plan</button>
<div id="a2" class="soma-expander-content" aria-hidden="true">…</div>
</div>
Reveal text: the hidden tail is an inline span inside the paragraph, the trailing trigger renders a leading ellipsis that disappears once expanded:
<p>
Shown intro text
<span id="tail" class="soma-expander-content soma-expander-inline"
aria-hidden="true">— the hidden tail.</span>
<a class="soma-expander-trigger soma-expander-reveal"
aria-controls="tail" aria-expanded="false" href="#">Read more</a>
</p>
CSS classes
| Class | Effect |
|---|---|
.soma-expander-trigger / -content | Disclosure pair linked by aria-controls; state on trigger aria-expanded + content aria-hidden. The trigger grows a trailing chevron that rotates when expanded (transition skipped under reduced motion). |
.soma-accordion | Wrapper adding dividers between stacked expanders. |
.soma-expander-inline | Makes the content a span that flows inline with the surrounding paragraph. |
.soma-expander-reveal | Reveal-text trigger: plain inline link, no chevron; renders a leading ellipsis that disappears once expanded. |
JavaScript
Constructor
| Member | Description |
|---|---|
Soma.expander(input) | Get or create the singleton for a pair. input may be the trigger element (content resolved via aria-controls), the content element (trigger found by its aria-controls back-reference), or a CSS selector for either. Throws when nothing matches, the trigger's content id doesn't resolve, or the element is neither half of a pair. The instance is keyed on the trigger; both entry points return the same one. |
Instance methods
| Member | Description |
|---|---|
.expand() | Show the content: aria-expanded="true" + aria-hidden="false", dispatch expand. No-op when already expanded. |
.collapse() | Hide the content and dispatch collapse. No-op when already collapsed. |
.toggle() | expand() or collapse() depending on state. |
.on(event, fn) / .off(event, fn) | Subscribe / unsubscribe: 'expand' and 'collapse'. Listeners attach to the content element. |
.destroy() | Unbind and release the singleton. aria-expanded / aria-hidden are removed only where the markup omitted them; author-supplied attributes stay, at their current value. |
.isExpanded | Boolean state, readable at any time. |
All methods return the instance, so calls chain. The underlying
DOM events are bubbling CustomEvents
(soma-expander-expand /
soma-expander-collapse) dispatched on the
content element, so delegated listening works from any
ancestor.
Listen on the instance, and unsubscribe with the same function reference:
const exp = Soma.expander('#more'); // trigger or content both work
const onExpand = () => console.log('expanded');
exp.on('expand', onExpand);
exp.off('expand', onExpand);
Make an accordion exclusive: collapse the siblings when a section expands (this is the fifth example above):
const acc = document.getElementById('acc-exclusive');
acc.addEventListener('soma-expander-expand', (e) => {
acc.querySelectorAll(':scope > .soma-expander-content').forEach((content) => {
if (content !== e.target) Soma.expander(content).collapse();
});
});
Bind a pair rendered after page load (auto-init runs once, at
DOMContentLoaded); the call is a get-or-create
singleton, safe after any DOM update:
container.insertAdjacentHTML('beforeend', renderedDisclosure);
Soma.expander(container.querySelector('.soma-expander-trigger'));
The live examples above, exactly as this page wires them:
// JS toggle widget
const exp = Soma.expander('#exp-js');
document.getElementById('exp-do-expand').addEventListener('click', () => exp.expand());
document.getElementById('exp-do-collapse').addEventListener('click', () => exp.collapse());
document.getElementById('exp-do-toggle').addEventListener('click', () => exp.toggle());
// Events fire on the content element (they bubble)
const out = document.getElementById('exp-js-out');
const content = document.getElementById('exp-js');
content.addEventListener('soma-expander-expand', () => { out.textContent = 'soma-expander-expand'; });
content.addEventListener('soma-expander-collapse', () => { out.textContent = 'soma-expander-collapse'; });
// Exclusive accordion widget
const acc = document.getElementById('acc-exclusive');
acc.addEventListener('soma-expander-expand', (e) => {
acc.querySelectorAll(':scope > .soma-expander-content').forEach((c) => {
if (c !== e.target) Soma.expander(c).collapse();
});
});