Exclusivebeta
Precog — <mv-precog>
Foresees which control the user is about to click from the pointer trajectory, before the click, and keeps score of its own foresight. Wraps any region of links and buttons and follows the approach from up to 240px outside it; heading (does the motion ray cross the target?), distance and braking (people slow down as they reach their target: the stopping point is estimated from speed and deceleration) are scored per candidate, and once the best one stays above the threshold it gets data-precog="foreseen", a --mv-precog-confidence variable and a faint ring that firms up with confidence. mv-foresee (cancelable) lets the app warm up anything ahead of time (fetch data, load a dialog's code), and the prefetch attribute inserts one <link rel="prefetch"> per same-origin link. Every click is then judged against the standing prediction (mv-verdict: hit or dissent, head start in ms) and stats sums up accuracy and average head start. Keyboard focus and touch presses count as certain foresights, so every input method gets the head start; show-indicator="false" runs it invisibly as pure prefetch intelligence.
| Category | Cursors |
|---|---|
| Type | Web Component (<mv-precog>) |
| Status | beta |
| Keywords | exclusive, culture, cursor, prediction, prefetch, preload, intent, trajectory, pointer, performance, navigation, speculation, analytics, anticipation |
When to use
- A navigation grid or dashboard should start loading the next page or its data before the user actually clicks
- A heavy dialog, editor or route chunk should be warmed up only when the user is clearly heading for its trigger
- A team wants to measure how predictable its navigation is (accuracy and head start per click) before investing in prefetching
- Hover-based prefetching fires too late or too often and a trajectory-based signal is needed
Avoid when
- The targets are few, far apart and cheap to load: plain links already feel instant
- The goal is a decorative effect that follows the cursor → use Cursor Follower instead
- Prefetching has side effects (GET links that mutate state, metered data): only use the event, never the prefetch attribute
Install
node scripts/add.mjs precog --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["precog"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/precog/precog.js, components/precog/precog.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="pc-demo" style="width:min(100%,64rem);margin-inline:auto">
<style>
#pc-demo { --pc-gap: .875rem }
#pc-demo .pc-layout { display:grid; grid-template-columns:minmax(0,1fr) 17rem; gap:1.25rem; align-items:start }
#pc-demo .pc-top { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; margin:0 0 1rem }
#pc-demo .pc-top h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
#pc-demo .pc-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#pc-demo .pc-actions { display:flex; gap:.5rem; flex-wrap:wrap }
#pc-demo .pc-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:var(--pc-gap) }
#pc-demo .pc-card { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.25rem .75rem; align-items:start; padding:1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); color:inherit; text-decoration:none; transition:border-color var(--mv-duration-fast), background-color var(--mv-duration-fast) }
#pc-demo .pc-card:hover { border-color:var(--mv-border-strong) }
#pc-demo .pc-card:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
#pc-demo .pc-card[data-precog="foreseen"] { background:color-mix(in oklab, var(--mv-accent) calc(3% * var(--mv-precog-confidence, 0) + 1%), var(--mv-surface)) }
#pc-demo .pc-ico { grid-row:span 2; display:grid; place-items:center; width:2.25rem; height:2.25rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-muted); color:var(--mv-fg-muted) }
#pc-demo .pc-ico svg { width:1.125rem; height:1.125rem }
#pc-demo .pc-card b { font-size:.875rem; font-weight:600 }
#pc-demo .pc-card span.pc-d { color:var(--mv-fg-muted); font-size:.75rem; line-height:1.4 }
#pc-demo .pc-hud { position:sticky; top:1rem; display:grid; gap:1rem; padding:1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface-raised); box-shadow:var(--mv-shadow-sm); font-size:.8125rem }
#pc-demo .pc-hud h4 { display:flex; align-items:center; justify-content:space-between; margin:0; font-size:.8125rem; font-weight:600 }
#pc-demo .pc-k { color:var(--mv-fg-muted); font-size:.6875rem; letter-spacing:.04em; text-transform:uppercase; font-weight:600 }
#pc-demo .pc-now { display:grid; gap:.375rem }
#pc-demo .pc-target { font-size:1rem; font-weight:600; letter-spacing:-.01em; min-height:1.5rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#pc-demo .pc-target[data-empty] { color:var(--mv-fg-subtle); font-weight:500; font-size:.875rem }
#pc-demo .pc-bar { position:relative; height:6px; border-radius:3px; background:var(--mv-bg-muted); overflow:hidden }
#pc-demo .pc-bar i { position:absolute; inset:0; border-radius:inherit; background:var(--mv-accent); transform-origin:left; scale:var(--v,0) 1; transition:scale 120ms linear }
#pc-demo .pc-bar-row { display:flex; justify-content:space-between; color:var(--mv-fg-muted); font-size:.75rem; font-variant-numeric:tabular-nums }
#pc-demo .pc-stats { display:grid; grid-template-columns:1fr 1fr; gap:.75rem; margin:0 }
#pc-demo .pc-stats div { display:grid; gap:.125rem; padding:.625rem .75rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#pc-demo .pc-stats dt { color:var(--mv-fg-muted); font-size:.6875rem }
#pc-demo .pc-stats dd { margin:0; font-size:1.25rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums }
#pc-demo .pc-stats dd small { font-size:.6875rem; font-weight:500; color:var(--mv-fg-muted); letter-spacing:0 }
#pc-demo .pc-log { list-style:none; margin:0; padding:0; display:grid; gap:.375rem }
#pc-demo .pc-log li { display:flex; justify-content:space-between; gap:.5rem; font-size:.75rem; color:var(--mv-fg-muted) }
#pc-demo .pc-log li b { color:var(--mv-fg); font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#pc-demo .pc-log li[data-hit="false"] b::after { content:" · dissent"; color:var(--mv-fg-subtle); font-weight:400 }
#pc-demo .pc-log li span { font-variant-numeric:tabular-nums; white-space:nowrap }
#pc-demo .pc-log .pc-empty { color:var(--mv-fg-subtle) }
#pc-demo .pc-sep { height:1px; background:var(--mv-border) }
#pc-demo .pc-hint { margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
@media (max-width:52rem) {
#pc-demo .pc-layout { grid-template-columns:minmax(0,1fr) }
#pc-demo .pc-hud { position:static }
}
@media (max-width:36rem) { #pc-demo .pc-grid { grid-template-columns:minmax(0,1fr) } }
</style>
<div class="pc-layout">
<mv-precog id="pc-region" prefetch>
<div class="pc-top">
<div>
<h3>Workspace</h3>
<p>Northwind Labs · 14 members</p>
</div>
<div class="pc-actions">
<button class="mv-button" data-variant="outline" data-size="sm" type="button">Invite</button>
<button class="mv-button" data-variant="outline" data-size="sm" type="button">Export</button>
<button class="mv-button" data-size="sm" type="button">New project</button>
</div>
</div>
<nav class="pc-grid" aria-label="Workspace sections">
<a class="pc-card" href="#overview"><span class="pc-ico"><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></span><b>Overview</b><span class="pc-d">Key metrics and recent activity</span></a>
<a class="pc-card" href="#projects"><span class="pc-ico"><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 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></span><b>Projects</b><span class="pc-d">12 active · 3 due this week</span></a>
<a class="pc-card" href="#analytics"><span class="pc-ico"><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></span><b>Analytics</b><span class="pc-d">Traffic, funnels and retention</span></a>
<a class="pc-card" href="#docs"><span class="pc-ico"><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></span><b>Documentation</b><span class="pc-d">Guides, API reference, changelog</span></a>
<a class="pc-card" href="#billing"><span class="pc-ico"><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></span><b>Billing</b><span class="pc-d">Pro plan · $240 / month</span></a>
<a class="pc-card" href="#team"><span class="pc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0"/><path d="M16 4.5a3.5 3.5 0 0 1 0 7"/><path d="M18.5 14.5a6.5 6.5 0 0 1 3 5.5"/></svg></span><b>Team</b><span class="pc-d">Roles, invitations and access</span></a>
<a class="pc-card" href="#integrations"><span class="pc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 2v6M15 2v6"/><path d="M6 8h12v4a6 6 0 0 1-12 0z"/><path d="M12 18v4"/></svg></span><b>Integrations</b><span class="pc-d">Slack, GitHub and 20 more</span></a>
<a class="pc-card" href="#security"><span class="pc-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2.5 4 5.5v6c0 4.8 3.4 8.7 8 10 4.6-1.3 8-5.2 8-10v-6z"/><path d="m8.8 12 2.2 2.2 4.2-4.4"/></svg></span><b>Security</b><span class="pc-d">SSO, audit log, API keys</span></a>
<a class="pc-card" href="#settings"><span class="pc-ico"><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="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg></span><b>Settings</b><span class="pc-d">Workspace name, region, defaults</span></a>
</nav>
</mv-precog>
<aside class="pc-hud" aria-label="Foresight monitor">
<h4>Foresight <span class="mv-badge" data-variant="secondary" data-shape="pill" id="pc-source">idle</span></h4>
<div class="pc-now">
<span class="pc-k">Foreseen</span>
<span class="pc-target" id="pc-target" data-empty>Move toward a card…</span>
<div class="pc-bar" aria-hidden="true"><i id="pc-bar"></i></div>
<div class="pc-bar-row"><span>Confidence</span><span id="pc-conf">—</span></div>
</div>
<dl class="pc-stats">
<div><dt>Accuracy</dt><dd id="pc-acc">—</dd></div>
<div><dt>Last head start</dt><dd id="pc-lead">—</dd></div>
<div><dt>Hits</dt><dd id="pc-hits">0 <small>of 0</small></dd></div>
<div><dt>Avg head start</dt><dd id="pc-avg">—</dd></div>
</dl>
<div class="pc-sep"></div>
<div class="pc-now">
<span class="pc-k">Warmed up before the click</span>
<ol class="pc-log" id="pc-log"><li class="pc-empty">Nothing yet: click any card</li></ol>
</div>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="pc-ring" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Show foresight ring</span></span>
</label>
<span class="mv-sr-only" role="status" id="pc-status"></span>
</aside>
</div>
<p class="pc-hint">Head for a card and slow down as you approach: it is foreseen before you click · Tab through the cards for keyboard foresight</p>
<script type="module">
const pc = document.getElementById("pc-region");
await customElements.whenDefined("mv-precog");
const $ = (id) => document.getElementById(id);
const target = $("pc-target"), bar = $("pc-bar"), conf = $("pc-conf"), source = $("pc-source");
const log = $("pc-log");
let raf = 0;
const labels = { pointer: "trajectory", keyboard: "keyboard", press: "press", touch: "touch", api: "app" };
const paint = () => {
raf = 0;
if (!pc.isConnected) return;
const f = pc.foreseen;
if (!f) return;
bar.style.setProperty("--v", f.confidence.toFixed(2));
conf.textContent = `${Math.round(f.confidence * 100)}%`;
raf = requestAnimationFrame(paint);
};
pc.addEventListener("mv-foresee", (e) => {
const { label, source: src } = e.detail;
target.textContent = e.detail.target.querySelector("b")?.textContent ?? label;
target.removeAttribute("data-empty");
source.textContent = labels[src] ?? src;
source.dataset.variant = src === "pointer" ? "info" : "secondary";
// Here the app would warm up whatever the section needs (data, code, a dialog…).
if (!raf) raf = requestAnimationFrame(paint);
});
pc.addEventListener("mv-foresee-end", () => {
cancelAnimationFrame(raf); raf = 0;
target.textContent = "Move toward a card…";
target.setAttribute("data-empty", "");
source.textContent = "idle"; source.dataset.variant = "secondary";
bar.style.setProperty("--v", 0);
conf.textContent = "—";
});
pc.addEventListener("click", (e) => { if (e.target.closest("a[href^='#']")) e.preventDefault(); });
const ms = (v) => (v == null ? "—" : `${v} ms`);
pc.addEventListener("mv-verdict", (e) => {
const { hit, actual, predicted, leadTime, source: src } = e.detail;
const s = pc.stats;
$("pc-acc").textContent = s.accuracy == null ? "—" : `${Math.round(s.accuracy * 100)}%`;
$("pc-hits").replaceChildren(`${s.hits} `, Object.assign(document.createElement("small"), { textContent: `of ${s.predictions}` }));
$("pc-avg").textContent = ms(s.avgLeadTime);
if (hit) $("pc-lead").textContent = ms(leadTime);
const name = (el) => el?.querySelector("b")?.textContent ?? el?.textContent.trim() ?? "";
const li = document.createElement("li");
li.dataset.hit = String(hit);
const b = document.createElement("b");
b.textContent = name(actual);
const t = document.createElement("span");
t.textContent = hit ? `${leadTime} ms early` : predicted ? `foresaw ${name(predicted)}` : "not foreseen";
li.append(b, t);
log.querySelector(".pc-empty")?.remove();
log.prepend(li);
while (log.children.length > 4) log.lastElementChild.remove();
$("pc-status").textContent = hit ? `${name(actual)} was foreseen ${leadTime} milliseconds early (${src}).` : `${name(actual)} was not foreseen.`;
});
$("pc-ring").addEventListener("change", (e) => { pc.showIndicator = e.target.checked; });
</script>
</div>Cultural reference
Minority Report — Steven Spielberg (after Philip K. Dick's 1956 novella) (2002, film). Precognitive seers foresee an act before it happens, and a dissenting vision is filed as a minority report. In the UI, the pointer trajectory foretells the click before it happens, the app acts on it ahead of time, and every wrong prediction is logged as a dissent in the component's own score.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
threshold | number (0..1) | 0.55 | Confidence above which the best candidate is foreseen (it must also stay there for dwell ms). Lower = earlier but more dissents; the foresight is dropped when its confidence falls below 60% of the threshold for 200 ms or another target wins. |
targets | CSS selector | a[href], button, [role='button'], [data-precog] | Candidates inside the region. Disabled ones (:disabled, aria-disabled="true", inside [inert]) and invisible ones are skipped. Rects are cached and refreshed only on scroll, resize or DOM changes. |
prefetch | boolean | false | When a same-origin http(s) link is foreseen and mv-foresee is not canceled, inserts <link rel="prefetch"> once per URL (deduplicated across the document). Skipped for in-page anchors, download links, data-precog-prefetch="false" and when the browser asks to save data. |
show-indicator | "true" | "false" | true | "false" hides the ring: the component still foresees, emits events and prefetches, invisibly. |
dwell | number (ms) | 40 | How long the best candidate must stay above the threshold before it is foreseen (filters out fly-overs). |
data-precog | "foreseen" | any | On a candidate: set to "foreseen" by the component while it is foreseen (its previous value is restored afterwards). Any element carrying the attribute is also a candidate by default. | |
data-precog-label | string | On a candidate: name reported in events and the foreseen property (default: aria-label, aria-labelledby, then text). | |
data-precog-prefetch | "false" | On a link: never prefetched, even when foreseen. |
Properties
| Name | Type | Description |
|---|---|---|
foreseen | { target, confidence, source, since, label } | null | Current foresight (read-only). source: "pointer" (trajectory), "keyboard" (focus), "press" (mouse/pen down), "touch" or "api". confidence is updated every frame while the pointer moves. |
stats | { predictions, hits, misses, accuracy, avgLeadTime } | Score of the pointer foresight (read-only). predictions = pointer clicks judged, hits = clicks on the foreseen target, accuracy = hits / predictions (null before the first click), avgLeadTime = mean head start of hits in ms. Keyboard and touch foresights are certain by construction and are not counted. |
threshold / targets / prefetch / showIndicator / dwell | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
foresee(target, { confidence? }) | Forces a foresight on a candidate (element or selector) from app logic, e.g. the next step of a wizard. Emits mv-foresee like any other foresight. Returns false if the element is not a candidate. |
resetStats() | Clears the score. |
Events
| Name | Description |
|---|---|
mv-foresee | A target is foreseen. detail: { target, confidence, leadTime (estimated ms until the click, null when unknown), distance (px left to travel, 0 once on the target), source, label }. Cancelable: preventDefault() skips the built-in prefetch; warm up anything else here. |
mv-foresee-end | The foresight ended. detail: { target, reason: "lost" | "replaced" | "leave" | "click" | "blur" | "disconnect" }. |
mv-verdict | A candidate was clicked (or activated with the keyboard). detail: { hit, predicted (element or null), actual, leadTime (ms between foresight and click, hits only), confidence, source, stats }. A miss is a dissent: the prediction standing at pointerdown is the one judged. |
Content structure
| Name | Description |
|---|---|
(content) | Any region containing links and buttons. The ring is drawn in an overlay layer appended to the element; the app's DOM is only touched through data-precog and --mv-precog-confidence on the foreseen target. |
CSS classes
| Name | Description |
|---|---|
mv-precog-layer | Overlay (aria-hidden, pointer-events: none) holding the indicator; hidden when show-indicator="false". |
mv-precog-indicator / -ring | The foresight ring. data-active while visible, data-source, data-verdict="hit | miss" for half a second after a click, --_c = confidence. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-precog-confidence | 0..1 | Set on the foreseen target: style it by confidence (subtle tint, raised shadow…). |
--mv-precog-color | var(--mv-accent) | Ring tint. |
--mv-precog-offset | 3px | Gap between the target and the ring (the ring's corner radius follows the target's plus this gap). |
Accessibility
Purely additive: nothing is announced and focus is never moved, the ring is aria-hidden with pointer-events: none, and the foreseen target keeps its role, name and behavior (only data-precog and a CSS variable are added, then restored). Keyboard users get the same benefit as pointer users: moving focus to a candidate is a foresight with confidence 1 (mv-foresee fires, links are prefetched), while the visible focus ring stays the only indicator so the two never compete. Touch has no hover, so a press foresees immediately and gives ~100 ms of head start before the click. The verdict is observed in the capture phase without calling preventDefault, so links and buttons work exactly as before. Reduced motion (prefers-reduced-motion or data-motion="reduce"): the ring jumps between targets with no glide and no pulse; confidence is still shown as a static change of intensity. Forced colors: the ring uses the Highlight system color. Performance: pointer events only store coordinates; scoring runs in a frameLoop that sleeps when the pointer is still, candidate rects are read once per invalidation (scroll, resize, DOM changes) and never inside the loop's write phase; everything stops when the element is removed.