익스클루시브

Hatch <mv-hatch>

세션 만료용 “데드맨 스위치”입니다. 눈에 띄지 않는 카운트다운 칩(기계식 카운터처럼 굴러가는 고정폭 숫자)으로, 0이 되기 전에 누군가 의도적으로 재설정해야 합니다.

문화적 레퍼런스

로스트, J. J. 에이브럼스, 데이먼 린델로프 & 제프리 리버(2004, 시리즈). 해치 안에서는 카운트다운이 0에 이르기 전에 누군가 코드를 입력해야 하며, 마지막 몇 분 동안 경보가 울리고, 그러지 않으면 시스템이 고장 납니다. UI에서는 이것이 세션 만료 데드맨 스위치가 됩니다. 조용한 카운트다운이 경보로 바뀌고, 세션이 끊기기 전에 의도적으로 재설정해야 합니다.

작동 방식

세션 만료 “데드맨 스위치”: 0이 되기 전에 누군가 의도적으로 재설정해야 하는 눈에 띄지 않는 카운트다운 칩(기계식 카운터처럼 굴러가는 고정폭 숫자)입니다. 경고 임계값 아래로 내려가면 눈에 잘 띄지만 비모달인 경보 패널(또는 modal을 지정하면 <dialog>)로 바뀌며, 큰 카운트다운, 줄어드는 미터, “Stay signed in” / “Sign out”을 표시합니다. 재설정하려면 확인 코드를 입력하도록 선택적으로 요구할 수 있고(키오스크, 높은 보안 수준), 부드러운 WebAudio 신호음을 켤 수 있습니다. 재설정은 취소 가능한 mv-reset 이벤트를 거치며, 그 waitUntil(promise)은 keepalive 엔드포인트가 응답하는 동안 대기 상태를 표시하고 실패를 처리합니다. 시간은 Date.now() 기준 마감 시각에서 계산되며(스로틀링되는 백그라운드 탭에서도 견고함), 재설정과 만료는 BroadcastChannel로 탭 간에 동기화되고, 사용자 활동은 평온한 단계에서만 세션을 조용히 연장할 수 있으며 경보가 뜬 뒤에는 절대 연장하지 않습니다.

카테고리피드백
유형Web Component (<mv-hatch>)
상태안정
키트정직한 시스템 상태
함께 설치되는 항목button, 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

  • 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

설치

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

Marvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["hatch"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, components/input/input.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/position.js, components/hatch/hatch.js, components/hatch/hatch.css.

사용법

빠른 시작, 동작하는 가장 작은 마크업:

<mv-hatch duration="15m" warn="2m" channel="app-session">
  <a slot="expired" href="/login">Sign in again</a>
</mv-hatch>

기본 마크업입니다. 여기서 시작해 속성, data-*, 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>
        Tallypoint
      </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>Acme 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="off" 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>

API

Attributes

Name유형DefaultDescription
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

Name유형Description
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.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고