Exclusivebeta
Hatch — <mv-hatch>
Session-expiry “dead man’s switch”: a discreet countdown chip (tabular digits that roll like a mechanical counter) that someone must deliberately reset before it hits zero. Under the warn threshold it turns into a prominent but non-modal alarm panel (or a <dialog> with modal) with a big countdown, a depleting meter and “Stay signed in” / “Sign out”; an optional confirmation code must be typed to reset (kiosks, high-security), and a soft WebAudio beep can be enabled. Resets go through a cancelable mv-reset event whose waitUntil(promise) shows a pending state while your keepalive endpoint answers and handles failure. Time is derived from a Date.now() deadline (robust to throttled background tabs), resets and expiries are synced across tabs with a BroadcastChannel, and user activity can silently extend the session during the calm phase only — never once the alarm is up.
| Category | Feedback |
|---|---|
| Type | Web Component (<mv-hatch>) |
| Status | beta |
| Also installs | button, input |
| Keywords | exclusive, culture, session, timeout, session-timeout, idle, keepalive, countdown, timer, expiry, logout, dead-mans-switch, security, kiosk, broadcast-channel, multi-tab, alarm, flip-digits |
When to use
- An authenticated app signs users out after a fixed or idle timeout and must warn them before unsaved work is lost
- A shared kiosk or back-office terminal must prove someone is still present before extending a sensitive session
- Several tabs share one server session and a keepalive in one tab must extend or end the session in all of them
- Compliance requires a visible countdown and a deliberate, logged action to extend a session
Avoid when
- The countdown is a product feature (sale ends, auction, quiz) rather than a session that must be kept alive
- The user just needs to be told what changed while they were away, not to confirm presence → use While Away instead
- The session never expires client-side or the server silently refreshes tokens; a countdown would only add anxiety
Install
node scripts/add.mjs hatch --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["hatch"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/position.js, components/hatch/hatch.js, components/hatch/hatch.css, components/button/button.css, components/input/input.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="ht-demo" style="width:min(100%,52rem);margin-inline:auto">
<style>
#ht-demo { display:grid; gap:1rem; align-content:start }
#ht-demo .ht-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#ht-demo .ht-bar { display:flex; align-items:center; gap:1rem; height:3.5rem; padding:0 1rem 0 1.125rem; border-bottom:1px solid var(--mv-border) }
#ht-demo .ht-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em }
#ht-demo .ht-logo { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
#ht-demo .ht-logo svg { width:1rem; height:1rem }
#ht-demo .ht-nav { display:flex; gap:.25rem; margin:0; padding:0; list-style:none }
#ht-demo .ht-nav a { display:block; padding:.375rem .625rem; border-radius:var(--mv-radius-md); color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500; text-decoration:none }
#ht-demo .ht-nav a[aria-current] { background:var(--mv-bg-muted); color:var(--mv-fg) }
#ht-demo .ht-end { display:flex; align-items:center; gap:.75rem; margin-inline-start:auto }
#ht-demo .ht-me { display:grid; place-items:center; width:2rem; height:2rem; border-radius:50%; background:var(--mv-bg-emphasis); color:var(--mv-fg); font-size:.75rem; font-weight:600 }
#ht-demo .ht-body { display:grid; grid-template-columns:minmax(0,1fr) 15rem; gap:1rem; padding:1.125rem }
#ht-demo .ht-card { min-width:0; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#ht-demo .ht-card h4 { margin:0 0 .25rem; font-size:.875rem }
#ht-demo .ht-card p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
#ht-demo .ht-rows { display:grid; gap:.5rem; margin-top:.875rem }
#ht-demo .ht-row { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:.5rem .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-surface); font-size:.8125rem }
#ht-demo .ht-row span:last-child { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums }
#ht-demo .ht-controls { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.875rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#ht-demo .ht-controls p { margin:0; color:var(--mv-fg-muted); font-size:.75rem; flex:1 1 16rem }
#ht-demo .ht-actions { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap }
#ht-demo .ht-log { min-height:1.25rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono) }
#ht-demo .mv-choice { font-size:.8125rem }
#ht-demo .ht-kiosk { display:flex; align-items:center; gap:1rem; flex-wrap:wrap; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
#ht-demo .ht-kiosk-title { display:grid; gap:.125rem; flex:1 1 14rem; min-width:0 }
#ht-demo .ht-kiosk-title strong { font-size:.9375rem; letter-spacing:-.01em }
#ht-demo .ht-kiosk-title span { color:var(--mv-fg-muted); font-size:.8125rem }
@media (max-width:40rem) {
#ht-demo .ht-nav { display:none }
#ht-demo .ht-body { grid-template-columns:minmax(0,1fr) }
}
</style>
<!-- Main: a billing console with a 45-second session (accelerated), alarm in the last 20 seconds -->
<section class="ht-app" aria-label="Billing console">
<header class="ht-bar">
<span class="ht-brand">
<span class="ht-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16v10H4z"/><path d="M4 11h16"/></svg></span>
Ledgerly
</span>
<ul class="ht-nav">
<li><a href="#" aria-current="page">Invoices</a></li>
<li><a href="#">Customers</a></li>
<li><a href="#">Payouts</a></li>
</ul>
<span class="ht-end">
<mv-hatch id="ht-main" duration="45s" warn="20s" channel="ht-demo-session">
<button slot="expired" type="button" class="mv-button" id="ht-signin">Sign in again</button>
</mv-hatch>
<span class="ht-me" aria-hidden="true">MR</span>
</span>
</header>
<div class="ht-body">
<div class="ht-card">
<h4>Invoice INV-2048 · Draft</h4>
<p>Northwind Traders · due Oct 15, 2026. Unsaved edits are at risk if the session ends.</p>
<div class="ht-rows">
<div class="ht-row"><span>Annual platform license</span><span>$12,400.00</span></div>
<div class="ht-row"><span>Onboarding workshop (2 days)</span><span>$3,200.00</span></div>
<div class="ht-row"><span>Priority support</span><span>$1,800.00</span></div>
</div>
</div>
<div class="ht-card">
<h4>Keepalive</h4>
<p>Each reset pings the server before the timer restarts.</p>
<p class="ht-log" id="ht-log" aria-live="polite" style="margin-top:.75rem">Session started.</p>
</div>
</div>
<div class="ht-controls">
<p>The session lasts 45 seconds here. Open this page in two tabs: extending in one extends both.</p>
<span class="ht-actions">
<label class="mv-choice"><input type="checkbox" class="mv-checkbox" id="ht-fail"> Make the keepalive fail</label>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="ht-ff">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 6 7 6-7 6zM13 6l7 6-7 6z"/></svg>
Fast-forward to alarm
</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="ht-restart">Restart</button>
</span>
</div>
</section>
<!-- Kiosk: resetting requires typing a short code -->
<div class="ht-kiosk">
<span class="ht-kiosk-title">
<strong>Front desk kiosk · Terminal 3</strong>
<span>Shared device: staying signed in requires typing a confirmation code.</span>
</span>
<mv-hatch id="ht-kiosk" duration="2m" warn="30s" code="K7M2" label="Kiosk"></mv-hatch>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="ht-kiosk-ff">Fast-forward to alarm</button>
</div>
<script type="module">
const main = document.getElementById("ht-main");
const kiosk = document.getElementById("ht-kiosk");
const log = document.getElementById("ht-log");
const fail = document.getElementById("ht-fail");
const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
const say = (text) => { log.textContent = `${time()} · ${text}`; };
// A fake keepalive endpoint: 600 ms round trip, optionally failing.
const keepalive = () => new Promise((resolve, reject) => {
setTimeout(() => (fail.checked ? reject(new Error("503")) : resolve()), 600);
});
main.addEventListener("mv-reset", (e) => {
say(`Keepalive ping (${e.detail.reason})…`);
e.detail.waitUntil(keepalive().then(() => say("Session extended by 45 s.")));
});
main.addEventListener("mv-reset-error", () => say("Keepalive failed: server unavailable."));
main.addEventListener("mv-alarm", () => say("Alarm: 20 seconds left."));
main.addEventListener("mv-expire", () => say("Session expired."));
main.addEventListener("mv-signout", () => say("Signed out."));
main.addEventListener("mv-sync", () => say("Extended from another tab."));
document.getElementById("ht-signin").addEventListener("click", () => { main.restart(); say("Signed in again."); });
document.getElementById("ht-restart").addEventListener("click", () => { main.restart(); say("Session restarted."); });
document.getElementById("ht-ff").addEventListener("click", () => { main.deadline = Date.now() + 19_500; });
document.getElementById("ht-kiosk-ff").addEventListener("click", () => {
if (kiosk.state === "expired" || kiosk.state === "signed-out") kiosk.restart();
kiosk.deadline = Date.now() + 25_500;
});
kiosk.addEventListener("mv-expire", () => setTimeout(() => kiosk.restart(), 4000));
kiosk.addEventListener("mv-signout", () => setTimeout(() => kiosk.restart(), 4000));
</script>
</div>Cultural reference
Lost — J. J. Abrams, Damon Lindelof & Jeffrey Lieber (2004, series). In the hatch, someone must enter the code before a countdown reaches zero, with an alarm in the final minutes, or the system fails. In the UI this becomes a session-expiry dead man’s switch: a quiet countdown that turns into an alarm and must be deliberately reset before the session is lost.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
duration | time ("15m", "90s", "1h30m", "500ms", ms number) | 15m | Session length restored by every reset. The countdown starts on first connection (or from the deadline property if you set it). |
warn | time | 2m | Remaining time under which the alarm phase starts: the chip turns amber and beacons, the alarm panel opens, the reset must be deliberate. Clamped to duration. |
code | string | Confirmation code the user must type (case and spaces ignored) before “Stay signed in” works during the alarm phase. For kiosks and high-security back offices; resetTimer() from code ignores it. | |
modal | boolean | Alarm and expired panels open in a modal <dialog> (showModal, backdrop, focus trapped by the browser) instead of the anchored non-modal panel. Escape folds it; the chip stays in alarm. | |
sound | boolean | Soft sine beeps via WebAudio when the alarm starts (2), at 30 s (1) and 10 s (3). Only after the user has interacted with the page (autoplay policy); never otherwise. | |
activity-reset | boolean | Real pointer, key, wheel and scroll activity anywhere in the page silently resets the timer during the calm phase only (throttled to one reset every duration/10, between 5 s and 60 s). Never in the alarm phase: then the reset must be deliberate. Emits mv-reset with reason "activity" so you can ping your keepalive. | |
channel | string | BroadcastChannel name shared by every tab of the same session: a reset (new deadline), an expiry or a sign-out in one tab applies to all of them. Ignored where BroadcastChannel is unsupported. | |
label | string | Session | Label shown in the chip before the countdown. |
placement | bottom-end | bottom-start | bottom | top-end | top-start | top | bottom-end | Preferred side of the non-modal panel relative to the chip (flips and shifts to stay in the viewport). |
data-state | active | alarm | expired | signed-out | Set by the component (styleable). data-critical is added in the last 10 seconds, data-pending while a keepalive promise is in flight. |
Properties
| Name | Type | Description |
|---|---|---|
deadline | number (ms epoch) | Date | Absolute expiry time. Read it, or set it from your server’s real session expiry (also leaves the expired state). Setting it is broadcast to the other tabs. |
remaining | number (ms) | Time left, recomputed from the deadline on every read (read-only). |
state | "active" | "alarm" | "expired" | "signed-out" | Current phase (read-only). |
pending | boolean | True while a reset waits for the promises passed to waitUntil() (read-only). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (keys: label, activeTitle, activeText, activeTextActivity, alarmTitle, alarmText, expiredTitle, expiredText, signedOutTitle, signedOutText, extend, stay, signOut, signIn, codeLabel ({code}), codeMismatch, error, timer ({time}), announceAlarm, announceLeft, announceExtended, announceRemote, announceExpired, announceSignedOut, expiredChip, signedOutChip). English defaults. |
Methods
| Name | Description |
|---|---|
resetTimer({ reason? }) | Programmatic keepalive (reason defaults to "api"): emits the cancelable mv-reset, waits for waitUntil promises, applies the new deadline and broadcasts it. Returns Promise<boolean> (false if cancelled, failed, already pending or expired). |
expire() | Forces the expired state now (e.g. the server answered 401); emits mv-expire with reason "api" and broadcasts it. |
restart() | Starts a brand-new session (after signing in again): fresh deadline, calm phase, panel closed. No mv-reset event. |
open() / close() | Opens or folds the panel (the calm panel shows the countdown with “Extend session” and “Sign out”). |
Events
| Name | Description |
|---|---|
mv-reset | Cancelable, before any reset. detail: { reason: "button" | "code" | "activity" | "api", remaining, waitUntil(promise) }. Call waitUntil() synchronously with your keepalive request: the button shows a busy state, expiry is held while it is in flight, a rejection keeps the old deadline and shows an error; a promise resolving to a Date or epoch ms uses that as the new deadline. preventDefault() refuses the reset. |
mv-reset-error | A waitUntil promise rejected. detail: { reason, error }. |
mv-alarm | The alarm phase started. detail: { remaining }. |
mv-expire | The session is lost. detail: { reason: "timeout" | "api" | "remote" } (remote: another tab expired). |
mv-signout | “Sign out” was chosen (cancelable), or another tab signed out (detail.remote true, not cancelable). detail: { remote }. |
mv-signin | The default “Sign in again” button of the expired panel was clicked (not emitted when you provide slot="expired" content). Call restart() once signed in. |
mv-sync | Another tab moved the deadline (reset or server expiry). detail: { deadline }. |
Content structure
| Name | Description |
|---|---|
expired | Children with slot="expired" (a sign-in link, a button that opens your login dialog…) replace the default “Sign in again” button in the expired / signed-out panel. |
CSS classes
| Name | Description |
|---|---|
mv-hatch-chip | The countdown chip (<button> toggling the panel): -chip-icon, -chip-label, .mv-hatch-reel, -chip-end (“Expired”). |
mv-hatch-reel / mv-hatch-cell / mv-hatch-glyph | Rolling tabular digits: one clipped cell per character, glyphs roll out upward and in from below; separators have data-sep. |
mv-hatch-panel | Alarm / status panel (Popover API top layer, or inside .mv-hatch-dialog with modal): -head, -icon, -title, -text, -clock (big reel + -meter), -code, -error, -actions, -slot. |
mv-hatch-dialog | The <dialog> used in modal mode (::backdrop styled with --mv-overlay). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-hatch-warn | var(--mv-warning) | Alarm-phase color: chip tint, beacon, meter, panel ring. |
--mv-hatch-critical | var(--mv-danger) | Color of the last 10 seconds and of the expired state. |
Accessibility
The chip is a real <button> (aria-expanded, aria-controls) described by a visually hidden role="timer" element (“14 minutes left in your session”; to the second during the alarm) whose implicit aria-live is off: screen readers read it on demand, it never chatters. Entering the alarm phase is announced once in an assertive live region (“Your session expires in 2 minutes. Choose Stay signed in to keep working.”), then at most at 60 s and 30 s (polite) and 10 s (assertive), skipping milestones already inside a short warn window. Focus moves to “Stay signed in” (or to the code field) when the alarm starts only if the user is not typing in a field and has not pressed a key in the last 1.5 s; otherwise the panel just appears. The panel is a labelled, described non-modal dialog placed right after the chip in the DOM (Tab reaches it naturally); Escape folds it back to the chip, the calm panel also closes on outside click, the alarm panel does not. modal uses a native <dialog> (focus containment, Escape folds it). The code field has a real <label> showing the code, aria-invalid and an error message linked with aria-describedby; Enter submits. While a keepalive is pending the button is aria-busy and aria-disabled; failures are announced assertively. After a reset, focus returns to the chip if it was inside the panel and “Session extended.” is announced politely; expiry is announced assertively and focus moves to the sign-in action (same typing rule). Colors always come with an icon and words (clock / alarm / lock, “Expired”). Reduced motion (OS or data-motion="reduce"): digits change without rolling, the beacon becomes a static ring, the meter jumps instead of sliding. Sound is opt-in and never plays before a user gesture.