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.

CategoryCursors
TypeWeb Component (<mv-precog>)
Statusbeta
Keywordsexclusive, culture, cursor, prediction, prefetch, preload, intent, trajectory, pointer, performance, navigation, speculation, analytics, anticipation

When to use

Avoid when

Install

node scripts/add.mjs precog --out ./src/marvelous

AI 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

NameTypeDefaultDescription
thresholdnumber (0..1)0.55Confidence 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.
targetsCSS selectora[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.
prefetchbooleanfalseWhen 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.
dwellnumber (ms)40How long the best candidate must stay above the threshold before it is foreseen (filters out fly-overs).
data-precog"foreseen" | anyOn 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-labelstringOn 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

NameTypeDescription
foreseen{ target, confidence, source, since, label } | nullCurrent 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 / dwellreflectedMirror the attributes.

Methods

NameDescription
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

NameDescription
mv-foreseeA 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-endThe foresight ended. detail: { target, reason: "lost" | "replaced" | "leave" | "click" | "blur" | "disconnect" }.
mv-verdictA 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

NameDescription
(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

NameDescription
mv-precog-layerOverlay (aria-hidden, pointer-events: none) holding the indicator; hidden when show-indicator="false".
mv-precog-indicator / -ringThe foresight ring. data-active while visible, data-source, data-verdict="hit | miss" for half a second after a click, --_c = confidence.

CSS variables

NameDefaultDescription
--mv-precog-confidence0..1Set on the foreseen target: style it by confidence (subtle tint, raised shadow…).
--mv-precog-colorvar(--mv-accent)Ring tint.
--mv-precog-offset3pxGap 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.