Exclusivebeta
Edge Tracker — <mv-edge-tracker>
Off-screen indicators for anything the user cannot see in a scroll container, a long page or a pannable canvas: unread messages below, form errors above, mentions, search matches, selected items, collaborators' cursors. Targets are declared by selector (targets="unread: .msg[data-unread], error: [aria-invalid=true]", a kind: prefix applies to the entries after it) or from code with track(el, { kind, label, group, color }). Every out-of-view target is grouped by kind and direction, and each group gets a real button pinned on the edge it lies beyond (“3 unread · 2 screens below”), top and bottom for lists, all four edges and corners for 2-D canvases, where the pill also slides along its edge toward its target and its arrow aims at it. Pills grow stronger as their nearest target gets closer and ping once when a new target arrives out of view; activating one scrolls smoothly to the nearest target (centered, clear of sticky headers declared with offset) and a repeat goes on to the next. Each kind has its own icon and tick shape, never color alone. The optional scale draws every target as a tick on the scrollbar side, or as dots on a minimap with the visible area in 2-D, and both are clickable. Visibility comes from an IntersectionObserver rooted on the scroller; positions are measured once per layout change, so scrolling only reads the scroll offset. mv-track-jump is cancelable, mv-track-change reports the groups, mv-track-seen fires when a target comes into view (read receipts), and arrivals are announced politely and throttled.
| Category | Navigation |
|---|---|
| Type | Web Component (<mv-edge-tracker>) |
| Status | beta |
| Keywords | exclusive, culture, off-screen, offscreen-indicator, scroll, unread, new-messages, jump-to, mentions, form-errors, validation, search-results, minimap, scrollbar-markers, whiteboard, multiplayer, cursors, canvas, intersection-observer, navigation |
When to use
- A chat or feed must show how many unread messages or mentions are waiting above or below the current scroll position
- A long form must point to validation errors that are scrolled out of view, and take the user straight to each one
- A whiteboard or map canvas must show where collaborators, selected objects or comments are when they are off screen
- Search matches, changes or flagged rows in a long document or table need a count and a jump to the next one
Avoid when
- The user needs an overview of a whole article's structure and reading progress, not a few flagged targets → use Reading Map instead
- Only the overall reading position matters, with nothing specific to point at → use Scroll Progress instead
- Everything relevant already fits on screen: the pills would never appear and the component only adds weight
Install
node scripts/add.mjs edge-tracker --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["edge-tracker"], 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/focus.js, core/motion.js, components/edge-tracker/edge-tracker.js, components/edge-tracker/edge-tracker.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="et-demo" style="width:min(100%,74rem);margin-inline:auto">
<style>
#et-demo { display:grid; gap:1.25rem }
#et-demo .et-row { display:grid; grid-template-columns:minmax(0,1.35fr) minmax(0,1fr); gap:1.25rem; align-items:start }
#et-demo .et-card { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#et-demo .et-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem; min-height:3.25rem; padding:.625rem 1rem; border-bottom:1px solid var(--mv-border) }
#et-demo .et-head h3 { margin:0; font-size:.9375rem; font-weight:650; letter-spacing:-.01em }
#et-demo .et-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.75rem }
#et-demo .et-foot { display:flex; align-items:center; gap:.5rem 1rem; flex-wrap:wrap; padding:.75rem 1rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#et-demo .et-foot .mv-choice { font-size:.8125rem }
#et-demo .et-log { flex:1 1 100%; min-height:1rem; margin:0; color:var(--mv-fg-subtle); font:.6875rem/1.4 var(--mv-font-mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
/* Chat */
#et-demo .et-chat { position:relative; height:24rem; overflow:auto; overscroll-behavior:contain; padding:.5rem 0 }
#et-demo .et-msg { position:relative; display:grid; grid-template-columns:2rem minmax(0,1fr); gap:.625rem; padding:.5rem 1.25rem .5rem 1rem; transition:background-color var(--mv-duration-slow) }
#et-demo .et-msg:hover { background:var(--mv-bg-subtle) }
#et-demo .et-av { display:grid; place-items:center; width:2rem; height:2rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-emphasis); color:var(--mv-fg-muted); font-size:.6875rem; font-weight:650 }
#et-demo .et-msg header { display:flex; align-items:baseline; gap:.5rem; font-size:.8125rem }
#et-demo .et-msg header b { font-weight:600 }
#et-demo .et-msg time { color:var(--mv-fg-subtle); font-size:.6875rem; font-variant-numeric:tabular-nums }
#et-demo .et-msg p { margin:.125rem 0 0; color:var(--mv-fg); font-size:.8125rem; line-height:1.45 }
#et-demo .et-at { padding:0 .1875rem; border-radius:var(--mv-radius-xs); background:color-mix(in oklab, var(--mv-info) 16%, transparent); color:var(--mv-fg); font-weight:600 }
#et-demo .et-msg[data-mention] { background:color-mix(in oklab, var(--mv-info) 6%, transparent) }
#et-demo .et-msg[data-mention]::before { content:""; position:absolute; inset:0 auto 0 0; width:3px; background:var(--mv-info) }
#et-demo .et-msg[data-unread] header b::after { content:""; display:inline-block; width:.4375rem; height:.4375rem; margin-inline-start:.375rem; border-radius:50%; background:var(--mv-accent); vertical-align:.0625rem }
#et-demo .et-divider { display:flex; align-items:center; gap:.625rem; margin:.5rem 1rem; color:var(--mv-accent-fg); font-size:.6875rem; font-weight:650; letter-spacing:.04em; text-transform:uppercase }
#et-demo .et-divider::before, #et-demo .et-divider::after { content:""; flex:1; height:1px; background:color-mix(in oklab, var(--mv-accent) 45%, transparent) }
#et-demo .et-day { margin:.75rem 1rem .25rem; color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:600; text-align:center }
/* Form */
#et-demo .et-form { position:relative; height:24rem; overflow:auto; overscroll-behavior:contain }
#et-demo .et-form-head { position:sticky; top:0; z-index:1; display:flex; align-items:center; justify-content:space-between; height:3rem; padding:0 1rem; border-bottom:1px solid var(--mv-border); background:color-mix(in oklab, var(--mv-surface) 92%, transparent); backdrop-filter:blur(6px); font-size:.8125rem; font-weight:600 }
#et-demo .et-form-head span { color:var(--mv-fg-muted); font-weight:500; font-size:.75rem }
#et-demo .et-fields { display:grid; gap:1rem; padding:1rem 1rem 1.5rem }
#et-demo .et-fields h4 { margin:.5rem 0 -.25rem; font-size:.75rem; font-weight:650; color:var(--mv-fg-muted); letter-spacing:.04em; text-transform:uppercase }
#et-demo .et-pair { display:grid; grid-template-columns:1fr 1fr; gap:.75rem }
/* Board */
#et-demo .et-board { position:relative; height:27rem; overflow:auto; overscroll-behavior:contain; background:var(--mv-bg-subtle) }
#et-demo .et-canvas { position:relative; width:2400px; height:1600px; background:radial-gradient(circle, var(--mv-border-strong) 1px, transparent 1.3px) 0 0 / 24px 24px }
#et-demo .et-col { position:absolute; top:40px; color:var(--mv-fg-subtle); font-size:.75rem; font-weight:650; letter-spacing:.05em; text-transform:uppercase }
#et-demo .et-note { position:absolute; display:grid; gap:.375rem; width:176px; min-height:104px; padding:.75rem .8125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-surface-raised); box-shadow:var(--mv-shadow-sm); color:var(--mv-fg); font:inherit; font-size:.8125rem; line-height:1.4; text-align:start; align-content:start; cursor:pointer }
#et-demo .et-note small { color:var(--mv-fg-muted); font-size:.6875rem }
#et-demo .et-note::before { content:""; position:absolute; inset:0 0 auto; height:4px; border-radius:var(--mv-radius-md) var(--mv-radius-md) 0 0; background:var(--c, var(--mv-border-strong)) }
#et-demo .et-note[aria-pressed="true"] { border-color:var(--mv-accent); box-shadow:0 0 0 2px color-mix(in oklab, var(--mv-accent) 30%, transparent), var(--mv-shadow-sm) }
#et-demo .et-note:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
#et-demo .et-pin { position:absolute; display:grid; place-items:center; width:28px; height:28px; border-radius:50% 50% 50% 4px; background:var(--mv-success); color:var(--mv-fg-on-accent); font-size:.6875rem; font-weight:700; box-shadow:var(--mv-shadow-md) }
#et-demo .et-cursor { position:absolute; top:0; left:0; display:flex; align-items:flex-start; gap:2px; color:var(--c); pointer-events:none; transition:translate 1.8s var(--mv-ease-in-out) }
#et-demo .et-cursor svg { width:18px; height:18px; fill:currentColor; stroke:var(--mv-surface); stroke-width:1.5 }
#et-demo .et-cursor span { margin-top:12px; padding:2px 6px; border-radius:var(--mv-radius-sm); background:var(--c); color:var(--mv-fg-on-accent); font-size:.6875rem; font-weight:600; white-space:nowrap }
@media (prefers-reduced-motion: reduce) { #et-demo .et-cursor { transition:none } }
@media (max-width:56rem) { #et-demo .et-row { grid-template-columns:minmax(0,1fr) } }
</style>
<div class="et-row">
<!-- 1 · Chat channel: unread messages below, mentions above -->
<section class="et-card" aria-labelledby="et-chat-title">
<div class="et-head">
<div>
<h3 id="et-chat-title"># launch-planning</h3>
<p>Northwind · 9 members · you are Alex Chen</p>
</div>
<span class="mv-badge" data-variant="secondary" data-shape="pill" id="et-chat-badge">6 unread</span>
</div>
<mv-edge-tracker id="et-chat-tracker" targets="mention: .et-msg[data-mention], unread: .et-msg[data-unread]" scale>
<div class="et-chat" id="et-chat" role="log" aria-label="Messages in launch-planning" tabindex="0">
<p class="et-day">Monday, September 21</p>
<article class="et-msg"><span class="et-av" aria-hidden="true">AO</span><div><header><b>Amara Okafor</b><time>9:02 AM</time></header><p>Morning! The launch checklist is in the doc. Please add anything missing by noon.</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">KW</span><div><header><b>Kenji Watanabe</b><time>9:05 AM</time></header><p>Pricing page copy is final.</p></div></article>
<article class="et-msg" data-mention data-track-label="Kenji Watanabe: annual plan math"><span class="et-av" aria-hidden="true">KW</span><div><header><b>Kenji Watanabe</b><time>9:06 AM</time></header><p><span class="et-at">@Alex</span> can you double-check the annual plan math before it goes out?</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">SL</span><div><header><b>Sofia Lindqvist</b><time>9:11 AM</time></header><p>German and Swedish translations landed in staging.</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">MG</span><div><header><b>Mateo García</b><time>9:14 AM</time></header><p>QA found a layout bug on the checkout step at 320px. Fixing it now.</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">PR</span><div><header><b>Priya Raman</b><time>9:20 AM</time></header><p>Status page and incident runbook are updated.</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">LW</span><div><header><b>Lukas Weber</b><time>9:26 AM</time></header><p>Load test at 3× expected traffic passed, p95 at 180 ms.</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">CM</span><div><header><b>Chloé Martin</b><time>9:31 AM</time></header><p>Press embargo lifts Tuesday at 9:00 AM ET. The draft is in the shared folder.</p></div></article>
<article class="et-msg" data-mention data-track-label="Yusuf Demir: webhook secret"><span class="et-av" aria-hidden="true">YD</span><div><header><b>Yusuf Demir</b><time>9:37 AM</time></header><p><span class="et-at">@Alex</span> the payment webhook secret needs rotating before we go live.</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">HK</span><div><header><b>Hana Kim</b><time>9:44 AM</time></header><p>Onboarding emails are scheduled: day 0, day 2 and day 7.</p></div></article>
<article class="et-msg" id="et-chat-start"><span class="et-av" aria-hidden="true">AO</span><div><header><b>Amara Okafor</b><time>9:52 AM</time></header><p>Reminder: go/no-go call at 2:00 PM.</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">KW</span><div><header><b>Kenji Watanabe</b><time>10:03 AM</time></header><p>Annual plan is $192/year, 20% off $20/month. Math checks out.</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">SL</span><div><header><b>Sofia Lindqvist</b><time>10:10 AM</time></header><p>App Store screenshots are approved.</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">MG</span><div><header><b>Mateo García</b><time>10:18 AM</time></header><p>Checkout fix is merged and deploying to staging.</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">PR</span><div><header><b>Priya Raman</b><time>10:25 AM</time></header><p>Support macros for the top 10 questions are ready.</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">LW</span><div><header><b>Lukas Weber</b><time>10:31 AM</time></header><p>CDN cache rules updated for the new marketing pages.</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">CM</span><div><header><b>Chloé Martin</b><time>10:40 AM</time></header><p>Customer quotes are cleared by legal.</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">YD</span><div><header><b>Yusuf Demir</b><time>10:44 AM</time></header><p>Pager rota for launch week is drafted.</p></div></article>
<article class="et-msg"><span class="et-av" aria-hidden="true">HK</span><div><header><b>Hana Kim</b><time>10:47 AM</time></header><p>Referral credits are live in staging: $25 for both sides.</p></div></article>
<p class="et-divider" role="separator">New messages</p>
<article class="et-msg" data-unread data-track-label="Yusuf Demir: webhook rotated"><span class="et-av" aria-hidden="true">YD</span><div><header><b>Yusuf Demir</b><time>10:52 AM</time></header><p>Webhook secret rotated in staging. Production at 1:00 PM.</p></div></article>
<article class="et-msg" data-unread data-track-label="Hana Kim: welcome email test"><span class="et-av" aria-hidden="true">HK</span><div><header><b>Hana Kim</b><time>10:58 AM</time></header><p>Welcome email A/B test: variant B is up 12% on click-through.</p></div></article>
<article class="et-msg" data-mention data-unread data-track-label="Amara Okafor: monitoring rota"><span class="et-av" aria-hidden="true">AO</span><div><header><b>Amara Okafor</b><time>11:04 AM</time></header><p><span class="et-at">@Alex</span> can you own the launch-day monitoring rota?</p></div></article>
<article class="et-msg" data-unread data-track-label="Kenji Watanabe: changelog"><span class="et-av" aria-hidden="true">KW</span><div><header><b>Kenji Watanabe</b><time>11:09 AM</time></header><p>Changelog post is drafted. It needs one screenshot.</p></div></article>
<article class="et-msg" data-unread data-track-label="Sofia Lindqvist: French review"><span class="et-av" aria-hidden="true">SL</span><div><header><b>Sofia Lindqvist</b><time>11:15 AM</time></header><p>French translation review is done, 3 strings changed.</p></div></article>
<article class="et-msg" data-unread data-track-label="Mateo García: staging green"><span class="et-av" aria-hidden="true">MG</span><div><header><b>Mateo García</b><time>11:21 AM</time></header><p>Staging is green. Ready for the go/no-go.</p></div></article>
</div>
</mv-edge-tracker>
<div class="et-foot">
<button type="button" class="mv-button" data-size="sm" data-variant="outline" id="et-chat-new">Simulate a new message</button>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="et-chat-read" checked> Mark read when seen</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="et-chat-scale" checked> Scale</label>
<p class="et-log" id="et-chat-log">Click a pill to jump; click again for the next one.</p>
</div>
</section>
<!-- 2 · Long form: validation errors above and below, under a sticky header -->
<section class="et-card" aria-labelledby="et-form-title">
<div class="et-head">
<div>
<h3 id="et-form-title">Checkout</h3>
<p>Order #NW-20931 · $1,248.00</p>
</div>
<span class="mv-badge" data-variant="danger" data-shape="pill" id="et-form-badge">3 errors</span>
</div>
<mv-edge-tracker id="et-form-tracker" targets="error: .mv-input[aria-invalid='true']" offset="48 0 0 0" focus-target>
<div class="et-form" id="et-form">
<div class="et-form-head">Shipping & payment <span>Step 2 of 3</span></div>
<form class="et-fields" id="et-form-fields" novalidate>
<h4>Contact</h4>
<div class="mv-field">
<label class="mv-label" for="et-f-name">Full name</label>
<input class="mv-input" id="et-f-name" autocomplete="name" value="Maria Silva" required>
<p class="mv-field-error">Enter your full name.</p>
</div>
<div class="mv-field">
<label class="mv-label" for="et-f-email">Email</label>
<input class="mv-input" id="et-f-email" type="email" autocomplete="email" value="maria.silva@" aria-invalid="true" aria-describedby="et-f-email-err" required>
<p class="mv-field-error" id="et-f-email-err">Enter a valid email address.</p>
</div>
<div class="mv-field">
<label class="mv-label" for="et-f-phone">Phone</label>
<input class="mv-input" id="et-f-phone" type="tel" autocomplete="tel" value="+1 415 555 0132">
</div>
<h4>Shipping address</h4>
<div class="mv-field">
<label class="mv-label" for="et-f-street">Street address</label>
<input class="mv-input" id="et-f-street" autocomplete="address-line1" value="1250 Market Street, Suite 400" required>
<p class="mv-field-error">Enter a street address.</p>
</div>
<div class="et-pair">
<div class="mv-field">
<label class="mv-label" for="et-f-city">City</label>
<input class="mv-input" id="et-f-city" autocomplete="address-level2" value="San Francisco" required>
<p class="mv-field-error">Enter a city.</p>
</div>
<div class="mv-field">
<label class="mv-label" for="et-f-zip">ZIP code</label>
<input class="mv-input" id="et-f-zip" autocomplete="postal-code" value="941" inputmode="numeric" pattern="\d{5}" aria-invalid="true" aria-describedby="et-f-zip-err" required>
<p class="mv-field-error" id="et-f-zip-err">ZIP codes have 5 digits.</p>
</div>
</div>
<div class="mv-field">
<label class="mv-label" for="et-f-country">Country</label>
<input class="mv-input" id="et-f-country" autocomplete="country-name" value="United States" required>
<p class="mv-field-error">Enter a country.</p>
</div>
<h4>Payment</h4>
<div class="mv-field">
<label class="mv-label" for="et-f-card">Card number</label>
<input class="mv-input" id="et-f-card" autocomplete="cc-number" inputmode="numeric" value="4242 4242 4242" pattern="(\d{4} ?){4}" aria-invalid="true" aria-describedby="et-f-card-err" required>
<p class="mv-field-error" id="et-f-card-err">Card numbers have 16 digits.</p>
</div>
<div class="et-pair">
<div class="mv-field">
<label class="mv-label" for="et-f-exp">Expiry</label>
<input class="mv-input" id="et-f-exp" autocomplete="cc-exp" placeholder="MM/YY" pattern="(0[1-9]|1[0-2])/\d{2}" required>
<p class="mv-field-error">Use MM/YY.</p>
</div>
<div class="mv-field">
<label class="mv-label" for="et-f-cvc">CVC</label>
<input class="mv-input" id="et-f-cvc" autocomplete="cc-csc" inputmode="numeric" placeholder="123" pattern="\d{3,4}" required>
<p class="mv-field-error">3 or 4 digits.</p>
</div>
</div>
</form>
</div>
</mv-edge-tracker>
<div class="et-foot">
<button type="button" class="mv-button" data-size="sm" id="et-form-submit">Place order</button>
<button type="button" class="mv-button" data-size="sm" data-variant="ghost" id="et-form-fix">Fix all</button>
<p class="et-log" id="et-form-log">Pills skip the sticky header and focus the field they lead to.</p>
</div>
</section>
</div>
<!-- 3 · Whiteboard: collaborators, selection and comments in every direction -->
<section class="et-card" aria-labelledby="et-board-title">
<div class="et-head">
<div>
<h3 id="et-board-title">Q4 onboarding journey</h3>
<p>Whiteboard · 5 people here · drag the scrollbars or scroll to pan</p>
</div>
<span class="mv-badge" data-variant="secondary" data-shape="pill" id="et-board-badge">2 selected</span>
</div>
<mv-edge-tracker id="et-board-tracker" targets="selected: .et-note[aria-pressed='true'], comment: .et-pin, cursor: .et-cursor" scale>
<div class="et-board" id="et-board" tabindex="0" aria-label="Whiteboard canvas">
<div class="et-canvas" id="et-canvas">
<span class="et-col" style="left:120px">Discover</span>
<span class="et-col" style="left:720px">Sign up</span>
<span class="et-col" style="left:1320px">Activate</span>
<span class="et-col" style="left:1920px">Retain</span>
<button type="button" class="et-note" aria-pressed="true" style="left:120px;top:300px;--c:var(--mv-info)">Landing page A/B: headline variants<small>Owner: Chloé · due Oct 2</small></button>
<button type="button" class="et-note" aria-pressed="false" style="left:340px;top:520px;--c:var(--mv-info)">Comparison page vs. spreadsheets<small>Owner: Kenji</small></button>
<button type="button" class="et-note" aria-pressed="false" style="left:760px;top:420px;--c:var(--mv-warning)">Social sign-in (Google, Apple)<small>Owner: Mateo</small></button>
<button type="button" class="et-note" aria-pressed="false" style="left:980px;top:620px;--c:var(--mv-warning)">Shorter signup: 2 fields instead of 5<small>Owner: Hana · in review</small></button>
<button type="button" class="et-note" aria-pressed="false" style="left:1180px;top:760px;--c:var(--mv-success)">Checklist: first project in 3 steps<small>Owner: Priya</small></button>
<button type="button" class="et-note" aria-pressed="false" style="left:1340px;top:560px;--c:var(--mv-success)">Sample data for empty workspaces<small>Owner: Lukas</small></button>
<button type="button" class="et-note" aria-pressed="false" style="left:1560px;top:880px;--c:var(--mv-success)">Invite teammates prompt on day 2<small>Owner: Amara</small></button>
<button type="button" class="et-note" aria-pressed="true" style="left:2040px;top:1300px;--c:var(--mv-danger)">Win-back email after 14 idle days<small>Owner: Yusuf · blocked</small></button>
<button type="button" class="et-note" aria-pressed="false" style="left:1960px;top:1020px;--c:var(--mv-danger)">Usage digest every Monday<small>Owner: Sofia</small></button>
<span class="et-pin" style="left:1210px;top:150px" data-track-label="Comment from Priya: which metric defines activated?">3</span>
<span class="et-pin" style="left:2290px;top:740px" data-track-label="Comment from Lukas: digest opt-out flow">1</span>
<span class="et-pin" style="left:1060px;top:600px" data-track-label="Comment from Hana: field order">2</span>
<div class="et-cursor" style="--c:var(--mv-info);translate:2120px 230px" data-track-label="Priya Raman" data-track-group="priya" data-track-color="var(--mv-info)"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3 19 10l-6.2 1.9L10 18.5z"/></svg><span>Priya Raman</span></div>
<div class="et-cursor" style="--c:var(--mv-success);translate:180px 880px" data-track-label="Kenji Watanabe" data-track-group="kenji" data-track-color="var(--mv-success)"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3 19 10l-6.2 1.9L10 18.5z"/></svg><span>Kenji Watanabe</span></div>
<div class="et-cursor" style="--c:var(--mv-danger);translate:1280px 1460px" data-track-label="Chloé Martin" data-track-group="chloe" data-track-color="var(--mv-danger)"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3 19 10l-6.2 1.9L10 18.5z"/></svg><span>Chloé Martin</span></div>
<div class="et-cursor" style="--c:var(--mv-warning);translate:1120px 700px" data-track-label="Hana Kim" data-track-group="hana" data-track-color="var(--mv-warning)"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3 19 10l-6.2 1.9L10 18.5z"/></svg><span>Hana Kim</span></div>
</div>
</div>
</mv-edge-tracker>
<div class="et-foot">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="et-board-live"> Live cursors</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="et-board-scale" checked> Minimap</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="et-board-compact"> Compact pills</label>
<p class="et-log" id="et-board-log">Click a note to select it. Pills slide along the edges toward what they point at.</p>
</div>
</section>
<script type="module">
const $ = (id) => document.getElementById(id);
await customElements.whenDefined("mv-edge-tracker");
const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
/* ── Chat ── */
const chat = $("et-chat"), chatT = $("et-chat-tracker"), chatLog = $("et-chat-log");
chat.scrollTop = $("et-chat-start").offsetTop - 8;
const unreadCount = () => chat.querySelectorAll("[data-unread]").length;
const syncBadge = () => {
const n = unreadCount();
$("et-chat-badge").textContent = n ? `${n} unread` : "All caught up";
$("et-chat-badge").dataset.variant = n ? "secondary" : "success";
};
// Read receipts: a message seen for 900 ms is marked read.
const seenTimers = new WeakMap();
chatT.addEventListener("mv-track-seen", (e) => {
const el = e.detail.target;
if (!$("et-chat-read").checked || !el.hasAttribute("data-unread")) return;
clearTimeout(seenTimers.get(el));
seenTimers.set(el, setTimeout(() => { el.removeAttribute("data-unread"); syncBadge(); }, 900));
});
chatT.addEventListener("mv-track-jump", (e) => {
const d = e.detail;
chatLog.textContent = `mv-track-jump · ${d.kind} ${d.index} of ${d.total} · ${d.label}`;
});
chatT.addEventListener("mv-track-change", (e) => {
if (!chatLog.textContent.startsWith("mv-track-jump")) chatLog.textContent = `mv-track-change · ${e.detail.offscreen} off-screen in ${e.detail.groups.length} group(s)`;
});
const incoming = [
["LW", "Lukas Weber", "Error budget dashboard is shared in the channel topic."],
["CM", "Chloé Martin", "@Alex the press kit link needs your sign-off.", true],
["PR", "Priya Raman", "Support inbox auto-replies are switched on for launch day."],
["YD", "Yusuf Demir", "Production webhook rotated, all green."],
];
let next = 0;
$("et-chat-new").addEventListener("click", () => {
const [ini, name, text, mention] = incoming[next++ % incoming.length];
const art = document.createElement("article");
art.className = "et-msg";
art.setAttribute("data-unread", "");
if (mention) art.setAttribute("data-mention", "");
art.dataset.trackLabel = `${name}: ${text.replace("@Alex ", "").slice(0, 32)}`;
const av = Object.assign(document.createElement("span"), { className: "et-av", textContent: ini });
av.setAttribute("aria-hidden", "true");
const body = document.createElement("div");
const head = document.createElement("header");
head.append(Object.assign(document.createElement("b"), { textContent: name }), Object.assign(document.createElement("time"), { textContent: time() }));
const p = document.createElement("p");
if (mention) { p.append(Object.assign(document.createElement("span"), { className: "et-at", textContent: "@Alex" }), text.replace("@Alex", "")); }
else p.textContent = text;
body.append(head, p);
art.append(av, body);
chat.append(art);
syncBadge();
});
$("et-chat-scale").addEventListener("change", (e) => { chatT.scale = e.target.checked; });
/* ── Form ── */
const formT = $("et-form-tracker"), fields = $("et-form-fields"), formLog = $("et-form-log");
const form = $("et-form");
form.scrollTop = $("et-f-street").closest(".mv-field").offsetTop - 64;
const validate = (input) => {
const ok = input.checkValidity() && (input.type !== "email" || /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(input.value));
if (ok) input.removeAttribute("aria-invalid"); else input.setAttribute("aria-invalid", "true");
return ok;
};
const syncErrors = () => {
const n = fields.querySelectorAll("[aria-invalid='true']").length;
$("et-form-badge").textContent = n ? `${n} error${n === 1 ? "" : "s"}` : "Ready to pay";
$("et-form-badge").dataset.variant = n ? "danger" : "success";
};
fields.addEventListener("input", (e) => { if (e.target.matches("[aria-invalid='true']")) { validate(e.target); syncErrors(); } });
fields.addEventListener("focusout", (e) => { if (e.target.matches(".mv-input") && e.target.value) { validate(e.target); syncErrors(); } });
$("et-form-submit").addEventListener("click", () => {
let bad = 0;
for (const input of fields.querySelectorAll(".mv-input")) if (!validate(input)) bad++;
syncErrors();
formLog.textContent = bad ? `${bad} field${bad === 1 ? " needs" : "s need"} attention: follow the red pills.` : "Order placed. Thank you, Maria!";
});
$("et-form-fix").addEventListener("click", () => {
const fix = { "et-f-email": "[email protected]", "et-f-zip": "94103", "et-f-card": "4242 4242 4242 4242", "et-f-exp": "08/29", "et-f-cvc": "314" };
for (const [id, v] of Object.entries(fix)) { $(id).value = v; validate($(id)); }
syncErrors();
formLog.textContent = "All fields fixed: the pills are gone.";
});
formT.addEventListener("mv-track-jump", (e) => { formLog.textContent = `mv-track-jump · ${e.detail.label} (${e.detail.index} of ${e.detail.total}) · focused`; });
/* ── Board ── */
const board = $("et-board"), boardT = $("et-board-tracker"), boardLog = $("et-board-log");
board.scrollLeft = (board.scrollWidth - board.clientWidth) / 2;
board.scrollTop = (board.scrollHeight - board.clientHeight) / 2;
const syncSel = () => { $("et-board-badge").textContent = `${board.querySelectorAll(".et-note[aria-pressed='true']").length} selected`; };
board.addEventListener("click", (e) => {
const note = e.target.closest(".et-note");
if (!note) return;
note.setAttribute("aria-pressed", note.getAttribute("aria-pressed") === "true" ? "false" : "true");
syncSel();
});
boardT.addEventListener("mv-track-jump", (e) => { boardLog.textContent = `mv-track-jump · ${e.detail.kind} · ${e.detail.label || "note"} · from ${e.detail.source}`; });
const cursors = [...board.querySelectorAll(".et-cursor")];
let live = 0;
const wander = () => {
if (!board.isConnected) return clearInterval(live);
for (const c of cursors) {
const x = 80 + Math.random() * 2200, y = 80 + Math.random() * 1400;
c.style.translate = `${Math.round(x)}px ${Math.round(y)}px`;
}
setTimeout(() => boardT.refresh(), 1850);
};
$("et-board-live").addEventListener("change", (e) => {
clearInterval(live);
if (e.target.checked) { wander(); live = setInterval(wander, 2600); }
});
$("et-board-scale").addEventListener("change", (e) => { boardT.scale = e.target.checked; });
$("et-board-compact").addEventListener("change", (e) => { boardT.detail = e.target.checked ? "count" : "full"; });
</script>
</div>Cultural reference
Aliens — James Cameron (1986, film). Surrounded in the dark, the squad cannot see what is coming, but a handheld motion tracker reports the direction and distance of every moving signal, its pulse rising in pitch as they close in. In the UI, everything that matters but is out of view is pinned to the edge it lies beyond, with its count and its distance in screens, growing stronger as it gets closer, and one press takes you to it.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
targets | selector list | What to track, searched inside the scroller (or inside the element in page mode). Comma-separated selectors; a "kind: " prefix (kind, colon, space) sets the kind of that entry and of the ones after it: "mention: .msg[data-mention], unread: .msg[data-unread], .msg.new". The first entry that matches an element wins. DOM changes and changes to the attributes used in the selectors are picked up automatically. | |
scroller | selector | "page" | (auto) | The scroll container, inside the element. Default: the first child whose overflow scrolls; when there is none, the page (viewport) is tracked and the pills are fixed to the viewport edges. |
axis | auto | y | x | both | auto | y: pills on the top and bottom edges (lists). x: left and right (timelines, carousels). both: the four edges and corners (canvases). auto picks from the directions the scroller can actually scroll. |
offset | px, CSS shorthand ("56", "56 0 0 0") | 0 | Insets of the useful view: a sticky header or footer inside the scroller. A target under the header counts as out of view, the pills sit below it and jumps center targets in the remaining space. |
scale | boolean | Adds the aggregate scale: ticks for every target along the scrollbar side (1-D) with the visible window, or a minimap with dots and the visible area in the bottom-right corner (2-D). Clicking a tick jumps to its target, clicking elsewhere scrolls there. | |
align | center | start | end | center | Where pills sit along a 1-D edge (e.g. end for a chat, like a “jump to latest” button). |
detail | full | count | full | count keeps only the arrow, the icon and the number (crowded canvases, small screens). The accessible name stays complete. |
announce | polite | off | polite | Live announcements: new targets arriving out of view (gathered for 1.2 s, at most one message every 5 s) and the target reached after a jump. |
focus-target | boolean | After a jump, focus moves to the target (itself if focusable, else its first focusable descendant, else it gets a temporary tabindex="-1"). Recommended for form errors. Without it, focus stays on the pill so Enter can be pressed again. | |
data-track-kind / data-track-label / data-track-group / data-track-color | on a target | Per-target overrides: kind (wins over the selector prefix), accessible label (default: aria-label, aria-labelledby, the input's <label>, then text), group (targets only group with the same group, e.g. one pill per collaborator) and tint (any CSS color, e.g. a collaborator's color). | |
data-offscreen | number | Set by the component on itself: how many targets are out of view (absent when none). | |
data-track-arrived | kind | Set by the component on a target for 1.6 s after a jump lands on it (brief outline, styleable). |
Properties
| Name | Type | Description |
|---|---|---|
groups | Array<{ key, kind, group, direction, count, distance, nearest, targets }> | Current off-screen groups (read-only). direction: top | bottom | left | right | top-left | top-right | bottom-left | bottom-right; distance in screens (viewport sizes) to the nearest target; targets sorted nearest first. |
tracked | Element[] | Every element currently tracked (read-only). |
kinds | Record<string, { noun: [one, other], short?: [one, other], icon?: string | string[] | SVGElement, fill?: boolean, named?: boolean, color?: string }> | Kind definitions, merged over the built-ins: item, unread, mention, error, match, selected, comment, cursor. icon is one or more SVG path d strings on a 24 grid (stroked, or filled with fill) or an SVG element to clone; named shows the target's label instead of “1 …” when a group has one target (cursor does). |
strings | Partial<Record<string, string>> | Overrides for every text (keys: top, bottom, left, right, top-left, top-right, bottom-left, bottom-right, near, screen, screens, pill, pillNamed, arrived, arrivedNamed, arrivals, region; placeholders {dir} {n} {count} {noun} {Noun} {distance} {label} {index} {total}). English defaults. |
targets / scroller / axis / offset / scale / align / detail / announce / focusTarget | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
track(el, { kind?, label?, group?, color? }) | Tracks an element from code (it must be inside the scroller, or inside the element in page mode). Returns a function that stops tracking it. |
untrack(el) | Stops tracking an element added with track(). |
jump(which) | Same as activating a pill. which: a group key, a direction ("bottom"), a kind ("error") or a tracked element. Returns false if nothing matched or mv-track-jump was canceled. |
next(kind?) / previous(kind?) | Jumps to the nearest out-of-view target after the view (below or to the right) or before it (above or to the left), optionally of one kind. Handy for keyboard shortcuts (e.g. Alt+↓ for the next unread). |
refresh() | Re-measures every target. Call it after moving targets without a DOM change (live cursors positioned by transform). |
Events
| Name | Description |
|---|---|
mv-track-jump | Cancelable, before scrolling. detail: { target, kind, group, direction, index, total, label, source: "pill" | "scale" | "api" }. preventDefault() skips the scroll, e.g. to load an older page of messages first. |
mv-track-change | The groups or their counts changed. detail: { groups (same shape as the groups property), offscreen (targets out of view), total (targets tracked) }. |
mv-track-seen | A tracked target came into view (at least half of it, or half of the view). detail: { target, kind, label }. Use it for read receipts. |
Content structure
| Name | Description |
|---|---|
(content) | The scroll container (first child that scrolls, or the scroller selector) with the targets inside it, or any page region in page mode. The pill layer is prepended to the element; the app's DOM is only touched through data-track-arrived (and a temporary tabindex with focus-target). |
CSS classes
| Name | Description |
|---|---|
mv-edge-tracker-layer | Overlay matching the scroller's client box (fixed to the viewport in page mode), role="group" named by strings.region. data-axis, data-scale, data-page. |
mv-edge-tracker-edge | Container for one edge or corner (data-edge="top" … "bottom-right"). |
mv-edge-tracker-pill | The <button> for one group: -arrow, -icon, -text (-count, -label, -distance). data-kind, data-zone, data-ping while pinging, --_near 0..1 (closeness of the nearest target). |
mv-edge-tracker-scale / -window / -tick | Scale (aria-hidden, pointer only): the visible window and one tick per target (data-kind for its shape, data-in when on screen). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-edge-tracker-unread / -mention / -error / -match / -selected / -comment / -cursor / -item | accent, info, danger, warning, accent, success, accent, fg-muted | Tint of each built-in kind (icon, border, ticks, arrival outline). |
--mv-edge-tracker-color | var(--mv-accent) | Tint of custom kinds. |
--mv-edge-tracker-gap | 8px | Distance between the pills and the edge, and between pills. |
--mv-edge-tracker-arrive | var(--mv-accent) | Arrival outline color for kinds without their own. |
--mv-edge-tracker-z | 20 (page mode: var(--mv-z-sticky)) | z-index of the pill layer. |
Accessibility
Every pill is a real <button> in a role="group" named “Off-screen items”, placed before the scroll content in the DOM so the summary of what is out of view is reached first. Its accessible name is complete even when the visible text is compact: “3 unread messages, nearest 2 screens below. Jump to it”, or “Priya Raman, 1 screen to the right. Jump there” for named targets. Enter and Space jump to the nearest target and a repeat goes to the next one; focus stays on the pill (so it can be pressed again), or moves to the target with focus-target (form errors), and when the focused pill disappears because its last target came into view, focus moves to another pill of the same kind or to the target just reached, never to the page top. Arrivals out of view are announced in a polite status region, grouped (“2 new unread messages below.”) and throttled to one message every 5 seconds; after a jump the target is announced (“Unread message 1 of 5: Kenji Watanabe: changelog”). announce="off" silences both. Kinds differ by icon and tick shape (dot, triangle, ring, square, bar, diamond, bubble), not only by color, and the arrow gives the direction. The scale is aria-hidden and pointer-only; every target it shows is also reachable through the pills. Reduced motion (OS or data-motion="reduce"): jumps are instant, pills appear without scaling, no ping, and the arrival outline fades in place. Forced colors: pills use ButtonFace/ButtonText with a Highlight focus outline, ticks use CanvasText. Performance: an IntersectionObserver rooted on the scroller decides visibility; target positions are read in one batch per layout change (ResizeObserver, DOM mutations, fonts) and cached in content coordinates, so a scroll frame only reads scrollTop/scrollLeft; updates are coalesced into one frame and everything stops when the element is removed.