Exclusifbêta
Hatch <mv-hatch>
« Dispositif de l’homme mort » pour l’expiration de session : une puce de compte à rebours discrète (chiffres tabulaires qui défilent comme un compteur mécanique) que quelqu’un doit réinitialiser délibérément avant qu’elle n’atteigne zéro. Sous le seuil warn, elle se transforme en panneau d’alarme bien visible mais non modal (ou en <dialog> avec modal), avec un grand compte à rebours, une jauge qui se vide et « Rester connecté » / « Se déconnecter » ; un code de confirmation optionnel doit être saisi pour réinitialiser (bornes, haute sécurité), et un léger bip WebAudio peut être activé. Les réinitialisations passent par un événement mv-reset annulable dont waitUntil(promise) affiche un état d’attente pendant que votre endpoint de keepalive répond, et gère l’échec. Le temps est dérivé d’une échéance Date.now() (robuste au ralentissement des onglets en arrière-plan), les réinitialisations et expirations sont synchronisées entre onglets avec un BroadcastChannel, et l’activité de l’utilisateur peut prolonger discrètement la session pendant la phase calme uniquement, jamais une fois l’alarme déclenchée.
| Catégorie | Feedback |
|---|---|
| Type | Web Component (<mv-hatch>) |
| Statut | bêta |
| Kit | Des états système honnêtes |
| Installe aussi | 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
Installation
node scripts/add.mjs hatch --out ./src/marvelousAgent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["hatch"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : 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.
Utilisation
Démarrage rapide, le balisage minimal qui fonctionne :
<mv-hatch duration="15m" warn="2m" channel="app-session">
<a slot="expired" href="/login">Sign in again</a>
</mv-hatch>Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :
<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>Référence culturelle
Lost : Les Disparus, J. J. Abrams, Damon Lindelof & Jeffrey Lieber (2004, série). Dans la station, quelqu’un doit saisir le code avant que le compte à rebours n’atteigne zéro, avec une alarme dans les dernières minutes, sinon le système flanche. Dans l’interface, cela devient un dispositif de l’homme mort pour l’expiration de session : un compte à rebours discret qui se transforme en alarme et doit être réinitialisé délibérément avant que la session ne soit perdue.
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.