Sidebar receives the .open class when open. Open DevTools and watch data-ln-toggle flip on the <aside>.
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:
-
The State Machine (JavaScript): The
ln-togglescript (only 145 lines) handles two things: maintaining state in the DOM via thedata-ln-toggleattribute, and synchronizingaria-expandedandaria-controlsattributes across all associated trigger buttons. It does not touch layout, dimensions, or animations. -
The Visual Presentation (CSS): When the state flips to open,
ln-toggleadds the.openclass 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. -
The Trigger (HTML Decoupling): Triggers (elements with
data-ln-toggle-for) and panels (elements withdata-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
| Attribute | On element | Description |
|---|---|---|
data-ln-toggle | panel | Creates the instance. Value "open" = open; anything else = closed. |
data-ln-toggle-for="id" | trigger | References the panel by ID. Click toggles it. |
data-ln-toggle-action="open|close" | trigger | Forces one direction. Default: toggle. |
data-ln-persist | panel | Opts in to localStorage persistence. Boolean (no value), or data-ln-persist="custom-key" for an explicit key. |
id | panel | Required so triggers can resolve the panel. |
class="open" | panel | Written by ln-toggle when open. CSS reads this for visual state. |
aria-expanded | trigger | Written by ln-toggle on every state change. Drives chevron rotation in the default CSS. |
Events reference
| Event | Cancelable | detail | Dispatched at |
|---|---|---|---|
ln-toggle:before-open | yes | { target } | After attribute flips to "open", before .open class is added. preventDefault() reverts the attribute to "close". |
ln-toggle:open | no | { target } | After .open class added, after aria-expanded synced, before persist write. |
ln-toggle:before-close | yes | { target } | After attribute flips to "close", before .open class is removed. preventDefault() reverts to "open". |
ln-toggle:close | no | { 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.
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.
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.
| Markup | Page | Storage key |
|---|---|---|
<aside id="sidebar" data-ln-toggle data-ln-persist> | any | ln:sidebar:data-ln-toggle |
<aside id="sidebar" data-ln-toggle data-ln-persist="left-panel"> | any | ln: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.
| Component | How it uses ln-toggle |
|---|---|
ln-accordion | Listens 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-dropdown | Listens 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-translations | The 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 withdata-ln-persist; close button usesdata-ln-toggle-action="close". - Inline collapsibles — pair with
@mixin collapsible; chevron icon rotates viaaria-expandedautomatically. - 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-openand callpreventDefault()on permission failure. - URL-driven panels — listen for
hashchangeand writedata-ln-toggle="open"on the panel matching the hash.