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.

CategoryFeedback
TypeWeb Component (<mv-hatch>)
Statusbeta
Also installsbutton, input
Keywordsexclusive, 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

Avoid when

Install

node scripts/add.mjs hatch --out ./src/marvelous

AI 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

NameTypeDefaultDescription
durationtime ("15m", "90s", "1h30m", "500ms", ms number)15mSession length restored by every reset. The countdown starts on first connection (or from the deadline property if you set it).
warntime2mRemaining 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.
codestringConfirmation 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.
modalbooleanAlarm 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.
soundbooleanSoft 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-resetbooleanReal 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.
channelstringBroadcastChannel 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.
labelstringSessionLabel shown in the chip before the countdown.
placementbottom-end | bottom-start | bottom | top-end | top-start | topbottom-endPreferred side of the non-modal panel relative to the chip (flips and shifts to stay in the viewport).
data-stateactive | alarm | expired | signed-outSet by the component (styleable). data-critical is added in the last 10 seconds, data-pending while a keepalive promise is in flight.

Properties

NameTypeDescription
deadlinenumber (ms epoch) | DateAbsolute 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.
remainingnumber (ms)Time left, recomputed from the deadline on every read (read-only).
state"active" | "alarm" | "expired" | "signed-out"Current phase (read-only).
pendingbooleanTrue while a reset waits for the promises passed to waitUntil() (read-only).
stringsPartial<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

NameDescription
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

NameDescription
mv-resetCancelable, 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-errorA waitUntil promise rejected. detail: { reason, error }.
mv-alarmThe alarm phase started. detail: { remaining }.
mv-expireThe 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-signinThe 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-syncAnother tab moved the deadline (reset or server expiry). detail: { deadline }.

Content structure

NameDescription
expiredChildren 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

NameDescription
mv-hatch-chipThe countdown chip (<button> toggling the panel): -chip-icon, -chip-label, .mv-hatch-reel, -chip-end (“Expired”).
mv-hatch-reel / mv-hatch-cell / mv-hatch-glyphRolling tabular digits: one clipped cell per character, glyphs roll out upward and in from below; separators have data-sep.
mv-hatch-panelAlarm / 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-dialogThe <dialog> used in modal mode (::backdrop styled with --mv-overlay).

CSS variables

NameDefaultDescription
--mv-hatch-warnvar(--mv-warning)Alarm-phase color: chip tint, beacon, meter, panel ring.
--mv-hatch-criticalvar(--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.