エクスクルーシブ
Lock-on <mv-lock-on>
Tab の順序が役に立たない 2D レイアウト(大きさの不揃いなウィジェットが並ぶダッシュボード、カードのグリッド、カンバンボード、座席表、マーカー付きの地図、ノードエディター、テレビやゲーム機の UI)のための空間的なキーボード操作です。動く対象を視界に捉え続けるロック機能を備えています。
文化的リファレンス
ゼルダの伝説 時のオカリナ、Nintendo EAD(プロデューサー:宮本茂)(1998年、ゲーム)。Z 注目では、ボタン 1 つで最も近い敵にロックオンできます。戦闘の位置が動いてもカメラは敵を中央に捉え続け、もう一度押すとロックを外さずに次のターゲットへ切り替わります。UI では、矢印キーで 2D レイアウト内の最も近い項目へフォーカスが移動し、ロックキーでフォーカス中の項目を固定すると、ライブコンテンツが動いてもスクロールコンテナがその項目を中央に保ちます。Tab を押すと、ロックが近くの他の項目へ順に切り替わります。
仕組み
Tab の順序が役に立たない 2D レイアウト(幅の不ぞろいなウィジェットのダッシュボード、カードグリッド、かんばんボード、座席表、マーカー付きの地図、ノードエディター、TV やコンソールの UI)のための空間的なキーボードナビゲーションで、動くターゲットを視界に保つロック機能を備えます。領域内では、矢印キーが実際の DOM フォーカスを、その方向で幾何学的に最も近いターゲットへ移動させます。軸方向の距離に、横方向のずれに対する調整可能なペナルティを加えて計算するため、不ぞろいなグリッドや隙間(埋まった座席、空のセル)でも目で見て期待するとおりに動きます。オプションで反対側の端で最もそろったターゲットへの折り返し、要素ごとの data-lock-on="skip"、端に達するまで矢印キーを内部にとどめる data-lock-on-group コンテナ(かんばんの列、キュー)を使えます。4 つの角括弧からなる装飾的なレティクルがターゲットからターゲットへ滑るように移動し(モーション軽減ではジャンプ)、端で移動が阻まれると小さく揺れます。ロックキー(L または Shift+Space)はフォーカス中のターゲットを固定します。コンテンツが動くたびに(並べ替わるライブリスト、先頭に追加されるストリーム、再描画される地図)、そのスクロールコンテナまたはページがターゲットを中央へ滑らかに戻し、手動でスクロールするか Escape を押すまで続きます。ロック中も矢印キーでフォーカスは自由に動かせ(フォーカスには軽いレティクル、ロックには角括弧 + ハロー + 「Locked」タグ)、領域内でロックまたは移動した後は、ロック中のターゲットから Tab / Shift+Tab で、ほかのターゲットを距離順にたどってロックを外側へ移していき、最後のターゲットを過ぎると開始位置にロックを戻して領域を離れます。テキストフィールド、select、スライダー、矢印キーを自ら扱う複合ウィジェット(listbox、grid、tablist、menu…)では矢印キーを横取りしません。roving で領域を単一の Tab ストップにすることもでき、すべての移動をキャンセル可能な mv-lock-move で通知します。
| カテゴリー | ナビゲーション |
|---|---|
| タイプ | Web Component(<mv-lock-on>) |
| ステータス | 安定版 |
| キット | パワーユーザー向けナビゲーション |
| 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 theirs, 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
インストール
node scripts/add.mjs lock-on --out ./src/marvelousMarvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["lock-on"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
コピーされるファイル(依存関係を含む):tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, components/lock-on/lock-on.js, components/lock-on/lock-on.css。
使い方
クイックスタート、動作する最小限のマークアップ:
<mv-lock-on label="Dashboard">
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem">
<button>Revenue</button><button>Churn</button><button>Signups</button><button>Tickets</button><button>NPS</button>
</div>
</mv-lock-on>基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:
<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 }
}
@media (forced-colors: active) {
#lo-demo .lo-chart i, #lo-demo .lo-meter i { forced-color-adjust:none; background:CanvasText }
#lo-demo .lo-chart i:last-child { background:Highlight }
#lo-demo .lo-meter { border:1px solid CanvasText }
#lo-demo .lo-seat[aria-pressed="true"], #lo-demo .lo-legend i[data-k="sel"] { forced-color-adjust:none; background:Highlight; border-color:Highlight; color:HighlightText }
#lo-demo .lo-seat:disabled, #lo-demo .lo-legend i[data-k="taken"] { forced-color-adjust:none; background:Canvas linear-gradient(45deg, transparent 42%, GrayText 42% 58%, transparent 58%); border:1px dashed GrayText; color:transparent }
}
</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>Harborline 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 }));
const timer = setInterval(() => {
if (!ops.isConnected) { clearInterval(timer); return; }
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>API
Attributes
| Name | タイプ | 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 | タイプ | 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). When the locked target itself has keyboard focus, the lock's hairline ring turns into a solid 2px focus ring, so focus stays visible there too. 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 the user has locked or moved with the keyboard during this visit (sequential Tab navigation through the page that lands on a locked target passes straight through); the walk visits each target once, then the next Tab puts the lock back where the walk started and leaves the region (Shift+Tab from the start leaves it backward), and Escape releases the lock at any time, 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.