Overview & Philosophy
ln-confirm is applied directly to action controls via <button data-ln-confirm> or <a data-ln-confirm>. On the first click, it intercepts the event via e.preventDefault(), transitions the control to data-ln-confirm-state="confirming", reveals the armed confirmation prompt (or activates [data-ln-confirm-active]), and starts an auto-revert timer. If the user clicks a second time before timeout, it releases the native platform event—allowing the form to submit, link to navigate, or AJAX handler to execute naturally—while reverting to idle if the timer expires.
In ln-ashlar, this serves as an in-place interaction gate for single-element, low-impact actions, avoiding disruptive modals or blocking browser dialogue windows.
The In-Place Action Gate
The confirmation action IS the platform click. The script intercepts the first click via event prevention and propagation stopping, and releases the second click's default action. If clicked within the armed timeout, the form submits, the link navigates, and existing AJAX handlers fire naturally. There is no rewrite of the default action. Propagation, however, is stopped on both clicks — the click never reaches an ancestor click surface.
Orthogonal Layers of Danger Gating
Destructive confirmation is achieved by separating visual morphs, automatic timing, and platform defaults:
-
The Platform Default (Trigger Action): The core action — standard form submits (
type="submit") or link targets (href) — is declared natively on the HTML button or link, untouched by the component. -
The Visual Morph (In-Place States): The button represents its state through HTML attributes (
data-ln-confirm-state="confirming"). CSS reads that attribute —theme/components/_confirm.scssrebinds--color-primaryto the error token while the button is armed. -
The State Gate (
ln-confirm): The JavaScript component coordinates the armed state. It schedules the countdown timer, intercepts the first user interaction, and reverts labels or icon paths on expiration.
How the Interaction Flows
- The user clicks a destructive button. The component intercepts the event, calls
e.preventDefault()ande.stopImmediatePropagation(), transitions the button todata-ln-confirm-state="confirming", replaces its label/icon with the armed prompt, schedules an auto-revert timer, and dispatchesln-confirm:waiting. - If the user clicks the armed button again before the timer expires, the component intercepts the second click, runs cleanups synchronously, calls
stopPropagation()so the click stays on the button, and lets the default action proceed. The browser executes the natural submit, navigate, or AJAX handler. - If the timer expires before a second click arrives, the button automatically clears the armed state, restores its original text or icon, and disarms.
For the full guide see the ln-confirm README and the architecture reference.
Anatomy & Three Usage Variants
ln-confirm acts as an in-place micro-component gate. It supports three distinct usage modes:
- 1. Two-Element Mode (Recommended & HTML-First): Separates idle and active contents using
[data-ln-confirm-idle]and[data-ln-confirm-active]. Zero string replacement in JS, preserving complex child nodes (icons, badges) and ensuring 100% translatable HTML. - 2. Shorthand Attribute Mode: Uses
data-ln-confirm="Are you sure?"on simple text buttons. The prompt string is read directly from the HTML attribute. - 3. Icon-Only Mode: Automatically detects buttons with only an SVG icon. Swaps the
hrefto#ln-icon-checkand swapsaria-labelto the prompt. No tooltip is rendered — composeln-tooltipif the button needs one.
What state goes where
"Should this button confirm before action?" lives on data-ln-confirm (presence). "Currently armed?" lives on data-ln-confirm-state="confirming" on the button — the public CSS hook. "Auto-revert timeout (seconds)" lives on data-ln-confirm-timeout. "Original label" / "original icon href" live on the JS instance, captured at construction (text) or at first arm (icon).
The underlying destructive action — type="submit", href, an existing click listener — is owned by you, untouched by ln-confirm. The component does not run it; the second click does, by way of the platform's default action handling.
Attributes reference
| Attribute | On element | Owned by | Description |
|---|---|---|---|
data-ln-confirm="text" | <button> | ln-confirm | Marks the button as confirm-gated. Value is the prompt text shown on first click. If empty, defaults to "Confirm?". Captured at construction; mutating later does not propagate to the live instance. |
data-ln-confirm-idle | Child element | Markup | The content displayed while the button is in the idle state (Two-Element Mode). |
data-ln-confirm-active | Child element | Markup | The content displayed while the button is armed in confirming state (Two-Element Mode). Should initially have hidden. |
data-ln-confirm-timeout="3" | <button> | ln-confirm | Auto-revert delay in seconds. Default 3. Accepts decimals ("1.5"). Read fresh at first-click time inside _startTimer; mutating mid-armed does NOT extend the running timer (NOT in the observer's attributeFilter). |
data-ln-confirm-state="confirming" | <button> | ln-confirm (auto) | Set by JS while the button is armed. Removed on second click, on timer fire, or on destroy(). Public CSS hook for the confirming state. |
Example 1 — Two-Element Mode (HTML-First & Recommended)
Two-element mode isolates the idle content (text, icons, selection counters) from the confirm state content, ensuring no DOM elements are destroyed on first click. Defining both states in the HTML also ensures 100% translatable text with no JS hardcoding.
Example 2 — Shorthand Declarative Attribute
Click once: the button morphs into the prompt text defined in the data-ln-confirm attribute. Click within 3 seconds: the click passes through to the platform action. Wait 3 seconds: the button auto-reverts.
Example — Icon-only buttons
The constructor sees empty text and a svg.ln-icon use child, so the icon-only branch runs on first click: the trash icon swaps to a check icon. The icon-only branch also handles screen-reader output — it captures the original aria-label ("Delete"), swaps it to the prompt text ("Delete?"), and appends a transient <span data-ln-confirm-announcer role="alert"> announcer inside the button. AT speaks the prompt on first click; on revert, the announcer is removed and the original aria-label is restored. Try it with VoiceOver / NVDA.
Example — Custom timeout
Use data-ln-confirm-timeout to override the default 3-second auto-revert. Read fresh at first-click time only — mutating the attribute mid-armed does NOT extend the running timer (the attribute is not in the observer's attributeFilter; see "Common mistakes" item 4 in the README).
Example — Inside a form (the canonical pattern)
The destructive flow that ln-confirm exists for. First click arms the button. Second click within timeout — _reset runs synchronously, the click continues through the platform's default action processor, the form's submit event fires (because nothing called preventDefault), and the form submits via its native action / method. No JS rewiring; type="submit" is the contract.
This demo intercepts the submit event with a toast just to keep the page from navigating — production code would let the platform POST run.
Example — Non-form button with AJAX click handler
When the destructive action is an AJAX call that does NOT go through a form, attach the destructive code to the button's click listener as you normally would. ln-confirm gates the first click via preventDefault + stopImmediatePropagation, so this listener does NOT run on the first click. On the second click, ln-confirm returns silently (no preventDefault), so this listener runs.
Listener attachment order matters. stopImmediatePropagation only blocks listeners on the SAME element that have not yet run. ln-confirm attaches at construction time (typically DOMContentLoaded or shortly after); listeners attached BEFORE then will fire on the first click too. In normal flow this is rarely an issue because ln-ashlar loads early.
Example — Listening for arm events
The one event ln-confirm dispatches: ln-confirm:waiting on the first click, AFTER the visual swap and timer schedule, with { target } in the detail. Use it for analytics ("user almost-deleted"), page-level chrome ("show a Cancel All button while any confirm is armed"), or debugging.
Note that by the time the event fires, the button's textContent is the prompt text ("Are you sure?"), not the original label. Read btn.lnConfirm.originalText if you want the original label, or attach a more specific listener that knows the button identity.
Arm event log will appear here.
Example — Confirm inside a clickable card
The card carries its own click listener (the "whole card is clickable" pattern). The delete button lives inside it. Both clicks are contained at the button: the first arms it, the second accepts — and neither reaches the card surface, because ln-confirm calls stopPropagation() on both branches.
Click the card body: the surface handler fires. Click delete once: nothing logs (the arming click is swallowed — this listener is bound after the bundle, so stopImmediatePropagation blocks it). Click delete again within 3 seconds: the delete handler fires alone.
Click log will appear here.
Limitation. This works because the card surface is a listener. If the card were a real ancestor <a href> wrapping the button, propagation containment would not stop it — navigation is activation behavior, cancelled only by preventDefault, which would also cancel the delete being confirmed. Use data-ln-link on the card instead of nesting the button inside an anchor.
Example — Multiple confirms in a button group
Each [data-ln-confirm] button is its own instance with its own arm-state. Arming one does NOT arm the others; arming one does NOT reset already-armed siblings. Each runs its own auto-revert timer.
Example — Inside [data-ln-table] (overflow escape)
ln-table uses overflow: clip on [data-ln-table], which would clip the icon-only mode's tooltip bubble. The co-located rule [data-ln-table]:has([data-ln-confirm-state]) { overflow: visible; } reverts the table to overflow: visible while ANY descendant button is armed. The moment the button reverts, :has() unmatches and the clip is restored.
| Item | Status | Actions |
|---|---|---|
| Project Alpha | Active |
|
| Project Beta | Archived |
|
API
const btn = document.querySelector('[data-ln-confirm]');
btn.lnConfirm.confirming; // boolean — true while armed
btn.lnConfirm.originalText; // string — captured at construction
btn.lnConfirm.confirmText; // string — captured at construction
btn.lnConfirm.destroy(); // _reset() if armed, then unbind click listener,
// delete instance reference. data-ln-confirm
// attribute stays in place — caller controls
// markup.
window.lnConfirm(root); // re-run init scan over `root`. Only needed for
// Shadow DOM / iframe roots the global
// MutationObserver cannot see.
There is no arm(), accept(), or cancel() method. The component shape is "click is the only input." Programmatic open/close would contradict the in-place model — the second click IS the accept, and there is nothing else for ln-confirm to do.
Common mistakes
1. Listening for an :accept event that does not exist
There is no ln-confirm:accept. The accept semantic IS the platform click on the second click — attach to the form's submit, the button's click, or the link's navigation. ln-confirm:waiting (the only event) fires on the FIRST click, not the second.
2. Both an onclick and a <form action>
Both run on the second click, in order — the in-place pattern doesn't change platform behavior. If only one should run, drop the other or call e.preventDefault() in the click handler.
3. Double-click protection is the consumer's job
Once the second click passes through, ln-confirm is done. If the user clicks again during the AJAX round-trip, the action runs again. Disable the button in your submit / click handler, or wire data-ln-form which handles aria-busy.
4. Expecting data-ln-confirm-timeout mutations to take effect mid-confirm
Does NOT work. The attribute is read fresh at first-click time only, and is not in the observer's attributeFilter. Change the attribute BEFORE the user arms (in markup, or in coordinator code that updates the attribute when destructiveness changes). Earlier docs claimed this was reactive — that was a documentation bug.
5. Using <a data-ln-confirm> for destructive actions
Works (the second click navigates), but destructive actions over GET are an antipattern — pre-fetchers, link-preview crawlers, and shared-link unfurlers can fire GET requests at the URL without any user interaction. Use a form-submit button for actually-destructive actions.
6. Calling .click() programmatically and expecting two-clicks behavior
Two synchronous .click() calls pass through the gate (first arms, second clears). ln-confirm doesn't check isTrusted — it's a UX gate, not a security gate. Server-side checks (CSRF, second-factor) are the right defense against programmatic destruction.