Exclusivebeta
Lock-on — <mv-lock-on>
Spatial keyboard navigation for 2D layouts where Tab order is useless (dashboards of uneven widgets, card grids, kanban boards, seat maps, maps with markers, node editors, TV and console UIs), with a lock that keeps a moving target in view. Inside the region, arrow keys move real DOM focus to the geometrically nearest target in that direction: gap along the axis plus a tunable penalty for lateral offset, so uneven grids and gaps (taken seats, empty cells) behave the way the eye expects; optional wrap to the best-aligned target on the opposite edge, per-element data-lock-on="skip", and data-lock-on-group containers (a kanban column, a queue) that keep arrows inside until their edge. A decorative reticle of four brackets glides from target to target (a jump under reduced motion) and nudges when an edge blocks the move. The lock key (L or Shift+Space) pins the focused target: its scroll container, or the page, eases it back to the center every time content moves (live lists that re-sort, streams that prepend, maps that re-render) until you scroll by hand or press Escape. Arrows keep moving focus freely while the lock holds (a lighter reticle for focus, brackets + halo + “Locked” tag for the lock), and from the locked target Tab / Shift+Tab walk the lock outward through the other targets ordered by distance. It never hijacks arrows in text fields, selects, sliders or composite widgets that own them (listbox, grid, tablist, menu…), can turn the region into a single Tab stop with roving, and reports every move through a cancelable mv-lock-move.
| Category | Navigation |
|---|---|
| Type | Web Component (<mv-lock-on>) |
| Status | beta |
| Keywords | exclusive, culture, spatial-navigation, keyboard, arrow-keys, focus, roving-tabindex, dashboard, grid, seat-map, kanban, tv, gamepad, lock, follow, live-list, reticle, a11y |
When to use
- A dashboard, card grid or board is laid out in 2D and keyboard users must reach the item next to the one they are on, not the next in DOM order
- A seat map, floor plan or grid with holes needs arrow keys that skip unavailable spots and land on the nearest free one
- Someone watches one row of a live list, feed or map that keeps re-sorting and must keep it in view without chasing it
- A TV, kiosk or gamepad-driven UI needs directional focus movement with a clear focus indicator
Avoid when
- The items form a single row or column, like tabs or a toolbar: a linear roving focus is simpler and expected → use Toggle instead
- The content is tabular data with rows and columns, which needs grid semantics and cell navigation → use Data table instead
- Cards must be moved between columns with the keyboard, not just focused → use Kanban instead
Install
node scripts/add.mjs lock-on --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["lock-on"], 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, core/observe.js, components/lock-on/lock-on.js, components/lock-on/lock-on.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="lo-demo" style="width:min(100%,68rem);margin-inline:auto">
<style>
#lo-demo { display:grid; gap:1.25rem; align-content:start; font-size:.875rem }
#lo-demo .lo-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
#lo-demo .lo-bar { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.875rem 1.125rem; border-bottom:1px solid var(--mv-border) }
#lo-demo .lo-bar h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
#lo-demo .lo-bar p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#lo-demo .lo-tools { display:flex; align-items:center; gap:.5rem 1rem; flex-wrap:wrap }
#lo-demo .mv-choice { font-size:.8125rem }
#lo-demo .lo-foot { display:flex; align-items:center; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; padding:.75rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle); border-radius:0 0 var(--mv-radius-xl) var(--mv-radius-xl) }
#lo-demo .lo-keys { display:flex; align-items:center; gap:.375rem 1rem; flex-wrap:wrap; margin:0; padding:0; list-style:none; color:var(--mv-fg-muted); font-size:.75rem }
#lo-demo .lo-keys li { display:inline-flex; align-items:center; gap:.3125rem }
#lo-demo .lo-log { margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); min-height:1.05rem }
/* Dashboard */
#lo-demo #lo-ops { padding:1.125rem }
#lo-demo .lo-layout { display:grid; grid-template-columns:minmax(0,1fr) 17.5rem; gap:1rem; align-items:stretch }
#lo-demo .lo-dash { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); grid-auto-rows:minmax(6.25rem,auto); gap:.875rem }
#lo-demo .lo-t { position:relative; min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface); transition:border-color var(--mv-duration-fast), background-color var(--mv-duration-fast) }
#lo-demo .lo-w { display:flex; flex-direction:column; gap:.375rem; padding:.875rem 1rem }
#lo-demo .lo-t:hover { border-color:var(--mv-border-strong) }
#lo-demo .lo-t[data-locked] { border-color:color-mix(in oklab, var(--mv-accent) 45%, var(--mv-border)); background:color-mix(in oklab, var(--mv-accent) 4%, var(--mv-surface)) }
#lo-demo .lo-w h4 { margin:0; color:var(--mv-fg-muted); font-size:.75rem; font-weight:550 }
#lo-demo .lo-big { font-size:1.625rem; font-weight:650; letter-spacing:-.025em; font-variant-numeric:tabular-nums; line-height:1.1 }
#lo-demo .lo-delta { color:var(--mv-fg-muted); font-size:.75rem }
#lo-demo .lo-delta b { color:var(--mv-success); font-weight:600 }
#lo-demo .lo-delta b[data-bad] { color:var(--mv-danger) }
#lo-demo .lo-span2 { grid-column:span 2 }
#lo-demo .lo-tall { grid-row:span 2 }
#lo-demo .lo-chart { flex:1; display:flex; align-items:flex-end; gap:5px; min-height:6.5rem; padding-top:.5rem }
#lo-demo .lo-chart i { flex:1; height:var(--v); border-radius:3px 3px 1px 1px; background:color-mix(in oklab, var(--mv-accent) 30%, var(--mv-bg-muted)) }
#lo-demo .lo-chart i:last-child { background:var(--mv-accent) }
#lo-demo .lo-axis { display:flex; justify-content:space-between; color:var(--mv-fg-subtle); font-size:.6875rem }
#lo-demo .lo-rows { display:grid; gap:.4375rem; margin:.125rem 0 0; padding:0; list-style:none; font-size:.8125rem }
#lo-demo .lo-rows li { display:grid; grid-template-columns:5.5rem minmax(0,1fr) 2.25rem; align-items:center; gap:.625rem }
#lo-demo .lo-rows li span:last-child { text-align:right; color:var(--mv-fg-muted); font-variant-numeric:tabular-nums }
#lo-demo .lo-meter { height:6px; border-radius:3px; background:var(--mv-bg-muted); overflow:hidden }
#lo-demo .lo-meter i { display:block; width:var(--v); height:100%; border-radius:inherit; background:var(--mv-accent) }
#lo-demo .lo-list { display:grid; gap:.3125rem; margin:0; padding:0; list-style:none; font-size:.8125rem }
#lo-demo .lo-list li { display:flex; justify-content:space-between; gap:.5rem }
#lo-demo .lo-list li span:last-child { color:var(--mv-danger); font-variant-numeric:tabular-nums; font-weight:550 }
#lo-demo .lo-faces { display:flex; margin-top:.25rem }
#lo-demo .lo-faces span { display:grid; place-items:center; width:1.75rem; height:1.75rem; margin-inline-start:-.375rem; border:2px solid var(--mv-surface); border-radius:50%; background:var(--mv-bg-emphasis); font-size:.625rem; font-weight:650 }
#lo-demo .lo-faces span:first-child { margin:0 }
/* Live queue: one group, so up/down stay inside it until its edge */
#lo-demo .lo-queue-wrap { display:flex; flex-direction:column; min-height:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#lo-demo .lo-queue-head { display:flex; align-items:center; justify-content:space-between; gap:.5rem; padding:.75rem .875rem .625rem; border-bottom:1px solid var(--mv-border) }
#lo-demo .lo-queue-head h4 { margin:0; font-size:.8125rem }
#lo-demo .lo-queue { position:relative; flex:1 1 0; min-height:0; height:22rem; overflow:auto; overflow-anchor:none; margin:0; padding:.625rem; list-style:none; display:grid; align-content:start; gap:.5rem; scrollbar-width:thin }
#lo-demo .lo-tk { display:grid; gap:.1875rem; padding:.5625rem .6875rem; font-size:.8125rem }
#lo-demo .lo-tk-top { display:flex; align-items:center; gap:.4375rem; color:var(--mv-fg-muted); font-size:.6875rem }
#lo-demo .lo-tk-top code { font:600 .6875rem var(--mv-font-mono); color:var(--mv-fg) }
#lo-demo .lo-tk-top time { margin-inline-start:auto; font-variant-numeric:tabular-nums }
#lo-demo .lo-dot { width:.5rem; height:.5rem; border-radius:50%; background:var(--mv-fg-subtle) }
#lo-demo .lo-dot[data-p="urgent"] { background:var(--mv-danger) }
#lo-demo .lo-dot[data-p="high"] { background:var(--mv-warning) }
#lo-demo .lo-tk b { font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#lo-demo .lo-tk-meta { display:flex; align-items:center; justify-content:space-between; gap:.5rem; color:var(--mv-fg-muted); font-size:.75rem }
#lo-demo .lo-tk[data-new] { animation:lo-in .5s var(--mv-ease-out) both }
@keyframes lo-in { from { opacity:0; translate:0 -6px } }
/* Seat map */
#lo-demo .lo-seat-body { display:grid; grid-template-columns:minmax(0,1fr) 15rem; gap:1.25rem; padding:1.125rem; align-items:center }
#lo-demo .lo-plane { overflow-x:auto; padding:.75rem .5rem .5rem }
#lo-demo #lo-seats { width:max-content; margin-inline:auto }
#lo-demo .lo-cabin { display:grid; grid-template-columns:1.25rem repeat(15,1.875rem); grid-template-rows:1rem repeat(3,1.875rem) 1.375rem repeat(3,1.875rem); gap:.3125rem; align-items:center; justify-items:center }
#lo-demo .lo-cabin > span { color:var(--mv-fg-subtle); font-size:.6875rem; font-variant-numeric:tabular-nums }
#lo-demo .lo-seat { width:1.875rem; height:1.875rem; padding:0; border:1px solid var(--mv-border-strong); border-radius:.4375rem .4375rem .3125rem .3125rem; background:var(--mv-surface); color:var(--mv-fg-muted); font:600 .625rem var(--mv-font-sans); cursor:pointer; transition:background-color var(--mv-duration-fast), border-color var(--mv-duration-fast), color var(--mv-duration-fast) }
#lo-demo .lo-seat:hover:not(:disabled) { border-color:var(--mv-accent) }
#lo-demo .lo-seat[data-extra] { background:color-mix(in oklab, var(--mv-info) 12%, var(--mv-surface)); border-color:color-mix(in oklab, var(--mv-info) 45%, var(--mv-border)) }
#lo-demo .lo-seat[aria-pressed="true"] { background:var(--mv-accent); border-color:var(--mv-accent); color:var(--mv-fg-on-accent) }
#lo-demo .lo-seat:disabled { background:var(--mv-bg-muted); border-color:transparent; color:transparent; cursor:not-allowed; background-image:linear-gradient(45deg, transparent 45%, var(--mv-border-strong) 45% 55%, transparent 55%) }
#lo-demo .lo-legend { display:flex; gap:.5rem 1rem; flex-wrap:wrap; margin:.875rem 0 0; padding:0; list-style:none; justify-content:center; color:var(--mv-fg-muted); font-size:.75rem }
#lo-demo .lo-legend li { display:inline-flex; align-items:center; gap:.375rem }
#lo-demo .lo-legend i { width:.875rem; height:.875rem; border-radius:.25rem; border:1px solid var(--mv-border-strong); background:var(--mv-surface) }
#lo-demo .lo-legend i[data-k="extra"] { background:color-mix(in oklab, var(--mv-info) 12%, var(--mv-surface)); border-color:color-mix(in oklab, var(--mv-info) 45%, var(--mv-border)) }
#lo-demo .lo-legend i[data-k="sel"] { background:var(--mv-accent); border-color:var(--mv-accent) }
#lo-demo .lo-legend i[data-k="taken"] { background:var(--mv-bg-muted) linear-gradient(45deg, transparent 42%, var(--mv-border-strong) 42% 58%, transparent 58%); border-color:transparent }
#lo-demo .lo-sum { display:grid; gap:.625rem; padding:1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#lo-demo .lo-sum h4 { margin:0; color:var(--mv-fg-muted); font-size:.75rem; font-weight:550 }
#lo-demo .lo-sum-seats { min-height:1.5rem; font-size:1rem; font-weight:600 }
#lo-demo .lo-sum-seats[data-empty] { color:var(--mv-fg-subtle); font-weight:500; font-size:.875rem }
#lo-demo .lo-sum-total { display:flex; justify-content:space-between; color:var(--mv-fg-muted); font-size:.8125rem; font-variant-numeric:tabular-nums }
#lo-demo .lo-sum-total b { color:var(--mv-fg) }
@media (max-width:60rem) {
#lo-demo .lo-layout, #lo-demo .lo-seat-body { grid-template-columns:minmax(0,1fr) }
#lo-demo .lo-queue { flex:none; height:18rem }
}
@media (max-width:36rem) {
#lo-demo .lo-dash { grid-template-columns:minmax(0,1fr) }
#lo-demo .lo-span2 { grid-column:auto }
}
</style>
<!-- 1. Dashboard + live queue: arrows move spatially, the queue ticket stays locked and centered as tickets stream in -->
<section class="lo-app" aria-label="Support operations">
<header class="lo-bar">
<div>
<h3>Support operations</h3>
<p>Northwind Help Desk · Today, Oct 15, 2026</p>
</div>
<div class="lo-tools">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="lo-live" checked> Live queue</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="lo-wrap" checked> Wrap at edges</label>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="lo-lock-urgent">Lock oldest urgent</button>
</div>
</header>
<mv-lock-on id="lo-ops" targets=".lo-t" label="Support dashboard" wrap>
<div class="lo-layout">
<div class="lo-dash">
<article class="lo-t lo-w" aria-labelledby="lo-h1">
<h4 id="lo-h1">Open tickets</h4>
<span class="lo-big">1,284</span>
<span class="lo-delta"><b data-bad>+4.2%</b> vs last week</span>
</article>
<article class="lo-t lo-w" aria-labelledby="lo-h2">
<h4 id="lo-h2">First response</h4>
<span class="lo-big">1h 12m</span>
<span class="lo-delta"><b>−8 min</b> vs last week</span>
</article>
<article class="lo-t lo-w" aria-labelledby="lo-h3">
<h4 id="lo-h3">Satisfaction</h4>
<span class="lo-big">94.1%</span>
<span class="lo-delta"><b>+1.3 pts</b> · 612 ratings</span>
</article>
<article class="lo-t lo-w lo-span2 lo-tall" aria-labelledby="lo-h4">
<h4 id="lo-h4">Ticket volume · last 14 days</h4>
<div class="lo-chart" aria-hidden="true">
<i style="--v:48%"></i><i style="--v:55%"></i><i style="--v:51%"></i><i style="--v:62%"></i><i style="--v:70%"></i><i style="--v:38%"></i><i style="--v:33%"></i>
<i style="--v:58%"></i><i style="--v:64%"></i><i style="--v:61%"></i><i style="--v:73%"></i><i style="--v:80%"></i><i style="--v:44%"></i><i style="--v:92%"></i>
</div>
<div class="lo-axis" aria-hidden="true"><span>Oct 2</span><span>Oct 8</span><span>Today · 412</span></div>
</article>
<article class="lo-t lo-w" aria-labelledby="lo-h5">
<h4 id="lo-h5">SLA at risk</h4>
<ul class="lo-list">
<li><span>#48188 · Billing</span><span>12 min</span></li>
<li><span>#48191 · Login</span><span>26 min</span></li>
<li><span>#48196 · Shipping</span><span>41 min</span></li>
</ul>
</article>
<article class="lo-t lo-w" aria-labelledby="lo-h6">
<h4 id="lo-h6">Agents online</h4>
<span class="lo-big">38<span style="color:var(--mv-fg-subtle);font-size:1rem;font-weight:500"> / 52</span></span>
<span class="lo-faces" aria-hidden="true"><span>AO</span><span>LB</span><span>MT</span><span>SA</span><span>+34</span></span>
</article>
<article class="lo-t lo-w" aria-labelledby="lo-h7">
<h4 id="lo-h7">Top issues</h4>
<ul class="lo-list" style="--mv-danger:var(--mv-fg-muted)">
<li><span>Refunds</span><span>31%</span></li>
<li><span>Sign-in</span><span>22%</span></li>
<li><span>Shipping</span><span>18%</span></li>
</ul>
</article>
<article class="lo-t lo-w lo-span2" aria-labelledby="lo-h8">
<h4 id="lo-h8">Backlog by region</h4>
<ul class="lo-rows">
<li><span>Americas</span><span class="lo-meter"><i style="--v:72%"></i></span><span>512</span></li>
<li><span>EMEA</span><span class="lo-meter"><i style="--v:54%"></i></span><span>386</span></li>
<li><span>Asia Pacific</span><span class="lo-meter"><i style="--v:41%"></i></span><span>294</span></li>
</ul>
</article>
</div>
<aside class="lo-queue-wrap" data-lock-on-group aria-labelledby="lo-qh">
<div class="lo-queue-head">
<h4 id="lo-qh">Live queue</h4>
<span class="mv-badge" data-variant="success" data-shape="pill"><span class="mv-badge-dot" data-pulse></span>Live</span>
</div>
<ul class="lo-queue" id="lo-queue" aria-label="Incoming tickets"></ul>
</aside>
</div>
</mv-lock-on>
<div class="lo-foot">
<ul class="lo-keys" aria-label="Keyboard">
<li><span class="mv-sr-only">Arrow keys:</span><kbd class="mv-kbd" data-size="sm"><svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="rotate:270deg"><path d="M8 13V3M4 7l4-4 4 4"/></svg></kbd><kbd class="mv-kbd" data-size="sm"><svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="rotate:0deg"><path d="M8 13V3M4 7l4-4 4 4"/></svg></kbd><kbd class="mv-kbd" data-size="sm"><svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="rotate:90deg"><path d="M8 13V3M4 7l4-4 4 4"/></svg></kbd><kbd class="mv-kbd" data-size="sm"><svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="rotate:180deg"><path d="M8 13V3M4 7l4-4 4 4"/></svg></kbd> Nearest item</li>
<li><kbd class="mv-kbd" data-size="sm">L</kbd> Lock</li>
<li><kbd class="mv-kbd" data-size="sm">Tab</kbd> Move the lock to the next nearest</li>
<li><kbd class="mv-kbd" data-size="sm">Esc</kbd> Release</li>
</ul>
<p class="lo-log" id="lo-log" aria-live="off">Locked on a ticket: it stays centered as new ones arrive.</p>
</div>
</section>
<!-- 2. Seat map: one Tab stop (roving), taken seats are skipped geometrically, arrows wrap -->
<section class="lo-app" aria-label="Seat selection">
<header class="lo-bar">
<div>
<h3>Choose your seats</h3>
<p>Flight 318 · Zurich (ZRH) to New York (JFK) · Thu, Oct 15, 2026</p>
</div>
<span class="mv-badge" data-variant="secondary">Economy · 2 travelers</span>
</header>
<div class="lo-seat-body">
<div>
<div class="lo-plane">
<mv-lock-on id="lo-seats" targets=".lo-seat" label="Seat map, rows 10 to 24" roving wrap>
<div class="lo-cabin" id="lo-cabin"></div>
</mv-lock-on>
</div>
<ul class="lo-legend">
<li><i aria-hidden="true"></i>Standard · $29</li>
<li><i data-k="extra" aria-hidden="true"></i>Extra legroom · $65</li>
<li><i data-k="sel" aria-hidden="true"></i>Selected</li>
<li><i data-k="taken" aria-hidden="true"></i>Taken</li>
</ul>
</div>
<div class="lo-sum">
<h4>Your seats</h4>
<div class="lo-sum-seats" id="lo-sum-seats" aria-live="polite">12C, 12D</div>
<div class="lo-sum-total"><span>Seat fees</span><b id="lo-sum-total">$58.00</b></div>
<button type="button" class="mv-button" data-size="sm" style="width:100%">Continue to baggage</button>
</div>
</div>
</section>
<script type="module">
const ops = document.getElementById("lo-ops");
const queue = document.getElementById("lo-queue");
const log = document.getElementById("lo-log");
const say = (t) => { log.textContent = t; };
/* ── Live queue ── */
const people = [["Amara Okafor", "Lagos"], ["Lukas Brandt", "Berlin"], ["Mei Tanaka", "Osaka"], ["Sofia Álvarez", "Madrid"], ["Priya Nair", "Bengaluru"], ["Noah Williams", "Toronto"], ["Chloé Martin", "Lyon"], ["Omar Haddad", "Dubai"], ["Elena Petrova", "Sofia"], ["Diego Ramos", "São Paulo"], ["Hannah Kim", "Seoul"], ["Jack O’Brien", "Dublin"], ["Fatima Zahra", "Casablanca"], ["Liam Chen", "Vancouver"]];
const subjects = ["Refund not received", "Can’t sign in after password reset", "Package marked delivered, not here", "Charged twice for March invoice", "Change shipping address", "CSV export times out", "Two-factor code never arrives", "Upgrade to the annual plan", "Item damaged on arrival", "Questions about API rate limits", "Cancel subscription at renewal", "Invoice needs a VAT number"];
const prio = ["normal", "high", "normal", "urgent", "normal", "high"];
const badge = { urgent: ["danger", "Urgent"], high: ["warning", "High"], normal: ["secondary", "Normal"] };
let seq = 48201;
let tick = 0;
function ticket(i, age, fresh) {
const [name, city] = people[i % people.length];
const subject = subjects[(i * 5) % subjects.length];
const p = prio[i % prio.length];
const id = `#${seq++}`;
const li = document.createElement("li");
li.className = "lo-t lo-tk";
li.dataset.lockOnLabel = `Ticket ${id}, ${subject}`;
li.dataset.priority = p;
if (fresh) li.dataset.new = "";
const top = document.createElement("span");
top.className = "lo-tk-top";
const dot = document.createElement("span");
dot.className = "lo-dot";
dot.dataset.p = p;
const code = document.createElement("code");
code.textContent = id;
const time = document.createElement("time");
time.textContent = age;
top.append(dot, code, time);
const b = document.createElement("b");
b.textContent = subject;
const meta = document.createElement("span");
meta.className = "lo-tk-meta";
const who = document.createElement("span");
who.textContent = `${name} · ${city}`;
const tag = document.createElement("span");
tag.className = "mv-badge";
tag.dataset.size = "sm";
tag.dataset.variant = badge[p][0];
tag.textContent = badge[p][1];
meta.append(who, tag);
li.append(top, b, meta);
return li;
}
// Newest on top: build from the oldest and prepend.
for (let i = 13; i >= 0; i--) queue.prepend(ticket(13 - i, `${(i + 1) * 3} min`));
const initial = queue.children[6];
// Non-empty initial state: a ticket is locked and stays centered in the queue.
requestAnimationFrame(() => ops.lock(initial, { focus: false }));
setInterval(() => {
if (!document.getElementById("lo-live")?.checked || document.hidden) return;
tick++;
for (const t of queue.querySelectorAll("time")) {
const m = t.textContent === "just now" ? 0 : parseInt(t.textContent, 10);
t.textContent = `${m + 1} min`;
}
queue.prepend(ticket(seq, "just now", true));
// Every third tick a ticket gets escalated and moves up the queue (sometimes the locked one).
if (tick % 3 === 0) {
const items = [...queue.children];
const pick = tick % 6 === 0 && ops.lockedTarget ? ops.lockedTarget : items[Math.min(items.length - 1, 8 + (tick % 4))];
if (pick && pick !== items[1]) queue.insertBefore(pick, items[1]);
}
// Cap the list, never dropping the locked ticket.
while (queue.children.length > 18) {
const last = [...queue.children].reverse().find((li) => li !== ops.lockedTarget);
last?.remove();
}
}, 2400);
document.getElementById("lo-wrap").addEventListener("change", (e) => { ops.wrap = e.target.checked; });
document.getElementById("lo-lock-urgent").addEventListener("click", () => {
const urgent = [...queue.querySelectorAll('[data-priority="urgent"]')].at(-1);
if (urgent) ops.lock(urgent);
});
const arrows = { next: "Tab", previous: "Shift+Tab", first: "Home", last: "End" };
const name = (el) => el?.dataset.lockOnLabel ?? el?.querySelector("h4")?.textContent ?? "";
ops.addEventListener("mv-lock-move", (e) => say(`mv-lock-move · ${arrows[e.detail.direction] ?? e.detail.direction}${e.detail.wrapped ? ", wrapped" : ""} → ${name(e.detail.to)}`));
ops.addEventListener("mv-lock", (e) => say(e.detail.locked ? `mv-lock · locked on ${name(e.detail.target)}` : `mv-lock · released (${e.detail.reason})`));
ops.addEventListener("mv-lock-edge", (e) => say(`mv-lock-edge · nothing ${e.detail.direction === "up" ? "above" : e.detail.direction === "down" ? "below" : `to the ${e.detail.direction}`}`));
/* ── Seat map ── */
const cabin = document.getElementById("lo-cabin");
const letters = ["F", "E", "D", null, "C", "B", "A"];
const taken = new Set(["10A", "10B", "11F", "13C", "13D", "13E", "14A", "15B", "15C", "16E", "17A", "17F", "18B", "18C", "18D", "19F", "20A", "20B", "21D", "22C", "22E", "23A", "23F", "24B", "24C"]);
const selected = new Set(["12C", "12D"]);
const rows = Array.from({ length: 15 }, (_, i) => 10 + i);
const place = (el, col, row) => { el.style.gridColumn = String(col); el.style.gridRow = String(row); cabin.append(el); };
rows.forEach((n, i) => {
const label = document.createElement("span");
label.textContent = n;
label.setAttribute("aria-hidden", "true");
place(label, i + 2, 1);
});
letters.forEach((l, r) => {
if (!l) return; // the aisle
const lab = document.createElement("span");
lab.textContent = l;
lab.setAttribute("aria-hidden", "true");
place(lab, 1, r + 2);
rows.forEach((n, i) => {
const id = `${n}${l}`;
const extra = n <= 11 || n === 16;
const b = document.createElement("button");
b.type = "button";
b.className = "lo-seat";
b.textContent = l;
if (extra) b.dataset.extra = "";
const pos = l === "A" || l === "F" ? "window" : l === "C" || l === "D" ? "aisle" : "middle";
if (taken.has(id)) {
b.disabled = true;
b.setAttribute("aria-label", `Seat ${id}, taken`);
} else {
b.setAttribute("aria-label", `Seat ${id}, ${pos}${extra ? ", extra legroom" : ""}, ${extra ? "$65" : "$29"}`);
b.setAttribute("aria-pressed", String(selected.has(id)));
b.dataset.seat = id;
b.dataset.price = extra ? "65" : "29";
}
place(b, i + 2, r + 2);
});
});
const sumSeats = document.getElementById("lo-sum-seats");
const sumTotal = document.getElementById("lo-sum-total");
const refresh = () => {
const list = [...cabin.querySelectorAll('.lo-seat[aria-pressed="true"]')];
const ids = list.map((b) => b.dataset.seat).sort((a, b) => parseInt(a, 10) - parseInt(b, 10) || a.localeCompare(b));
sumSeats.textContent = ids.length ? ids.join(", ") : "No seats selected";
sumSeats.toggleAttribute("data-empty", !list.length);
const total = list.reduce((s, b) => s + Number(b.dataset.price), 0);
sumTotal.textContent = total.toLocaleString("en-US", { style: "currency", currency: "USD" });
};
cabin.addEventListener("click", (e) => {
const b = e.target.closest(".lo-seat");
if (!b || b.disabled) return;
b.setAttribute("aria-pressed", String(b.getAttribute("aria-pressed") !== "true"));
refresh();
});
refresh();
// The seat map's single Tab stop starts on the first selected seat.
requestAnimationFrame(() => document.getElementById("lo-seats").moveTo('[data-seat="12C"]', { focus: false }));
</script>
</div>Cultural reference
The Legend of Zelda: Ocarina of Time — Nintendo EAD (produced by Shigeru Miyamoto) (1998, game). Z-targeting lets the player press one button to lock onto the nearest enemy: the camera keeps it centered while the fight moves around, and pressing again cycles to the next target without breaking the lock. In the UI, arrow keys jump focus to the nearest item in a 2D layout, and a lock key pins the focused item so its scroll container keeps it centered as live content moves, with Tab cycling the lock through the nearest other items.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
targets | CSS selector | a[href], button, input, select, textarea, summary, [tabindex], [contenteditable] | Elements that arrows move between. Disabled ones (:disabled, aria-disabled="true", inside [inert]), invisible ones, anything inside data-lock-on="skip" and targets of a nested mv-lock-on are left out. With the default selector an app-set tabindex="-1" also means “not a stop”. Matched elements that are not focusable get tabindex="0" (restored when the element is removed). |
wrap | boolean | false | At an edge, arrows continue to the best-aligned target on the opposite edge (same row or column first). Without it, the reticle nudges toward the blocked side and mv-lock-edge fires. |
roving | boolean | false | The region becomes a single Tab stop: only the current target keeps tabindex="0", the others get "-1" (original values restored on removal). Tab enters on the last target used. |
lock-keys | comma-separated key combos | l, shift+space | Keys that lock the focused target (pressed on another target, the lock moves there; on the locked one, it releases). Modifiers: shift, alt, ctrl, meta. Empty disables the key; lock() still works. The region description lists them. |
follow | center | nearest | none | center | While locked, how the nearest scroll container of the target (per axis, or the page) keeps it in view as content moves: eased back to the center, only back inside the view with a small margin, or not at all. A target taller than the view is aligned to its start. |
cross-weight | number | 2 | Penalty per pixel of lateral offset in the distance score (score = gap along the axis + cross-weight × lateral gap + a small pull toward aligned centers). Raise it to favor staying in the same row or column, lower it to favor the closest target overall. |
reticle | on | compact | off | on | compact hides the “Locked” tag; off draws nothing and keeps the browser focus ring (navigation and lock still work). |
label | string | Accessible name of the region (sets aria-label). The region gets role="group" unless it already has a role. | |
data-lock-on | "skip" | "keys" | On an element inside the region: skip = never a target (nor anything inside it); keys = this subtree owns the arrow keys (a custom widget), the component leaves them alone there. | |
data-lock-on-group | boolean | On a container (a kanban column, a queue, a sidebar): arrows first look for a target inside the same group and only leave it at its edge. Home / End go to the first / last target of the group (Ctrl+Home / Ctrl+End: of the region). | |
data-lock-on-label | string | On a target: name used in announcements (default: aria-label, aria-labelledby, then its first heading or text). | |
data-locked / data-lock-current | set by the component | data-locked is set on the locked target and on the region while a lock is held; data-lock-current on the target the reticle frames. Style hooks, removed afterwards. |
Properties
| Name | Type | Description |
|---|---|---|
current | Element | null | Target that holds, or last held, focus in the region (read-only). |
locked | boolean | True while a target is locked (read-only; use lock() / unlock()). |
lockedTarget | Element | null | The locked target (read-only). |
targetList | Element[] | Every current target in reading order (read-only). |
strings | Partial<Record<string, string>> | Overrides for the region description and announcements (keys: describe, describeLock ({keys}), or, locked ({label}), released, lost ({label}), tag). English defaults. |
targets / wrap / roving / lockKeys / follow / crossWeight / reticle / label | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
move(direction) | Moves like an arrow key ("up" | "down" | "left" | "right"), from the current target or the first one; for gamepads, remotes and voice commands. Returns the new target, or null if nothing was found or mv-lock-move was canceled. |
nearest(direction, from?) | The target an arrow would reach from from (default: current), without moving. Useful for hints and tests. |
moveTo(target, { focus = true }) | Focuses a target (element or selector) and shows the reticle as if reached with the keyboard. focus: false only makes it the current target (the Tab stop with roving). |
lock(target = current, { focus = true }) | Locks a target and announces it; focus: false locks without moving focus (a row picked from elsewhere in the app). Returns false if the target is not in the region or mv-lock was canceled. |
unlock() / toggleLock() | Releases or toggles the lock. |
refresh() | Re-scans the targets after changes the internal MutationObserver cannot see (a shadow root, a canvas overlay repositioned by CSS only). |
Events
| Name | Description |
|---|---|
mv-lock-move | Cancelable, before focus moves. detail: { from, to, direction: "up" | "down" | "left" | "right" | "next" | "previous" | "first" | "last" | "none", wrapped, reason: "arrow" | "cycle" | "key" | "api" }. preventDefault() keeps focus where it is (a locked editor, an unsaved card). |
mv-lock | The lock was taken or released. detail: { locked, target, reason: "key" | "api" | "escape" | "scroll" | "removed" }. Cancelable except for removed (the target left the DOM; focus then moves to the nearest target if it was on the removed one). |
mv-lock-edge | An arrow found nothing in that direction (and wrap is off). detail: { from, direction }. Move focus to a neighboring region or load the next page here. |
Content structure
| Name | Description |
|---|---|
(content) | Any 2D layout. The component appends an aria-hidden overlay (.mv-lock-on-layer) and two visually hidden elements (description and live region); it only touches the app's DOM through tabindex (roving or non-focusable targets) and the data-locked / data-lock-current hooks. |
CSS classes
| Name | Description |
|---|---|
mv-lock-on-layer | Overlay (aria-hidden, pointer-events: none) holding the reticle, positioned over the region. |
mv-lock-on-reticle | Two reticles: data-kind="focus" follows keyboard focus (hidden when focus sits on the locked target), data-kind="lock" frames the locked target. data-active while shown, data-offscreen when the target is scrolled out of its container. Each holds .mv-lock-on-frame with four .mv-lock-on-corner[data-corner]; the lock one adds .mv-lock-on-ring (halo) and .mv-lock-on-tag (lock icon + “Locked”). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-lock-on-color | var(--mv-accent) | Brackets, ring and tag color. |
--mv-lock-on-tag-fg | var(--mv-fg-on-accent) | Text color of the “Locked” tag. |
--mv-lock-on-gap | 6px | Space between the target and the brackets (the brackets’ radius follows the target’s corners plus this gap). |
--mv-lock-on-locked-gap | 4px | Tighter gap while locked (the brackets close in). |
--mv-lock-on-corner | 14px | Bracket length (capped at 40% of the target for small ones such as seats). |
Accessibility
Focus is always real DOM focus, so screen readers announce each target exactly as they would on Tab; the reticle is decorative (aria-hidden, pointer-events: none) and replaces only the browser's default focus ring on the target it frames, never an app's own focus styles (with reticle="off" the default ring stays). The region gets role="group" with the label attribute as its name, and aria-describedby points to a visually hidden description of the keyboard model (arrows, the lock keys, Tab while locked, Escape), generated from lock-keys and localizable through strings. Arrow keys are handled only when focus is inside the region, without modifiers, and never inside text fields, selects, radio groups, media, contenteditable, [data-lock-on="keys"] or composite widgets that own them (slider, spinbutton, listbox, grid, treegrid, tree, tablist, menu, menubar, radiogroup, combobox, toolbar). Tab is only taken over while focus is on the locked target, and Escape releases the lock (then Tab leaves the region normally), which the description states: no keyboard trap; with roving the region is a single Tab stop that remembers the last target. A polite live region announces lock changes only (“Locked on Revenue chart”, “Lock released”, “Lock lost: … is no longer available”), never ordinary moves, which the focus change already conveys. If the focused target is removed (live list), focus moves to the nearest remaining target instead of falling back to the page. Scrolling by hand (wheel, touch, scrollbar, page keys) releases the lock rather than fighting the user. Reduced motion (OS or data-motion="reduce"): the reticle jumps instead of gliding, the edge nudge and halo pulse are off and the follow scroll is instant. Forced colors: brackets, ring and tag use Highlight, and the framed target gets a Highlight outline. Performance: rects are read on key presses only; the frame loop runs during a glide, briefly after scroll, resize or DOM changes, and while a lock is held (one rect read per frame), and stops when the element is removed.