Toggle

Mode

Skin

Theme

Overview & Philosophy

ln-toggle is applied to any collapsible panel element via data-ln-toggle, with trigger buttons bound from anywhere in the DOM via data-ln-toggle-for="id". It listens for trigger clicks and reacts to direct attribute mutations to toggle binary open / close state. The data-ln-toggle DOM attribute is the single source of truth—automatically keeping aria-expanded and the .open CSS class in sync with zero JavaScript height calculations or private shadow state.

Every complex open/close user interface element in a modern web application—whether a sidebar drawer, a dropdown menu, a collapsible section, a dismissible alert, or an accordion—shares this common state machine.

The Primitive Doctrine

The contract is absolute: the HTML attribute is the state, and the state is the HTML attribute. data-ln-toggle="open" represents open; anything else ("close" or empty) represents closed. Trigger clicks, JavaScript API writes, sibling coordinators, and manual DevTools edits all converge into one standard setAttribute call. A single MutationObserver runs the pipeline. There is no second path.

Orthogonality: Pure State vs Pure Layout

To keep the codebase modular, ln-toggle enforces a strict separation of concerns. It operates at three completely orthogonal layers:

  1. The State Machine (JavaScript): The ln-toggle script (only 145 lines) handles two things: maintaining state in the DOM via the data-ln-toggle attribute, and synchronizing aria-expanded and aria-controls attributes across all associated trigger buttons. It does not touch layout, dimensions, or animations.
  2. The Visual Presentation (CSS): When the state flips to open, ln-toggle adds the .open class to the panel. All animations, translations, and transitions are defined in Vanilla CSS by reading the class and attribute. The library ships mixins like @mixin collapsible (smooth grid-template-rows height transitions) and @mixin sidebar-drawer (smooth translate transforms) to handle this elegantly.
  3. The Trigger (HTML Decoupling): Triggers (elements with data-ln-toggle-for) and panels (elements with data-ln-toggle) are bound purely by element ID, not DOM proximity. Triggers can live anywhere on the page—allowing a button in the header to control a panel in the footer, or multiple buttons to sync with a single panel simultaneously.

For the complete developer reference, see the ln-toggle README and the architecture reference.

Anatomy

A toggle has two halves: the panel (the thing that opens and closes) and the trigger (the thing that controls it). The pairing is by ID; the elements can live anywhere in the DOM relative to each other.

<!-- Trigger anywhere -->
<button data-ln-toggle-for="my-panel">Toggle</button>

<!-- Panel anywhere -->
<section id="my-panel" data-ln-toggle class="collapsible">
    <article class="collapsible-body"><p>Hello.</p></article>
</section>

The panel — [data-ln-toggle]

The presence of the attribute is what makes the element a toggle; the value is the state. "open" is open; anything else is closed. Empty (data-ln-toggle with no value) is also closed — the comparison is literal-string equality with "open".

The element should have an id so triggers can reference it. The element type is the consumer's choice — <section>, <aside>, <article>, <div> all work. Do NOT use <main> — the HTML spec allows only one per page.

The trigger — [data-ln-toggle-for="id"]

Any element with data-ln-toggle-for becomes a click target. <button> is the right semantic for a state-toggling action; <header> is also common in accordion patterns where the entire heading is the clickable surface. The pairing is purely by ID — a trigger in the page header can open a sidebar in the page footer; multiple triggers anywhere in the document can all point to the same panel.

The trigger action — data-ln-toggle-action

Default is toggle (flip to the opposite state). Override with data-ln-toggle-action="open" or "close" to force one direction — the canonical pattern for the X close button inside a sidebar drawer or dismissible alert.

What state goes where

"Is this panel open?" lives on the panel's data-ln-toggle attribute. "Is the trigger's chevron rotated?" lives on the trigger's aria-expanded (synced from panel state). "What animation runs?" lives in CSS reading the .open class. The toggle's instance state is two fields: dom and isOpen. The attribute is the source of truth; isOpen is a cached comparison the observer uses to skip no-op transitions.

Single source of truth

Every consumer that wants to change state writes the attribute. There is no imperative API method — setAttribute is the only path.

const el = document.getElementById('my-panel');

// Open / close / read state
el.setAttribute('data-ln-toggle', 'open');
el.setAttribute('data-ln-toggle', 'close');
el.lnToggle.isOpen;       // boolean — read-only
el.lnToggle.destroy();    // detach listeners

Trigger click, sibling component, external script, DevTools — all write the attribute. The MutationObserver runs the same pipeline regardless of caller: .open class addition, aria-expanded sync on every matching trigger, :open event dispatch, localStorage write. One channel. One audited path.

Attributes reference

AttributeOn elementDescription
data-ln-togglepanelCreates the instance. Value "open" = open; anything else = closed.
data-ln-toggle-for="id"triggerReferences the panel by ID. Click toggles it.
data-ln-toggle-action="open|close"triggerForces one direction. Default: toggle.
data-ln-persistpanelOpts in to localStorage persistence. Boolean (no value), or data-ln-persist="custom-key" for an explicit key.
idpanelRequired so triggers can resolve the panel.
class="open"panelWritten by ln-toggle when open. CSS reads this for visual state.
aria-expandedtriggerWritten by ln-toggle on every state change. Drives chevron rotation in the default CSS.

Events reference

EventCancelabledetailDispatched at
ln-toggle:before-openyes{ target }After attribute flips to "open", before .open class is added. preventDefault() reverts the attribute to "close".
ln-toggle:openno{ target }After .open class added, after aria-expanded synced, before persist write.
ln-toggle:before-closeyes{ target }After attribute flips to "close", before .open class is removed. preventDefault() reverts to "open".
ln-toggle:closeno{ target }After .open class removed, after aria-expanded synced, before persist write.

All events bubble. detail.target is always the panel element, not a trigger. When :before-open or :before-close is canceled, the observer reverts the attribute; the revert is itself an attribute change, but the no-op guard at the top of _syncAttribute catches it (since isOpen never updated). No infinite loop.

Example — Sidebar drawer

The library's @mixin sidebar-drawer reads data-ln-toggle="open" directly from CSS and applies a translateX(0) transform. No JS coordinator — toggle owns the state, CSS owns the animation. The X close button uses data-ln-toggle-action="close" to always-close, never re-open.

Sidebar receives the .open class when open. Open DevTools and watch data-ln-toggle flip on the <aside>.

Example — Collapsible section

For an inline expand/collapse with a smooth height animation, pair ln-toggle with the .collapsible mixin. The chevron rotates 180° on open via the trigger's aria-expanded; .collapsible animates grid-template-rows from 0fr to 1fr; .collapsible-body holds the padding so the parent can collapse cleanly to zero height.

Advanced options

This is hidden content when collapsed.

Click the button to open/close.

Example — Dropdown menu

ln-dropdown is a wrapper that adds menu-grade behavior on top of one toggle: outside-click closes, resize closes, scroll repositions, body teleport for proper z-index. The underlying open/closed state still lives in data-ln-toggle on the inner menu element.

Example — Multiple triggers per panel

Triggers are matched by ID, not by DOM proximity. Multiple triggers pointing to the same panel all work; clicking any one toggles the panel; all of them have their aria-expanded synced. Useful when the same action is exposed from multiple parts of a layout — header bar, sidebar, inline content.

<button data-ln-toggle-for="info">Top trigger</button>
<button data-ln-toggle-for="info">Side trigger</button>

<section id="info" data-ln-toggle>
    <p>Either button toggles me; both stay in sync.</p>
    <button aria-label="Close"
            data-ln-toggle-for="info"
            data-ln-toggle-action="close">×</button>
</section>

The aria sync iterates document.querySelectorAll('[data-ln-toggle-for="info"]') on every state change, so any number of triggers at any DOM location stay coherent.

Persistence

Add data-ln-persist to a panel to remember its open/closed state in localStorage. State restores on page load before the first event fires. Storage key format is global by default: ln:{id}:data-ln-toggle. Add data-ln-persist-scope="page" to scope the key to the current pathname instead: ln:{id}:{pagePath}:data-ln-toggle.

MarkupPageStorage key
<aside id="sidebar" data-ln-toggle data-ln-persist>anyln:sidebar:data-ln-toggle
<aside id="sidebar" data-ln-toggle data-ln-persist="left-panel">anyln:left-panel:data-ln-toggle
<section id="faq-1" data-ln-toggle data-ln-persist data-ln-persist-scope="page">/ln:faq-1:/:data-ln-toggle

Keys are global by default — the same id="sidebar" on different pages resolves to the same key, so a shell element (e.g. a nav present on every page) shares one state everywhere. Use data-ln-persist-scope="page" when the same id or key must resolve to a different key per page. Restore happens inside the constructor before reactivity starts; the user's choice always wins over markup defaults on subsequent visits.

If data-ln-persist is present but the element has neither an id nor an explicit key value, the persist module logs a warning and silently skips — the toggle continues to function without persistence. localStorage failures (private browsing, quota exceeded) are also silent.

Cancelable transitions

Listeners on :before-open and :before-close can call preventDefault() to halt the transition. The observer reverts the attribute; the revert is itself an attribute change, but the no-op guard catches it. Use this for permission checks, unsaved-data warnings, async confirmations.

document.addEventListener('ln-toggle:before-open', function (e) {
    if (e.detail.target.id === 'admin-panel' && !window.currentUser.isAdmin) {
        e.preventDefault();
        // observer reverts attribute back to "close"
        // _syncAttribute re-fires but no-ops because isOpen never flipped
    }
});

Do not write data-ln-toggle on another panel from inside a :before-open handler. Cancelable handlers conflate "I might prevent this" with "I want to react to it" — listen to the post-event (:open) for fan-out instead.

Cross-component coordination

ln-toggle is the foundation; several other components are thin layers on top. They all reach the toggle the same way: listen for events, or write the attribute.

ComponentHow it uses ln-toggle
ln-accordionListens for bubbled ln-toggle:open on the wrapper. Sets data-ln-toggle="close" on every other open sibling. Each sibling's observer runs its own close pipeline. Never reaches into a sibling's instance — the attribute is the only path.
ln-dropdownListens for ln-toggle:open / :close on the inner menu. On open: teleport to <body>, position, attach outside-click and resize-close listeners. On close: reverse. Writes data-ln-toggle="close" from the outside-click and resize handlers.
ln-translationsThe language-add menu uses a [data-ln-dropdown] > [data-ln-toggle] shape. After the user picks a language, ln-translations writes data-ln-toggle="close" to dismiss the menu.
App shell sidebar@mixin sidebar-drawer reads [data-ln-toggle="open"] directly from CSS for the translate-x animation. The scrim overlay reacts via sibling selector. No JS coordinator.
Dismissible alert.alert[data-ln-toggle="close"] { display: none; } in scss/components/_alert.scss hides closed alerts. With data-ln-persist, the dismissal sticks.

ln-modal, ln-popover, and ln-tabs are NOT built on ln-toggle. They have their own state machines because they need different contracts (modal owns ESC + scrim + focus trap; tabs own active-panel selection; popovers own positioning). If a piece of UI feels open/close-shaped but ships with a different attribute surface, that's intentional.

Common mistakes

Setting data-ln-toggle on a panel without an id

The panel works (you can flip data-ln-toggle on it directly via setAttribute), but no [data-ln-toggle-for] trigger can find it — document.getElementById returns null and the click handler returns silently. Add an id.

Removing data-ln-toggle to "close" the panel

removeAttribute('data-ln-toggle') does NOT close the panel via the toggle pipeline. The .open class persists; aria-expanded does not update. To close, write the value: setAttribute('data-ln-toggle', 'close'). To dispose entirely, call destroy() first.

Putting data-ln-toggle on <main>

HTML spec allows only one <main> per page. Use <section>, <aside>, <article>, or <div> with a class.

Adding padding to .collapsible directly

The collapse animation transitions grid-template-rows from 0fr to 1fr. Padding on the parent shows as a thin always-visible strip when collapsed. Padding goes on .collapsible-body, never on .collapsible.

Expecting ESC or outside-click to close

ln-toggle has no keyboard handler and no document-level click listener. For ESC-to-close drawers, wire it on the consumer side (document.addEventListener('keydown', ...)). For dropdown-style outside-click, use ln-dropdown. For modal-style ESC + focus trap + scrim, use ln-modal (which is NOT built on ln-toggle).

Putting data-ln-toggle and data-ln-toggle-for on the same element

The element becomes a panel that controls itself — clicking it would call toggle() on its own instance. Rarely intentional. Split into trigger and panel.

Persisting a panel without id or explicit key

data-ln-persist in boolean form requires an id. Without one, the persist module logs [ln-persist] Element requires id or data-ln-persist="key" and silently skips. Use data-ln-persist="custom-key" if the panel has no id.

CSS for animations

ln-toggle adds/removes the .open class. Visual transition is the consumer's CSS. Three patterns are common:

// Sidebar — transform animation
.sidebar {
    transform: translateX(-100%);
    @include transition;
    &.open { transform: translateX(0); }
}

// Collapsible — grid-template-rows (RECOMMENDED for height animation)
<section id="panel" data-ln-toggle class="collapsible">
    <article class="collapsible-body">
        Content...
    </article>
</section>

// Or via @include in project SCSS:
.my-panel              { @include collapsible; }
.my-panel > .my-body   { @include collapsible-content; }

// Hidden via display:none (for dismissible alerts)
.alert[data-ln-toggle="close"] { display: none; }

The library ships @mixin collapsible (grid-template-rows pattern), @mixin sidebar-drawer (translate-x pattern), and the dismissible-alert pattern out of the box.

Dynamic DOM

The component uses a document-level MutationObserver — it automatically initializes new elements added to the DOM, including content from AJAX, innerHTML replacement, or template-driven rendering.

// No manual initialization needed.
// MutationObserver detects new [data-ln-toggle] elements.

fetch('/partial').then(r => r.text()).then(html => {
    document.getElementById('content').innerHTML = html;
    // ln-toggle automatically initializes the new elements
});

window.lnToggle(root) is escape-hatch only — for Shadow DOM, iframes, or any context the document-level observer doesn't see.

Practical uses

  • Sidebar drawer — pair with @mixin sidebar-drawer; persist with data-ln-persist; close button uses data-ln-toggle-action="close".
  • Inline collapsibles — pair with @mixin collapsible; chevron icon rotates via aria-expanded automatically.
  • Accordion panels — wrap with [data-ln-accordion] for single-open coordination. See the accordion docs.
  • Dropdown menus — wrap with [data-ln-dropdown] for outside-click, repositioning, and z-index teleport. See the dropdown docs.
  • Dismissible alerts — combine with data-ln-persist; .alert[data-ln-toggle="close"] hides the alert globally.
  • Conditional panels — listen for :before-open and call preventDefault() on permission failure.
  • URL-driven panels — listen for hashchange and write data-ln-toggle="open" on the panel matching the hash.