Exclusivebeta
Alert Phase — <mv-alert-phase>
An incident status whose severity decays over time instead of flipping between red and green. Signals (signal(level, { reason }), a click on any [data-alert-phase-signal] trigger, or DOM events mapped with listen) raise it to Alert, Elevated or Caution; each phase runs its own visible countdown (a depleting ring and, in the panel, a decay path to Clear) and, when it times out without a new signal, steps down one level: Alert → Elevated → Caution → Clear. A signal of the same level re-arms the phase, a higher one escalates at once through the cancelable mv-escalate (maintenance windows, deduplication), a lower one is only noted, and mv-decay lets the app keep a phase a little longer. The badge variant is an inline chip for tables and headers; the panel variant is an on-call card with the countdown, time to Clear, last reason, signal count, a patterned timeline of the last minutes with a tick per signal, a transition log and Acknowledge / Hold / Resolve actions. Deadlines are chained from Date.now(), so a throttled background tab catches up exactly, state is JSON-serializable (state / restore() / persist) for reloads and server hydration, and every phase has its own icon, label and pattern (solid, stripes, dots, flat) on top of its color.
| Category | Feedback |
|---|---|
| Type | Web Component (<mv-alert-phase>) |
| Status | beta |
| Also installs | button |
| Keywords | exclusive, culture, status, incident, severity, alerting, decay, cooldown, monitoring, on-call, observability, countdown, timer, dashboard, security, fraud, ci, timeline, health-check, escalation |
When to use
- A monitoring or on-call dashboard should show recent trouble fading back to normal instead of flipping from red to green
- Bursty signals (5xx errors, fraud flags, failed logins) must keep a status raised while they continue and relax once they stop
- A security or fraud console needs a visible cool-down period after an incident before anyone declares all clear
- A table of services, jobs or pipelines needs a compact severity chip that also tells how long the current level will last
Avoid when
- The status is a fixed label that never changes on its own (paid, draft, archived) → use Badge instead
- A one-off message must be shown to the user and dismissed → use Toast instead
- Clearing must only ever follow a human decision, never elapsed time: use a plain status and an incident workflow
Install
node scripts/add.mjs alert-phase --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["alert-phase"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/alert-phase/alert-phase.js, components/alert-phase/alert-phase.css, components/button/button.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="ap-demo" style="width:min(100%,68rem);margin-inline:auto">
<style>
#ap-demo { display:grid; gap:1rem }
#ap-demo .ap-top { display:flex; align-items:flex-end; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap }
#ap-demo .ap-top h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
#ap-demo .ap-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#ap-demo .ap-layout { display:grid; grid-template-columns:minmax(0,1fr) 21rem; gap:1rem; align-items:start }
#ap-demo .ap-main { display:grid; gap:.75rem; min-width:0 }
#ap-demo .ap-controls { display:grid; gap:.75rem; padding:.875rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle) }
#ap-demo .ap-row { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap }
#ap-demo .ap-k { color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase; margin-inline-end:.25rem }
#ap-demo .ap-row .mv-choice { font-size:.8125rem }
#ap-demo .ap-log { margin:0; min-height:1.1rem; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#ap-demo .ap-services { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#ap-demo .ap-services header { display:flex; align-items:center; justify-content:space-between; gap:.5rem; padding:.875rem 1rem; border-bottom:1px solid var(--mv-border) }
#ap-demo .ap-services h4 { margin:0; font-size:.875rem }
#ap-demo .ap-list { list-style:none; margin:0; padding:.25rem 0 }
#ap-demo .ap-list li { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:.625rem 1rem }
#ap-demo .ap-list li + li { border-top:1px solid var(--mv-border) }
#ap-demo .ap-svc { display:grid; gap:.125rem; min-width:0 }
#ap-demo .ap-svc b { font-size:.8125rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#ap-demo .ap-svc span { color:var(--mv-fg-muted); font-size:.75rem }
#ap-demo .ap-foot { display:grid; gap:.625rem; padding:.875rem 1rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#ap-demo .ap-foot p { margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.5 }
@media (max-width:56rem) { #ap-demo .ap-layout { grid-template-columns:minmax(0,1fr) } }
</style>
<div class="ap-top">
<div>
<h3>Incident console</h3>
<p>Acme Commerce · production · accelerated timers for the demo (Alert 20 s → Elevated 25 s → Caution 30 s)</p>
</div>
<span class="mv-badge" data-variant="outline" data-shape="pill">On call: Priya Natarajan</span>
</div>
<div class="ap-layout">
<div class="ap-main">
<mv-alert-phase id="ap-checkout" variant="panel" label="Checkout API · us-east-1"
alert-duration="20s" elevated-duration="25s" caution-duration="30s" history-window="3m">
<a slot="actions" class="mv-button" data-variant="link" data-size="sm" href="#runbook">Open runbook</a>
</mv-alert-phase>
<div class="ap-controls">
<div class="ap-row">
<span class="ap-k">Send a signal</span>
<button type="button" class="mv-button" data-variant="outline" data-size="sm"
data-alert-phase-signal="alert" data-alert-phase-target="ap-checkout"
data-alert-phase-reason="5xx rate 6.1% on /v2/charges (threshold 2%)">Error spike · Alert</button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm"
data-alert-phase-signal="elevated" data-alert-phase-target="ap-checkout"
data-alert-phase-reason="Retry rate 18% from mobile clients">Retry storm · Elevated</button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm"
data-alert-phase-signal="caution" data-alert-phase-target="ap-checkout"
data-alert-phase-reason="p95 latency 910 ms (target 600 ms)">Slow responses · Caution</button>
</div>
<div class="ap-row">
<label class="mv-choice">
<input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="ap-maint">
<span class="mv-choice-text"><span class="mv-choice-title">Maintenance window: veto escalations</span></span>
</label>
</div>
<p class="ap-log" id="ap-log" aria-hidden="true">Waiting for the next transition…</p>
</div>
</div>
<aside class="ap-services" aria-label="Service status">
<header>
<h4>Services</h4>
<label class="mv-choice" data-control="end" style="font-size:.75rem">
<input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="ap-sim" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Live signals</span></span>
</label>
</header>
<ul class="ap-list">
<li><span class="ap-svc"><b>Payments gateway</b><span>eu-west-1</span></span>
<mv-alert-phase id="ap-s1" label="Payments gateway" announce="escalations" alert-duration="30s" elevated-duration="30s" caution-duration="40s"></mv-alert-phase></li>
<li><span class="ap-svc"><b>Identity (SSO)</b><span>global</span></span>
<mv-alert-phase id="ap-s2" label="Identity" announce="escalations" alert-duration="30s" elevated-duration="30s" caution-duration="40s"></mv-alert-phase></li>
<li><span class="ap-svc"><b>Search</b><span>us-west-2</span></span>
<mv-alert-phase id="ap-s3" label="Search" announce="escalations" alert-duration="30s" elevated-duration="30s" caution-duration="40s"></mv-alert-phase></li>
<li><span class="ap-svc"><b>Media CDN</b><span>global edge</span></span>
<mv-alert-phase id="ap-s4" label="Media CDN" announce="escalations" alert-duration="30s" elevated-duration="30s" caution-duration="40s"></mv-alert-phase></li>
<li><span class="ap-svc"><b>Notifications</b><span>ap-southeast-1</span></span>
<mv-alert-phase id="ap-s5" label="Notifications" announce="escalations" alert-duration="30s" elevated-duration="30s" caution-duration="40s"></mv-alert-phase></li>
<li><span class="ap-svc"><b>Ledger sync</b><span>eu-central-1</span></span>
<mv-alert-phase id="ap-s6" label="Ledger sync" announce="escalations" alert-duration="30s" elevated-duration="30s" caution-duration="40s"></mv-alert-phase></li>
</ul>
<div class="ap-foot">
<p>Each status decays one level when its timer runs out without a new signal. A new signal of the same level re-arms it; a higher one escalates at once.</p>
</div>
</aside>
</div>
<script type="module">
await customElements.whenDefined("mv-alert-phase");
const root = document.getElementById("ap-demo");
const $ = (id) => document.getElementById(id);
const s = 1000;
const t = Date.now();
// Hydrate from a server snapshot: the incident started two minutes ago.
$("ap-checkout").restore({
phase: "alert",
remaining: 17 * s,
since: t - 9 * s,
episode: { start: t - 118 * s, signals: 9 },
last: { level: "alert", reason: "Card authorization timeouts: 312 in the last minute", at: t - 3 * s },
history: [
{ type: "change", from: "clear", to: "caution", at: t - 118 * s, cause: "signal", reason: "p95 latency 842 ms (target 600 ms)" },
{ type: "change", from: "caution", to: "alert", at: t - 97 * s, cause: "signal", reason: "5xx rate 4.2% on /v2/charges" },
{ type: "change", from: "alert", to: "elevated", at: t - 71 * s, cause: "timeout", idle: 20 * s },
{ type: "change", from: "elevated", to: "caution", at: t - 46 * s, cause: "timeout", idle: 25 * s },
{ type: "change", from: "caution", to: "alert", at: t - 9 * s, cause: "signal", reason: "Card authorization timeouts: 188 in the last minute" },
],
signals: [118, 111, 97, 94, 91, 50, 9, 6, 3].map((ago, i) => ({ at: t - ago * s, level: i < 2 ? 1 : i === 5 ? 2 : 3 })),
});
const seed = {
"ap-s1": { phase: "elevated", remaining: 12 * s, last: { level: 3, reason: "Issuer declines up 3x", at: t - 30 * s } },
"ap-s2": { phase: "alert", held: true, remaining: 16 * s, acknowledged: { by: "Priya N." }, last: { level: 3, reason: "SAML assertion failures", at: t - 40 * s } },
"ap-s3": { phase: "caution", remaining: 19 * s },
"ap-s4": { phase: "caution", remaining: 6 * s },
"ap-s5": { phase: "clear" },
"ap-s6": { phase: "clear" },
};
for (const [id, state] of Object.entries(seed)) $(id).restore(state);
// Maintenance window: the app vetoes escalations (the cancelable hook).
const maint = $("ap-maint");
$("ap-checkout").addEventListener("mv-escalate", (e) => { if (maint.checked) e.preventDefault(); });
const log = $("ap-log");
root.addEventListener("mv-change", (e) => {
const { from, to, cause } = e.detail;
const name = e.target.label ?? e.target.id;
log.textContent = `mv-change · ${name} · ${from} → ${to} (${cause})`;
});
root.addEventListener("mv-signal", (e) => {
if (e.detail.outcome === "vetoed") log.textContent = `mv-escalate vetoed · ${e.target.label} · ${e.detail.level}`;
});
root.addEventListener("click", (e) => { if (e.target.closest("a[href='#runbook']")) e.preventDefault(); });
// Live signals on the service list (your WebSocket or polling handler would do this).
const feed = [
["ap-s1", "elevated", "Issuer declines up 3x"], ["ap-s1", "alert", "Acquirer timeouts above 5%"],
["ap-s3", "caution", "Index lag 42 s"], ["ap-s3", "elevated", "Query errors 2.4%"],
["ap-s4", "caution", "Origin fetch p95 1.2 s"], ["ap-s5", "caution", "Push queue depth 18k"],
["ap-s6", "elevated", "Reconciliation drift $1,240"], ["ap-s5", "alert", "APNs rejects 9%"],
["ap-s4", "elevated", "Cache hit ratio 71%"],
];
let i = 0;
const sim = $("ap-sim");
const timer = setInterval(() => {
if (!root.isConnected) return clearInterval(timer);
if (!sim.checked || document.hidden) return;
const [id, level, reason] = feed[i++ % feed.length];
$(id).signal(level, { reason, source: "metrics" });
}, 5000);
</script>
</div>Cultural reference
Metal Gear Solid — Hideo Kojima (Konami) (1998, game). Once guards spot the player, the base goes into Alert, then decays through Evasion and Caution back to normal, each phase on its own visible countdown that only a new sighting can reset or send straight back to Alert. In the UI, incident signals raise a status that steps down one level at a time on its own timers, so severity fades gracefully instead of flipping from red to green, and any new signal re-arms or escalates it.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
variant | badge | panel | badge | badge: inline chip (icon with countdown ring, phase, time left). panel: card with big countdown, decay path, last signal, history timeline, log and actions. |
label | string | Status | What the status is about ("Checkout API"). Panel title, prefix of the badge's accessible name and of every announcement. |
phase | alert | elevated | caution | clear | Initial phase at first connection (full duration). Changing it later forces that phase (authoritative: not cancelable). The live phase is reflected in data-phase, not here. | |
alert-duration | time ("2m", "90s", "1h30m", ms number) | 2m | How long Alert lasts without a new alert-level signal before stepping down to Elevated. |
elevated-duration | time | 5m | How long Elevated lasts before stepping down to Caution. |
caution-duration | time | 10m | How long Caution lasts before Clear. Duration changes apply from the next phase. |
held | boolean | Freezes the countdown (the Hold action sets it): no step-down until removed, signals still re-arm and escalate. Removed automatically when the status reaches Clear. | |
history-window | time | 15m | Span of the panel's timeline strip (a block per non-clear stretch, a tick per signal). |
log-size | number | 4 | Transitions listed under the panel's timeline, newest first (0 hides the log). |
announce | all | escalations | none | all | What is spoken: escalations assertively, step-downs, Clear, acknowledgments and holds politely. Use escalations on long lists of badges so only rising severity interrupts. |
listen | string | Space-separated event:level pairs listened on document, e.g. "checkout-5xx:alert checkout-slow:caution". The event detail may carry { level, reason (or message), source }; without a level in the pair, detail.level or alert is used. | |
persist | string | localStorage key: the state is saved on every change and restored (with elapsed time caught up) after a reload. Storage failures are ignored. | |
actions | "false" | "false" hides the built-in Acknowledge / Hold / Resolve buttons of the panel (slot="actions" content stays). | |
locale | BCP 47 tag | en-US | Locale of the times in the panel (Intl.DateTimeFormat). |
data-alert-phase-signal | alert | elevated | caution | On any clickable element: a click sends that signal to the element whose id is in data-alert-phase-target (space-separated ids) or, without it, to the enclosing mv-alert-phase. data-alert-phase-reason gives the reason. | |
data-alert-phase-action | acknowledge | hold | resolve | On any clickable element: runs that action on the target (same targeting rules; hold toggles). data-alert-phase-by names who acknowledged. | |
data-phase / data-level / data-held / data-acknowledged | set by the component | Live state on the host for styling (data-level 0..3). |
Properties
| Name | Type | Description |
|---|---|---|
phase | "alert" | "elevated" | "caution" | "clear" | Current phase. Setting it forces that phase (not cancelable, announced). |
level | 0..3 | Current level: 0 clear, 1 caution, 2 elevated, 3 alert (read-only). |
remaining | number (ms) | Time left in the running phase, recomputed on every read; frozen while held, 0 when clear (read-only). |
remainingToClear | number (ms) | Time before Clear if no new signal arrives: remaining plus the lower phases' durations (read-only). |
deadline | number (epoch ms) | null | When the running phase steps down; null when clear or held (read-only). |
held | boolean | Mirrors the held attribute. |
acknowledged | { by, at } | null | Acknowledgment of the current episode; reset by any escalation and by Clear (read-only). |
lastSignal | { level, phase, reason, source, at, outcome } | null | Most recent signal, whatever its outcome (escalated, rearmed, noted, vetoed), read-only. The panel marks a vetoed one. |
history | Array<{ type, at, from?, to?, cause?, reason?, idle?, by?, phase? }> | Up to 200 entries, oldest first. type: change (cause: signal | timeout | resolve | api | initial; idle = ms without signal for timeouts), ack, hold, resume, veto (read-only copy). |
state | object | JSON-safe snapshot { phase, level, deadline, remaining, held, acknowledged, since, quietSince, episode, last, history, signals }: send it to your server or storage and give it back to restore(). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement: phase names (alert, elevated, caution, clear), next, nextClear, heldText, quiet, lastSignal, signalsValue ({count}), acknowledge, hold, resume, resolve, timer ({time}), logTimeout, announceEscalate ({label}, {phase}), announceStep, announceClear… English defaults. |
Methods
| Name | Description |
|---|---|
signal(level = "alert", { reason?, source? }) | Reports a signal. Same level as the current phase → re-armed to its full duration; higher → escalation (mv-escalate, cancelable); lower → only noted (counted, shown as last signal). Returns "escalated" | "rearmed" | "noted" | "vetoed" | "ignored". |
acknowledge({ by? }) | Marks the current episode as seen: the Alert beacon stops, the panel shows who acknowledged. Returns false when clear or already acknowledged. |
hold() / release() | Freezes / resumes the countdown (sets / removes held). Emits mv-hold. |
resolve({ reason? }) | Back to Clear now. Returns false when already clear. |
restore(state) | Rebuilds the status from a state snapshot (or a partial one: { phase, remaining } is enough), then catches up the time elapsed since, step by step. Emits the resulting mv-change events, without announcing them. |
clearHistory() | Forgets the history and the signal ticks; the current phase is kept. |
Events
| Name | Description |
|---|---|
mv-escalate | Cancelable, before a signal raises the phase. detail: { from, to, reason, source }. preventDefault() vetoes it (maintenance window, known flapping check): the signal is logged as vetoed and the phase is unchanged. |
mv-decay | Cancelable, before a timeout steps the phase down. detail: { from, to, at }. preventDefault() keeps the phase and re-arms it for its full duration (e.g. while a deploy is still rolling out). |
mv-change | After every transition. detail: { from, to, level, cause: "signal" | "timeout" | "resolve" | "api", reason, source, at, deadline }. at is the exact time of the step, also when a background tab catches up. |
mv-signal | After every signal. detail: { level, reason, source, outcome, phase } (outcome as returned by signal(), phase = resulting phase). |
mv-acknowledge | The episode was acknowledged. detail: { phase, by, at }. |
mv-hold | The countdown was frozen or resumed. detail: { held, remaining, phase }. |
Content structure
| Name | Description |
|---|---|
actions | Children with slot="actions" ("Open runbook", "Page on-call"…) are placed at the end of the panel's actions row; hidden in the badge variant. |
CSS classes
| Name | Description |
|---|---|
mv-alert-phase-badge | Badge root (data-phase, data-flash on escalation): .mv-alert-phase-icon (countdown ring), -name, -time (-time-held pause glyph). |
mv-alert-phase-panel | Panel root (role=group, data-phase): -head, -title, -flag (ack / held), -now (icon, -name, -next, -clock), -path (-track, -seg, -seg-fill, -end, -steps, -step), -facts, -history (-strip, -block, -tick, -axis, -log), -actions. |
mv-alert-phase-swatch | Any element painted with a phase's tone and pattern (set data-phase on it): segment fills, timeline blocks, log dots. Reusable for a legend. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-alert-phase-alert | var(--mv-danger) | Alert tone (solid pattern). |
--mv-alert-phase-elevated | mix of --mv-danger and --mv-warning | Elevated tone (diagonal stripes). |
--mv-alert-phase-caution | var(--mv-warning) | Caution tone (dots). |
--mv-alert-phase-clear | var(--mv-success) | Clear tone (flat tint). |
Accessibility
Severity is never carried by color alone: each phase has its own icon (octagon, magnifier, eye, check), its name in text and its own pattern (solid, stripes, dots, flat), which also keeps the phases apart in forced colors where every tone becomes CanvasText. Changes are announced through two visually hidden live regions: an escalation assertively with its reason (“Checkout API: Alert. 5xx rate 6.1%…”), step-downs, Clear, acknowledgments and holds politely; re-armed phases and the ticking countdown are never announced, and announce="escalations" or "none" keeps long lists of badges quiet. The remaining time is exposed in a role="timer" element right after the phase name (“Checkout API: Alert, steps down in 17 seconds”), whose implicit aria-live is off, so it is read on demand without chatter. The panel is a labelled group; the timeline strip and decay path are aria-hidden and duplicated by text (next step, time to Clear, last signal, signal count, a real <ol> log with <time datetime>). Acknowledge, Hold (aria-pressed toggle) and Resolve are native buttons; when they don't apply they become aria-disabled, so focus is never lost. Declarative triggers work with any native button, keyboard included. Reduced motion (OS or data-motion="reduce"): the ring and bars jump once per second instead of sliding, the Alert beacon becomes a static ring and the escalation flash is skipped. Performance: one timeout per second at most, aligned on the displayed second; badges sleep entirely while clear or held.