Exclusivebeta
Two-Key — <mv-two-key>
Guard for critical actions (delete production, wire funds, deploy) that is armed only by two deliberate, synchronized “keys”: two rotary key switches whose barrel turns a quarter turn with a spring, and a guarded action button hidden under a frosted lid. sync mode: both keys must be turned within a short window of each other — the first one latches with a countdown ring, two fingers or a two-key keyboard chord (F + J) turn both at once, and missing the window resets everything with “Out of sync”. two-person mode: your key requests a second approver (mv-request → approve(name) / reject()) and shows “Waiting for a second approver…”. Once both are turned, the lid lifts off the action for a few seconds (draining fuse); Escape or the timeout disarms. Can also require typing the resource name before the keys unlock.
| Category | Forms |
|---|---|
| Type | Web Component (<mv-two-key>) |
| Status | beta |
| Keywords | exclusive, culture, confirm, confirmation, destructive, dangerous-action, two-person-rule, four-eyes, approval, dual-control, arming, key-switch, rotary, chord, multi-touch, guard, safety |
When to use
- An irreversible operation such as deleting a production database needs more than a single confirm click
- A payment or payout above a threshold must be approved by a second person before it can be sent
- A deploy, rollback or failover should require a deliberate two-handed gesture that can't happen by accident
- An admin console needs a typed resource name plus an explicit arming step before a destructive action
Avoid when
- The action is reversible or low-stakes: a plain confirmation is enough and less tiring → use Dialog instead
- The two-person rule must be enforced for real: the approval has to be checked on the server, the component only orchestrates the UI
Install
node scripts/add.mjs two-key --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["two-key"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/two-key/two-key.js, components/two-key/two-key.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="tk-demo" style="width:min(100%,62rem);margin-inline:auto">
<style>
#tk-demo { display:grid; grid-template-columns:repeat(auto-fit, minmax(20rem, 1fr)); gap:1.25rem; align-items:start }
#tk-demo .tk-card { display:grid; gap:1rem; padding:1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#tk-demo .tk-head { display:flex; align-items:flex-start; gap:.75rem }
#tk-demo .tk-icon { display:grid; place-items:center; width:2.25rem; height:2.25rem; flex:none; border-radius:var(--mv-radius-lg); background:color-mix(in oklch, var(--mv-danger) 12%, transparent); color:var(--mv-danger) }
#tk-demo .tk-icon[data-tone="accent"] { background:var(--mv-accent-subtle); color:var(--mv-accent-fg) }
#tk-demo .tk-icon svg { width:1.125rem; height:1.125rem }
#tk-demo .tk-title { display:grid; gap:.2rem; min-width:0 }
#tk-demo .tk-title h3 { margin:0; font-size:.9375rem; letter-spacing:-.01em; line-height:1.3 }
#tk-demo .tk-title p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.45 }
#tk-demo .tk-facts { display:grid; grid-template-columns:auto 1fr; gap:.3rem 1rem; margin:0; padding:.75rem .875rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle); font-size:.8125rem }
#tk-demo .tk-facts dt { color:var(--mv-fg-muted) }
#tk-demo .tk-facts dd { margin:0; text-align:end; font-variant-numeric:tabular-nums; font-weight:500 }
#tk-demo .tk-foot { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; padding-top:.75rem; border-top:1px solid var(--mv-border) }
#tk-demo .tk-log { margin:0; min-width:0; color:var(--mv-fg-subtle); font:400 .75rem/1.5 var(--mv-font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1 1 12rem }
#tk-demo .tk-log b { color:var(--mv-fg-muted); font-weight:600 }
#tk-demo .mv-choice { font-size:.8125rem }
</style>
<!-- 1 · Sync mode + typed confirmation -->
<section class="tk-card" aria-labelledby="tk-db-title">
<div class="tk-head">
<span class="tk-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5.5" rx="7.5" ry="2.8"/><path d="M4.5 5.5v6c0 1.5 3.4 2.8 7.5 2.8"/><path d="M19.5 5.5v4"/><path d="M4.5 11.5v6c0 1.5 3.4 2.8 7.5 2.8"/><path d="m15.5 14.5 5 5"/><path d="m20.5 14.5-5 5"/></svg></span>
<div class="tk-title">
<h3 id="tk-db-title">Delete production database</h3>
<p>Permanently deletes <b>orders-prod-db</b>, its 14 replicas and 30 days of snapshots.</p>
</div>
</div>
<mv-two-key id="tk-db" mode="sync" window="3000" armed="5000" chord="F J" confirm-text="orders-prod-db" done-label="Deletion scheduled. You have 24 hours to restore it.">
<button slot="action" class="mv-button" data-variant="destructive" type="button">Delete orders-prod-db</button>
</mv-two-key>
<div class="tk-foot">
<p class="tk-log" id="tk-db-log">Type the name, then turn both keys (or hold F + J)</p>
<button class="mv-button" data-variant="ghost" data-size="sm" type="button" id="tk-db-fill">Fill name</button>
</div>
</section>
<!-- 2 · Two-person approval -->
<section class="tk-card" aria-labelledby="tk-wire-title">
<div class="tk-head">
<span class="tk-icon" data-tone="accent" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10h18"/><path d="M5 10v8"/><path d="M9.7 10v8"/><path d="M14.3 10v8"/><path d="M19 10v8"/><path d="M3 20.5h18"/><path d="M12 3.5 21 8H3z"/></svg></span>
<div class="tk-title">
<h3 id="tk-wire-title">Transfer $48,200 to vendor</h3>
<p>Payments over $25,000 need a second approver from Finance.</p>
</div>
</div>
<dl class="tk-facts">
<dt>Payee</dt><dd>Halden & Rowe Logistics</dd>
<dt>Invoice</dt><dd>INV-20931 · due Sep 30, 2026</dd>
<dt>From</dt><dd>Operating account ••4417</dd>
</dl>
<mv-two-key id="tk-wire" mode="two-person" armed="8000" label-a="Your key" label-b="Finance approver" done-label="Transfer sent. Confirmation #TX-58213.">
<button slot="action" class="mv-button" type="button">Send $48,200.00</button>
</mv-two-key>
<div class="tk-foot">
<p class="tk-log" id="tk-wire-log">Priya Raman (Finance) answers in about 2s</p>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="tk-decline"><span class="mv-choice-text"><span class="mv-choice-title">Approver declines</span></span></label>
</div>
</section>
<script type="module">
await customElements.whenDefined("mv-two-key");
const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
const logger = (id) => {
const el = document.getElementById(id);
return (text) => {
const b = document.createElement("b");
b.textContent = text;
el.replaceChildren(b, ` · ${time()}`);
};
};
const wire = (el, log) => {
el.addEventListener("mv-turn", (e) => log(`mv-turn { key: "${e.detail.key}", turned: ${e.detail.turned} }`));
el.addEventListener("mv-arm", (e) => log(`mv-arm { expires: ${e.detail.expires} }`));
el.addEventListener("mv-launch", (e) => log(`mv-launch { approver: ${e.detail.approver ? `"${e.detail.approver}"` : "null"} }`));
el.addEventListener("mv-abort", (e) => log(`mv-abort { reason: "${e.detail.reason}" }`));
};
const db = document.getElementById("tk-db");
wire(db, logger("tk-db-log"));
document.getElementById("tk-db-fill").addEventListener("click", () => {
const input = db.querySelector(".mv-two-key-input");
input.value = "orders-prod-db";
input.dispatchEvent(new Event("input", { bubbles: true }));
});
const wireEl = document.getElementById("tk-wire");
const wireLog = logger("tk-wire-log");
wire(wireEl, wireLog);
let pending = 0;
wireEl.addEventListener("mv-request", (e) => {
wireLog("mv-request → notifying Priya Raman");
clearTimeout(pending);
const decline = document.getElementById("tk-decline").checked;
pending = setTimeout(() => {
if (decline) e.detail.reject("the invoice amount doesn't match the PO");
else e.detail.resolve("Priya Raman");
}, 2000);
});
</script>
</div>Cultural reference
WarGames — John Badham (1983, film). A missile launch requires two officers to turn two keys at the same time, placed too far apart for one person to reach both. In the UI, a critical action unlocks only after two deliberate, synchronized key turns — or a key turned by a second approver.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
mode | sync | two-person | sync | sync: the user turns both keys within window ms. two-person: the user turns key A, key B belongs to a second approver (mv-request, approve(), reject()). |
window | number (ms) | 3000 | sync: maximum time between the two turns (min 300). The first key latches with a countdown ring; when it runs out both keys reset with “Out of sync”. |
armed | number (ms) | 5000 | How long the action stays unlocked once both keys are turned (min 1000). Lapsing disarms (mv-abort reason "timeout"). |
chord | string | F J | sync: the two keyboard keys (shown on the key caps) that turn key A and key B when held together while focus is inside the component. Letting go before the other key is down un-turns it. Ignored while typing in a field. |
confirm-text | string | Adds a “Type orders-prod-db to unlock the keys” field: the keys stay locked (aria-disabled) until the exact text is entered (case-sensitive, trimmed). The field is read-only while a sequence is in progress. | |
action-label | string | Confirm | Label of the built-in action button, used when no slot="action" is provided. |
label-a / label-b | string | Key 1 / Key 2 (two-person: Your key / Second approver) | Visible labels (and accessible names) of the two keys. |
done-label | string | Done | Message shown (and announced) after the action fired. |
disabled | boolean | Locks both keys and the action. | |
data-state | idle | locked | latched | waiting | armed | fired | out-of-sync | rejected | Set by the component (fired, out-of-sync and rejected return to idle after ~1.8s). |
Properties
| Name | Type | Description |
|---|---|---|
state | string | Read-only: current state (see data-state). |
turned | { a: boolean, b: boolean } | Read-only: which keys are turned. |
approver | string | null | Read-only (two-person): name given to approve(). |
Methods
| Name | Description |
|---|---|
turn("a" | "b") | Turns a key as if the user did (key B is refused in two-person mode). Returns false if not allowed (locked, already turned, armed…). |
approve(name?) | two-person, while waiting: key B turns, the name is shown under it and the action arms. |
reject(reason?) | two-person, while waiting: key A resets and “Declined: reason” is shown (mv-abort reason "rejected"). |
disarm(reason?) | Cancels a latched key, a pending request or an armed action (mv-abort with that reason, default "manual"). |
reset() | Silently returns to the initial state. |
Events
| Name | Description |
|---|---|
mv-turn | A key turned or un-turned. detail: { key: "a" | "b", turned, source?, approver? } (source: pointer | keyboard | chord | click | api). |
mv-request | two-person: key A was turned and a second approval is needed. detail: { resolve(name), reject(reason) } — equivalent to approve() / reject(), ignored once the request is cancelled or superseded. |
mv-arm | Both keys are turned: the action is unlocked. detail: { approver, expires (ms) }. |
mv-launch | The unlocked action was pressed. detail: { approver, mode }. Clicks on the action never get through while it is not armed (blocked in the capture phase). |
mv-abort | A sequence stopped. detail: { reason: "out-of-sync" | "timeout" | "escape" | "cancelled" | "released" | "rejected" | custom, detail? }. |
Content structure
| Name | Description |
|---|---|
action | The guarded button (e.g. <button slot="action" class="mv-button" data-variant="destructive">). It is moved under the lid, disabled until armed and stretched to full width. Without it, a built-in button labelled by action-label is rendered. Other children are not kept. |
CSS classes
| Name | Description |
|---|---|
mv-two-key-confirm / -input / -need | Typed confirmation: label, field, expected text. |
mv-two-key-deck | Panel holding the two keys and the link between them (role=group). |
mv-two-key-key | One key (data-key a | b, data-turned, data-latched, data-remote, data-waiting). |
mv-two-key-switch / -dial / -barrel / -slot / -ring / -marks | The rotary switch: button, dial, rotating barrel with its key slot, countdown ring, off/on marks. |
mv-two-key-caption / -label / -kbd / -who | Key caption: label, keyboard cap or approver name. |
mv-two-key-link | Connector whose halves fill as each key turns; its node unlocks when armed. |
mv-two-key-guard / -lid / -fuse | The guarded action, its hinged frosted lid and the armed-time fuse. |
mv-two-key-status / -count | Status line (role=status inside, data-tone neutral | progress | armed | danger | success) and the ticking countdown chip (aria-hidden). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-two-key-color | var(--mv-accent) | Turned keys, rings, link and fuse. |
--mv-two-key-danger | var(--mv-danger) | Out of sync / declined feedback. |
--mv-two-key-size | 5.5rem | Diameter of each key dial. |
--mv-two-key-duration | var(--mv-duration-slower) | Duration of the quarter-turn spring. |
Accessibility
Each key is a real <button role="switch"> with aria-checked, named by its visible label (“Key 1”), aria-keyshortcuts for its chord key, and aria-disabled (still focusable) while locked, armed or owned by the remote approver (the remote key is removed from the tab order). Keyboard: Tab to a key, Space or Enter turns it (again to turn it back while latched); holding the chord keys (F + J by default) turns both; Escape cancels a latched key, a pending request or an armed action. When the action arms, focus moves to it if focus was inside the component, and returns to key 1 after firing or disarming. Pointer: keys turn on pointerdown, so two fingers on two keys turn both at the same time (touch-action: none, no context menu); clicks synthesized by assistive technology (no pointer, no key) are honored too. The status line is a polite role=status (“Key 1 turned. Turn Key 2 within 3 seconds.”, “Waiting for a second approver…”, “Approved by Priya Raman. Armed: … is unlocked for 8 seconds. Press Escape to cancel.”, “Out of sync…”) and describes the action button; the ticking seconds are an aria-hidden chip so nothing chatters. The action is disabled until armed and its clicks are blocked in the capture phase. Color is never the only cue: barrel angle, marks, text and icons change together. Reduced motion (system or data-motion="reduce"): no rotation animation, lid, fills and messages change instantly, the waiting ring does not spin. The component only orchestrates the UI: enforce dual control on the server.