Exclusivebeta
Keen Sight — <mv-keen-sight>
Hold-to-reveal hint mode that lets keyboard users act on anything they can see without tabbing through it. Hold Alt (Option on a Mac) for a quarter second without another key, press a toggle chord or click a toggle button: the region (or the whole page with global) desaturates and dims under a veil with a hole cut out for every actionable element really in view (links, buttons, fields, ARIA widgets, your own selector), each outlined and labeled with a 1–2 letter hint. Hints are prefix-free and shortest first (home-row letters for the first targets), and their badge shape tells what happens before you type: a tag for navigation, a block for actions, an underlined field for inputs, an octagon for destructive controls. Typing a hint focuses and clicks it (fields are only focused; Shift on the last letter only moves focus), Backspace edits, and a non-matching letter is refused on the spot. Elements that declare their own shortcut (aria-keyshortcuts, data-shortcut or data-combo, shown per platform as ⌘⇧E or Ctrl+Shift+E, sequences as G · N) display it instead of a hint, so the mode also teaches the real shortcuts. Releasing the key, Escape, a click or any other key exits; nothing is ever taken from a single character key while the mode is closed. Targets are found in one read pass (rects plus a hit test, so anything covered by a dialog, a sticky header or a scroller's edge is skipped) and written in one pass, recomputed on scroll, resize and DOM changes, with hints kept stable across recomputes; mv-hint-open and mv-hint-activate are cancelable.
| Category | Navigation |
|---|---|
| Type | Web Component (<mv-keen-sight>) |
| Status | beta |
| Keywords | exclusive, culture, keyboard, hint-mode, link-hints, hints, shortcuts, hotkeys, discoverability, power-user, accessibility, navigation, overlay, spotlight, aria-keyshortcuts, focus |
When to use
- A dense web app (back office, tracker, admin table) should let keyboard users reach any visible control in two keystrokes
- New users need to discover at a glance what is clickable on a busy screen and which controls already have shortcuts
- A toolbar or dashboard has too many controls for Tab order to be practical, but adding a shortcut to each one is not
- Existing aria-keyshortcuts or declared shortcuts should be surfaced in context instead of in a separate cheat sheet
Avoid when
- Users need memorized multi-key shortcuts bound to specific actions rather than hints generated for what is on screen → use Combo instead
- Users look for an action by name, including actions that are not visible on the screen → use Command instead
- The page is mostly free text entry (an editor, a chat) where holding Alt or Option is already used for typing or word navigation
Install
node scripts/add.mjs keen-sight --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["keen-sight"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/keen-sight/keen-sight.js, components/keen-sight/keen-sight.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="ks-demo" style="width:min(100%,66rem);margin-inline:auto">
<style>
#ks-demo .ks-bar { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1.25rem; flex-wrap:wrap; margin:0 0 .875rem }
#ks-demo .ks-bar-start, #ks-demo .ks-bar-end { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap }
#ks-demo .ks-how { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.6 }
#ks-demo .ks-field { display:flex; align-items:center; gap:.5rem; font-size:.8125rem; color:var(--mv-fg-muted) }
#ks-demo .ks-field .mv-select { width:auto; min-width:10.5rem }
#ks-demo [data-keen-sight-toggle][aria-pressed="true"] { box-shadow:0 0 0 3px var(--mv-accent-subtle) }
#ks-demo .ks-log { display:flex; align-items:center; gap:.5rem; min-height:1.75rem; margin:.875rem 0 0; font-size:.8125rem; color:var(--mv-fg-muted) }
#ks-demo .ks-log b { color:var(--mv-fg); font-weight:600 }
#ks-demo .ks-app { display:grid; grid-template-columns:12.5rem minmax(0,1fr); min-height:33rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden; font-size:.875rem }
#ks-demo .ks-side { display:flex; flex-direction:column; gap:1rem; padding:1rem .75rem; border-inline-end:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#ks-demo .ks-brand { display:flex; align-items:center; gap:.5rem; padding:.25rem .5rem; font-weight:650; letter-spacing:-.01em }
#ks-demo .ks-logo { display:grid; place-items:center; width:1.625rem; height:1.625rem; border-radius:var(--mv-radius-md); background:var(--mv-fg); color:var(--mv-bg); font-size:.75rem; font-weight:700 }
#ks-demo .ks-nav { display:grid; gap:.125rem }
#ks-demo .ks-nav a { display:flex; align-items:center; gap:.625rem; padding:.4375rem .625rem; border-radius:var(--mv-radius-md); color:var(--mv-fg-muted); text-decoration:none }
#ks-demo .ks-nav a:hover { background:var(--mv-bg-muted); color:var(--mv-fg) }
#ks-demo .ks-nav a[aria-current] { background:var(--mv-surface); color:var(--mv-fg); font-weight:550; box-shadow:var(--mv-shadow-xs) }
#ks-demo .ks-nav a:focus-visible, #ks-demo .ks-link:focus-visible, #ks-demo .ks-filters a:focus-visible, #ks-demo .ks-pager a:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
#ks-demo .ks-nav svg { width:1rem; height:1rem; flex:none }
#ks-demo .ks-side-foot { margin-top:auto; padding:.75rem .625rem 0; border-top:1px solid var(--mv-border); color:var(--mv-fg-subtle); font-size:.75rem; line-height:1.5 }
#ks-demo .ks-main { display:flex; flex-direction:column; gap:1rem; padding:1.25rem 1.5rem; min-width:0 }
#ks-demo .ks-head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; flex-wrap:wrap }
#ks-demo .ks-head h3 { margin:0; font-size:1.125rem; letter-spacing:-.015em }
#ks-demo .ks-head p { margin:.1875rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#ks-demo .ks-actions { display:flex; gap:.5rem }
#ks-demo .ks-tools { display:flex; align-items:center; gap:.625rem; flex-wrap:wrap }
#ks-demo .ks-tools .mv-input { flex:1 1 14rem; min-width:0 }
#ks-demo .ks-tools .mv-select { width:auto; flex:none }
#ks-demo .ks-filters { display:flex; gap:.25rem; margin-inline-start:auto }
#ks-demo .ks-filters a { display:inline-flex; align-items:center; gap:.375rem; height:2rem; padding:0 .625rem; border-radius:var(--mv-radius-full); color:var(--mv-fg-muted); font-size:.8125rem; text-decoration:none }
#ks-demo .ks-filters a:hover { color:var(--mv-fg) }
#ks-demo .ks-filters a[aria-current] { background:var(--mv-bg-muted); color:var(--mv-fg); font-weight:550 }
#ks-demo .ks-filters small { color:var(--mv-fg-subtle); font-size:.75rem; font-variant-numeric:tabular-nums }
#ks-demo .ks-table-wrap { overflow-x:auto; margin-inline:-.25rem; padding-inline:.25rem }
#ks-demo .ks-table { width:100%; border-collapse:collapse; font-size:.8125rem }
#ks-demo .ks-table th { padding:.5rem .625rem; border-bottom:1px solid var(--mv-border); color:var(--mv-fg-muted); font-weight:500; font-size:.75rem; text-align:start; white-space:nowrap }
#ks-demo .ks-table td { padding:.5rem .625rem; border-bottom:1px solid var(--mv-border); vertical-align:middle; white-space:nowrap }
#ks-demo .ks-table tr:last-child td { border-bottom:0 }
#ks-demo .ks-table .ks-num { text-align:end; font-variant-numeric:tabular-nums }
#ks-demo .ks-table .ks-check { width:2rem }
#ks-demo .ks-table .ks-row-actions { display:flex; justify-content:flex-end; gap:.25rem }
#ks-demo .ks-link { color:var(--mv-fg); font-weight:550; text-decoration:none; border-radius:var(--mv-radius-xs); font-family:var(--mv-font-mono); font-size:.75rem }
#ks-demo .ks-link:hover { text-decoration:underline }
#ks-demo .ks-vendor { color:var(--mv-fg-muted) }
#ks-demo .ks-del { --_h:1.75rem }
#ks-demo .ks-del svg { font-size:.9375rem }
#ks-demo tr[data-removing] { opacity:.35; transition:opacity var(--mv-duration-normal) }
#ks-demo .ks-foot { display:flex; align-items:center; justify-content:space-between; gap:1rem; margin-top:auto; color:var(--mv-fg-muted); font-size:.75rem }
#ks-demo .ks-pager { display:flex; gap:.25rem }
#ks-demo .ks-pager a { display:inline-flex; align-items:center; height:1.75rem; padding:0 .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); color:var(--mv-fg); text-decoration:none }
#ks-demo .ks-pager a[aria-disabled="true"] { color:var(--mv-fg-subtle); pointer-events:none }
@media (max-width:52rem) {
#ks-demo .ks-app { grid-template-columns:minmax(0,1fr) }
#ks-demo .ks-side { display:none }
#ks-demo .ks-filters { margin-inline-start:0 }
#ks-demo .ks-hide-sm { display:none }
}
</style>
<div class="ks-bar">
<div class="ks-bar-start">
<button class="mv-button" type="button" data-size="sm" data-keen-sight-toggle="ks-app" aria-pressed="false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>
Hint mode
</button>
<p class="ks-how">or hold <kbd class="mv-kbd" id="ks-key">Alt</kbd> over the app, type a hint, release to exit</p>
</div>
<div class="ks-bar-end">
<label class="ks-field" for="ks-shortcuts">Declared shortcuts
<select class="mv-select" id="ks-shortcuts" data-size="sm">
<option value="replace" selected>Instead of hints</option>
<option value="both">Next to hints</option>
<option value="ignore">Hidden</option>
</select>
</label>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="ks-sticky">
<span class="mv-choice-text"><span class="mv-choice-title">Sticky</span></span>
</label>
</div>
</div>
<mv-keen-sight id="ks-app" open>
<div class="ks-app">
<aside class="ks-side">
<div class="ks-brand"><span class="ks-logo" aria-hidden="true">L</span>Ledgerline</div>
<nav class="ks-nav" aria-label="Main">
<a href="#overview"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>Overview</a>
<a href="#invoices" aria-current="page"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h4"/></svg>Invoices</a>
<a href="#vendors"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 21h18"/><path d="M5 21V8l7-5 7 5v13"/><path d="M9 21v-6h6v6"/></svg>Vendors</a>
<a href="#payments"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="5" width="19" height="14" rx="2"/><path d="M2.5 10h19"/><path d="M6.5 15h3"/></svg>Payments</a>
<a href="#reports"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20V10"/><path d="M10 20V4"/><path d="M16 20v-7"/><path d="M22 20H2"/></svg>Reports</a>
<a href="#settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/></svg>Settings</a>
</nav>
<p class="ks-side-foot">Northwind Trading LLC<br>Fiscal year 2026</p>
</aside>
<section class="ks-main" aria-labelledby="ks-title">
<div class="ks-head">
<div>
<h3 id="ks-title">Invoices</h3>
<p>Q3 2026 · 128 open · $412,380.00 due</p>
</div>
<div class="ks-actions">
<button class="mv-button" type="button" data-variant="outline" data-size="sm" data-shortcut="Mod+Shift+E" data-log="Exported 128 invoices to CSV">Export CSV</button>
<button class="mv-button" type="button" data-size="sm" data-shortcut="g n" data-log="New invoice draft opened">New invoice</button>
</div>
</div>
<div class="ks-tools">
<input class="mv-input" type="search" data-size="sm" placeholder="Search vendor or invoice #" aria-label="Search invoices" aria-keyshortcuts="/">
<select class="mv-select" data-size="sm" aria-label="Due date">
<option>Due this month</option>
<option>Due next 30 days</option>
<option>Past due</option>
</select>
<nav class="ks-filters" aria-label="Status">
<a href="#all" aria-current="page">All <small>128</small></a>
<a href="#pending">Pending <small>42</small></a>
<a href="#overdue">Overdue <small>7</small></a>
<a href="#paid">Paid</a>
</nav>
</div>
<div class="ks-table-wrap">
<table class="ks-table">
<thead>
<tr>
<th class="ks-check"><input type="checkbox" class="mv-checkbox" aria-label="Select all invoices"></th>
<th>Invoice</th>
<th>Vendor</th>
<th class="ks-num">Amount</th>
<th class="ks-hide-sm">Due</th>
<th>Status</th>
<th><span class="mv-sr-only">Actions</span></th>
</tr>
</thead>
<tbody id="ks-rows">
<tr>
<td><input type="checkbox" class="mv-checkbox" aria-label="Select INV-2041"></td>
<td><a class="ks-link" href="#inv-2041">INV-2041</a></td>
<td class="ks-vendor">Kasai Logistics</td>
<td class="ks-num">$18,420.00</td>
<td class="ks-hide-sm">Oct 3, 2026</td>
<td><span class="mv-badge" data-variant="warning">Pending</span></td>
<td><div class="ks-row-actions"><button class="mv-button" type="button" data-variant="ghost" data-size="sm" data-approve>Approve</button><button class="mv-button ks-del" type="button" data-variant="destructive" data-size="icon" aria-label="Delete INV-2041" data-delete><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg></button></div></td>
</tr>
<tr>
<td><input type="checkbox" class="mv-checkbox" aria-label="Select INV-2038"></td>
<td><a class="ks-link" href="#inv-2038">INV-2038</a></td>
<td class="ks-vendor">Oliveira & Filhos</td>
<td class="ks-num">$6,975.50</td>
<td class="ks-hide-sm">Sep 28, 2026</td>
<td><span class="mv-badge" data-variant="danger">Overdue</span></td>
<td><div class="ks-row-actions"><button class="mv-button" type="button" data-variant="ghost" data-size="sm" data-approve>Approve</button><button class="mv-button ks-del" type="button" data-variant="destructive" data-size="icon" aria-label="Delete INV-2038" data-delete><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg></button></div></td>
</tr>
<tr>
<td><input type="checkbox" class="mv-checkbox" aria-label="Select INV-2035"></td>
<td><a class="ks-link" href="#inv-2035">INV-2035</a></td>
<td class="ks-vendor">Nordlicht GmbH</td>
<td class="ks-num">$42,300.00</td>
<td class="ks-hide-sm">Oct 12, 2026</td>
<td><span class="mv-badge" data-variant="warning">Pending</span></td>
<td><div class="ks-row-actions"><button class="mv-button" type="button" data-variant="ghost" data-size="sm" data-approve>Approve</button><button class="mv-button ks-del" type="button" data-variant="destructive" data-size="icon" aria-label="Delete INV-2035" data-delete><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg></button></div></td>
</tr>
<tr>
<td><input type="checkbox" class="mv-checkbox" aria-label="Select INV-2029"></td>
<td><a class="ks-link" href="#inv-2029">INV-2029</a></td>
<td class="ks-vendor">Adebayo Print Co.</td>
<td class="ks-num">$2,180.75</td>
<td class="ks-hide-sm">Oct 20, 2026</td>
<td><span class="mv-badge" data-variant="success">Approved</span></td>
<td><div class="ks-row-actions"><button class="mv-button" type="button" data-variant="ghost" data-size="sm" data-approve disabled>Approve</button><button class="mv-button ks-del" type="button" data-variant="destructive" data-size="icon" aria-label="Delete INV-2029" data-delete><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg></button></div></td>
</tr>
<tr>
<td><input type="checkbox" class="mv-checkbox" aria-label="Select INV-2024"></td>
<td><a class="ks-link" href="#inv-2024">INV-2024</a></td>
<td class="ks-vendor">Tanaka Components</td>
<td class="ks-num">$11,040.00</td>
<td class="ks-hide-sm">Nov 2, 2026</td>
<td><span class="mv-badge" data-variant="warning">Pending</span></td>
<td><div class="ks-row-actions"><button class="mv-button" type="button" data-variant="ghost" data-size="sm" data-approve>Approve</button><button class="mv-button ks-del" type="button" data-variant="destructive" data-size="icon" aria-label="Delete INV-2024" data-delete><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg></button></div></td>
</tr>
</tbody>
</table>
</div>
<div class="ks-foot">
<span>Showing 1–5 of 128</span>
<nav class="ks-pager" aria-label="Pagination">
<a href="#prev" aria-disabled="true">Previous</a>
<a href="#next">Next</a>
</nav>
</div>
</section>
</div>
</mv-keen-sight>
<p class="ks-log" id="ks-log">Type a hint shown on the app: <b>F</b> goes to Overview. <kbd class="mv-kbd">Shift</kbd> on the last letter only moves focus.</p>
<script type="module">
const ks = document.getElementById("ks-app");
await customElements.whenDefined("mv-keen-sight");
const log = document.getElementById("ks-log");
const say = (...parts) => {
log.replaceChildren(...parts.map((p) => (typeof p === "string" ? p : Object.assign(document.createElement("b"), { textContent: p.b }))));
};
if (/mac|iphone|ipad/i.test(navigator.userAgentData?.platform || navigator.platform || "")) document.getElementById("ks-key").textContent = "⌥ Option";
const nameOf = (el) => el.getAttribute("aria-label") || el.textContent.replace(/\s+/g, " ").trim();
ks.addEventListener("mv-hint-activate", (e) => {
const { target, hint, action, shortcut } = e.detail;
const via = hint ? `hint ${hint.toUpperCase()}` : `shortcut ${shortcut}`;
say(action === "focus" ? "Focused " : "Activated ", { b: nameOf(target) }, ` · ${via}`);
});
// The app itself: links stay on the page, rows can be approved or deleted.
ks.addEventListener("click", (e) => {
const link = e.target.closest("a[href^='#']");
if (link) {
e.preventDefault();
const group = link.closest(".ks-nav, .ks-filters");
if (group) for (const a of group.querySelectorAll("a")) {
if (a === link) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
}
return;
}
const btn = e.target.closest("button");
if (!btn) return;
const row = btn.closest("tr");
if (btn.hasAttribute("data-approve")) {
const badge = row.querySelector(".mv-badge");
badge.dataset.variant = "success";
badge.textContent = "Approved";
btn.disabled = true;
} else if (btn.hasAttribute("data-delete")) {
row.setAttribute("data-removing", "");
setTimeout(() => row.remove(), 260);
} else if (btn.dataset.log) {
say({ b: btn.dataset.log });
}
});
document.getElementById("ks-shortcuts").addEventListener("change", (e) => { ks.shortcuts = e.target.value; });
document.getElementById("ks-sticky").addEventListener("change", (e) => { ks.sticky = e.target.checked; });
</script>
</div>Cultural reference
Assassin's Creed — Ubisoft Montreal (2007, game). Holding a button switches to a heightened sense in which the world fades to grey while every target, ally and usable object stands out in its own color, so the player sees at a glance what can be acted on. In the UI, holding a key greys out the page and cuts out every actionable element, colored and shaped by what it does, each with a short hint that activates it when typed.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
key | Alt | Control | Meta | Mod | none | Alt | Key to hold. It must be pressed alone (no other modifier) and held for hold-delay: any other key first means a chord (Alt+Tab, Alt+F…) and cancels. AltGr is never taken. With Control, Meta or Mod (⌘ on Apple, Ctrl elsewhere) the mode stays open after release, because Ctrl/⌘ + letter belongs to the browser. none: no hold trigger (toggle-key, toggle buttons and show() only). |
hold-delay | time (ms number, "250ms", "0.3s") | 250 | How long the key must be held alone before the hints appear. |
toggle-key | chord ("Mod+Shift+H", "F6") | Chord that opens the mode latched (no holding, for users who cannot hold keys). Must contain Ctrl, Alt, ⌘/Meta or Mod, or be a function key: a bare character key is refused. Letters also match by physical key. | |
sticky | boolean | Releasing the held key keeps the hints open; a hint, Escape, a click or another key closes them. | |
open | boolean | Reflects whether the mode is open (set by the component). Setting it opens the mode latched, as show() does; removing it closes. | |
global | boolean | Scans the whole document and veils the whole viewport instead of the element's children. A scoped instance takes the held key when focus or the pointer is inside it; a global one otherwise. | |
include | CSS selector | Extra targets on top of the built-in list (a[href], buttons, fields, summary, contenteditable, [role=button|link|tab|checkbox|radio|switch|menuitem*|option|treeitem|combobox|textbox|searchbox|slider|spinbutton], [data-keen-sight-kind], [data-keen-sight-hint]). | |
exclude | CSS selector | Targets to skip. data-keen-sight="off" on any element also skips it and everything inside. | |
alphabet | string | fjdkslaghrueiwoqpcmvnxbzty | Letters used for hints, most comfortable first (home row). The first targets in reading order get single letters; the last letters become prefixes of two-letter hints only when needed (up to alphabet² targets). |
shortcuts | replace | both | ignore | replace | Declared shortcuts (aria-keyshortcuts, data-shortcut, data-combo): replace shows the shortcut instead of a hint (the element then has no hint), both shows the hint followed by the shortcut, ignore hides shortcuts. |
in-fields | boolean | Allows the hold trigger while typing in a text field. Off by default: Option + letter types accented characters on a Mac and Alt + arrows moves by word. | |
platform | auto | mac | windows | linux | auto | How shortcuts and the held key are displayed (⌘⇧E vs Ctrl+Shift+E). Display only. |
show-status | "true" | "false" | true | "false" hides the status pill at the bottom of the region (typed letters, count, legend of shapes, how to exit). |
data-keen-sight-kind | navigate | act | input | destructive (on a target) | Overrides the detected kind (and makes the element a target). Detection: links and tabs navigate, text-like fields, selects, contenteditable and field roles are inputs, data-variant="destructive|danger", data-destructive and data-danger are destructive, everything else acts. | |
data-keen-sight-hint | 1–2 letters (on a target) | Pins a mnemonic hint (e.g. "n" for New). Pinned hints are reserved before generating the others, which never collide with them. | |
data-keen-sight-action | click | focus (on a target) | What typing the hint does: click (focus, then click) or focus only. Default: focus for inputs and sliders, click otherwise. | |
data-keen-sight-toggle | id of the instance, or empty (on any button) | Makes a button open or close the mode latched; it gets aria-pressed. Empty value: the instance containing it (or the global one). | |
aria-keyshortcuts / data-shortcut / data-combo | on a target | Declared shortcut shown in its badge. aria-keyshortcuts: first alternative (space-separated). data-shortcut and data-combo: first alternative (comma-separated), steps separated by spaces ("g n"), Mod = ⌘ on Apple, Ctrl elsewhere. |
Properties
| Name | Type | Description |
|---|---|---|
targets | Array<{ element, hint, kind, action, shortcut }> | Targets currently labeled, in reading order (empty while closed). hint is null for elements that show their own shortcut. |
typed | string | Letters typed so far in the current hint (read-only). |
strings | Partial<Record<string, string>> | Overrides for visible texts and announcements (keys: announceOpen, announceOpenHeld, announceNone, announceNarrow, announceNoMatch, announceShortcuts, target, targets, prompt, navigate, act, input, destructive, exit, exitHeld; placeholders {count}, {key}, {typed}). English defaults. |
key / holdDelay / toggleKey / sticky / open / global / include / exclude / alphabet / shortcuts / inFields / platform / showStatus | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
show({ source? }) | Opens the mode latched (source is reported in mv-hint-open, default "api"). Returns false if mv-hint-open was canceled or the element is not connected. |
close(reason?) | Closes the mode; reason (default "api") is reported in mv-hint-close. |
toggle() | Opens or closes; returns whether it is now open. |
activate(hint | element, { focusOnly? }) | Activates a labeled target as if its hint had been typed (mv-hint-activate is emitted, with source "api"). Returns false when the mode is closed or the target is not labeled. |
refresh() | Re-scans now, after a layout change the component cannot observe (a canvas redraw, an animation that ended). |
Events
| Name | Description |
|---|---|
mv-hint-open | Cancelable, before the hints appear. detail: { source: "hold" | "toggle" | "api", count, targets }. preventDefault() keeps the mode closed (e.g. while a modal flow is running). |
mv-hint-activate | Cancelable, when a hint is completed or activate() is called, before anything happens. detail: { target, hint, kind, shortcut, action: "click" | "focus", source: "hint" | "api" }. The mode closes either way; preventDefault() keeps the element from being focused and clicked, so the app can route it itself. |
mv-hint-close | The mode closed. detail: { reason: "release" | "escape" | "activate" | "pointer" | "key" | "toggle" | "blur" | "api" | "disconnect" }. |
Content structure
| Name | Description |
|---|---|
(content) | Any region with links, buttons and fields; left untouched (the overlay is appended to the element and lives in the top layer while open). Empty with global. |
CSS classes
| Name | Description |
|---|---|
mv-keen-sight-layer | Overlay (aria-hidden, pointer-events: none, popover="manual" in the top layer): -veil (masked backdrop), -field (marks), -status. data-held while the key is held. |
mv-keen-sight-mark | One per target, laid over it: data-kind, data-out (does not match what was typed), data-next (still matches); its ::before is the outline (solid, dashed for inputs, double for destructive). |
mv-keen-sight-badge / -shape / -char / -keys / -sep | Hint badge on the target's corner: shape by data-kind, one -char per letter (data-typed once typed), -keys for a declared shortcut (data-shortcut on the badge when it replaces the hint). |
mv-keen-sight-status / -typed / -slot / -count / -legend / -key / -swatch / -exit | Status pill: typed letters in slots, target count, legend of the shapes present, exit key. data-reject shakes it when a letter matches nothing. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-keen-sight-navigate | var(--mv-info) | Color of links and tabs. |
--mv-keen-sight-act | var(--mv-accent) | Color of buttons and other actions. |
--mv-keen-sight-input | var(--mv-success) | Color of fields. |
--mv-keen-sight-destructive | var(--mv-danger) | Color of destructive controls. |
--mv-keen-sight-veil | color-mix(in oklab, var(--mv-bg) 52%, transparent) | Tint of the veil over everything that is not a target. |
--mv-keen-sight-filter | grayscale(1) contrast(0.9) | backdrop-filter of the veil (none to only dim). |
Accessibility
Additive and opt-in: the overlay is aria-hidden with pointer-events: none, the app's DOM is never rewritten, and once activated a target is focused (with a visible focus ring) before being clicked, so screen readers read where the user landed and fields enter focus mode. Opening is announced once in a polite live region with the count (“Hint mode: 24 targets, type a hint. Escape to exit.”, plus how many show their own shortcut); narrowing (“2 match.”) and refused letters (“No hint starts with Q.”) are announced politely too. Screen readers: the hold trigger is Alt/Option pressed alone, which no major screen reader uses as its own modifier (NVDA and JAWS use Insert/Caps Lock, Narrator Caps Lock/Insert, VoiceOver Control+Option); any other modifier pressed before the delay cancels the hold, and one pressed while the mode is open (a Control+Option VoiceOver command) closes it and passes through untouched. In browse mode letters never reach the page, so nothing is hijacked; teams whose users rely on Alt can set key="none" and keep toggle-key or a toggle button. Nothing is taken while the mode is closed; while open, only hint letters, Backspace, Shift and Escape are consumed, and any other key (Tab, arrows, Enter, chords) closes the mode and does its normal job. Holding a key is never required (WCAG 2.1.1): toggle-key, sticky and [data-keen-sight-toggle] buttons (aria-pressed) open it latched, and there is no time limit to type a hint (WCAG 2.2.1). The hold trigger is ignored in text fields unless in-fields is set. Kinds are told apart by shape and outline style, not only color (tag, block, underlined field, octagon; solid, dashed, double). Reduced motion (OS or data-motion="reduce"): no fade, no staggered reveal, no shake. Forced colors: the veil is removed and outlines and badges use system colors (Highlight, LinkText, CanvasText, Mark), shapes unchanged. Performance: while closed only key listeners exist; while open, one read pass (rects, one hit test per candidate) then one write pass per frame at most, on scroll, resize or DOM changes.