익스클루시브
Lock-on <mv-lock-on>
Tab 순서가 쓸모없는 2D 레이아웃(크기가 제각각인 위젯 대시보드, 카드 그리드, 칸반 보드, 좌석 배치도, 마커가 있는 지도, 노드 편집기, TV 및 콘솔 UI)을 위한 공간 키보드 탐색으로, 움직이는 대상을 계속 시야에 두는 잠금 기능을 제공합니다.
문화적 레퍼런스
젤다의 전설 시간의 오카리나, Nintendo EAD (프로듀서 미야모토 시게루) (1998, 게임). Z 주목 기능으로 플레이어는 버튼 하나로 가장 가까운 적을 고정할 수 있습니다. 전투가 이리저리 움직이는 동안 카메라는 적을 중앙에 유지하고, 다시 누르면 고정을 풀지 않고 다음 대상으로 넘어갑니다. UI에서는 화살표 키가 2D 레이아웃에서 가장 가까운 항목으로 포커스를 이동시키고, 고정 키는 포커스된 항목을 고정해 실시간 콘텐츠가 움직여도 스크롤 컨테이너가 그 항목을 중앙에 유지하며, Tab은 가장 가까운 다른 항목들로 고정 대상을 순환시킵니다.
작동 방식
Tab 순서가 쓸모없는 2D 레이아웃(크기가 고르지 않은 위젯 대시보드, 카드 그리드, 칸반 보드, 좌석 배치도, 마커가 있는 지도, 노드 편집기, TV 및 콘솔 UI)을 위한 공간 키보드 내비게이션이며, 움직이는 대상을 화면에 계속 붙잡아 두는 잠금 기능을 갖추고 있습니다. 영역 안에서 화살표 키는 실제 DOM 포커스를 그 방향으로 기하학적으로 가장 가까운 대상으로 옮깁니다. 축 방향 간격에 측면 어긋남에 대한 조정 가능한 페널티를 더해 계산하므로, 고르지 않은 그리드와 빈자리(예약된 좌석, 빈 셀)도 눈이 기대하는 대로 동작합니다. 반대편 가장자리에서 가장 잘 정렬된 대상으로 넘어가는 순환(선택), 요소별 data-lock-on="skip", 그리고 가장자리에 닿을 때까지 화살표를 안에 가두는 data-lock-on-group 컨테이너(칸반 열, 대기열)를 지원합니다. 네 개의 괄호로 된 장식용 조준선이 대상에서 대상으로 미끄러지고(동작 줄이기 설정에서는 순간 이동), 가장자리에 막히면 살짝 흔들립니다. 잠금 키(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.