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

PatternUse it for
Lone expanderA single "show more" disclosure — advanced options, long help text.
AccordionIndependent sections the user opens as needed — settings groups, FAQs. Several can be open at once.
Reveal textTruncated prose that finishes inline: descriptions, changelog entries. The tail flows with the paragraph.
TabsPeer views of one entity where exactly one is visible at a time.

Examples

Single disclosure

Collapsed by default:

Start expanded

Initial state lives in the markup:

Author aria-expanded="true" on the trigger and aria-hidden="false" on the content — auto-init respects whatever the server rendered.

Reveal text

Expand a paragraph's tail in place:

Soma is the component substrate for the Nware platform

Accordion

Independent sections; several can be open:

Invoices are issued monthly; usage is metered daily.

Exclusive accordion

Consumer logic over the events; one open at a time:

Workspace name, avatar, default locale.

Expanding one collapses its siblings — a few lines of consumer code over soma-expander-expand (see the JavaScript section). Not built in: by default sections are independent.

JS toggle

.expand() / .collapse() / .toggle() + events:

Last event: (none)

Keyboard & auto-init

KeyAction
TabReaches the trigger, a native <button> (or a link in the reveal-text form), so it needs no extra wiring.
Enter / SpaceToggle. 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

ClassEffect
.soma-expander-trigger / -contentDisclosure 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-accordionWrapper adding dividers between stacked expanders.
.soma-expander-inlineMakes the content a span that flows inline with the surrounding paragraph.
.soma-expander-revealReveal-text trigger: plain inline link, no chevron; renders a leading ellipsis that disappears once expanded.

JavaScript

Constructor

MemberDescription
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

MemberDescription
.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.
.isExpandedBoolean 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();
  });
});