Exclusivobeta
Foresee <mv-foresee>
Prevé qué control está a punto de pulsar el usuario a partir de la trayectoria del puntero, antes del clic, y lleva la cuenta de sus propios aciertos. Envuelve cualquier zona de enlaces y botones y sigue la aproximación desde hasta 240px fuera de ella; la dirección (¿el rayo del movimiento cruza el objetivo?), la distancia y el frenado (las personas frenan al llegar a su objetivo: el punto de parada se estima a partir de la velocidad y la desaceleración) se puntúan para cada candidato, y cuando el mejor se mantiene por encima del umbral recibe data-foresee="foreseen", una variable --mv-foresee-confidence y un anillo tenue que se afianza con la confianza. mv-foresee (cancelable) permite que la app prepare cualquier cosa por adelantado (obtener datos, cargar el código de un diálogo), y el atributo prefetch inserta un <link rel="prefetch"> por cada enlace del mismo origen. Cada clic se contrasta después con la predicción vigente (mv-verdict: hit o dissent, ventaja en ms) y stats resume la precisión y la ventaja media. El foco de teclado y las pulsaciones táctiles cuentan como previsiones seguras, así que todos los métodos de entrada obtienen la ventaja; show-indicator="false" lo ejecuta de forma invisible, como pura inteligencia de prefetch.
| Categoría | Cursores |
|---|---|
| Tipo | Web Component (<mv-foresee>) |
| Estado | 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
Instalación
node scripts/add.mjs foresee --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["foresee"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Archivos copiados (dependencias incluidas): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/foresee/foresee.js, components/foresee/foresee.css.
Uso
Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:
<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-foresee="foreseen"] { background:color-mix(in oklab, var(--mv-accent) calc(3% * var(--mv-foresee-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-foresee 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-foresee>
<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-foresee");
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>Referencia cultural
Minority Report, Steven Spielberg (basada en el relato de Philip K. Dick de 1956) (2002, película). Unos videntes precognitivos prevén un acto antes de que ocurra, y una visión discrepante se archiva como informe de la minoría. En la interfaz, la trayectoria del puntero anticipa el clic antes de que ocurra, la app actúa por adelantado y cada predicción errónea se registra como una discrepancia en la puntuación del propio componente.
API
Attributes
| Name | Tipo | 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-foresee] | 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-foresee-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-foresee | "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-foresee-label | string | On a candidate: name reported in events and the foreseen property (default: aria-label, aria-labelledby, then text). | |
data-foresee-prefetch | "false" | On a link: never prefetched, even when foreseen. |
Properties
| Name | Tipo | 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-foresee and --mv-foresee-confidence on the foreseen target. |
CSS classes
| Name | Description |
|---|---|
mv-foresee-layer | Overlay (aria-hidden, pointer-events: none) holding the indicator; hidden when show-indicator="false". |
mv-foresee-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-foresee-confidence | 0..1 | Set on the foreseen target: style it by confidence (subtle tint, raised shadow…). |
--mv-foresee-color | var(--mv-accent) | Ring tint. |
--mv-foresee-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-foresee 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.