独家
Edge Tracker <mv-edge-tracker>
为用户在滚动容器、长页面或可平移画布中看不到的任何内容提供屏幕外指示:下方的未读消息、上方的表单错误、提及、搜索匹配、选中项、协作者的光标。
文化出处
异形2,詹姆斯·卡梅隆(1986 年,电影)。小队在黑暗中被包围,看不见来袭之物,但一台手持运动探测器会报告每个移动信号的方向和距离,信号逼近时蜂鸣音调越来越高。在 UI 中,所有重要却不在视野内的东西都会钉在它所在方向的边缘,显示数量和以屏为单位的距离,越靠近越醒目,按一下就能跳过去。
工作原理
为滚动容器、长页面或可平移画布中用户看不到的一切提供屏幕外指示:下方的未读消息、上方的表单错误、提及、搜索匹配项、选中项、协作者的光标。目标通过选择器声明(targets="unread: .msg[data-unread], error: [aria-invalid=true]",kind: 前缀作用于其后的条目),或在代码中通过 track(el, { kind, label, group, color }) 声明。每个视野外的目标按类型和方向分组,每组都有一个真正的按钮,钉在目标所在方向的边缘(“3 unread · 2 screens below”):列表用上下两边,二维画布用四边和四角,此时胶囊标签还会沿边缘滑向目标,箭头指向它。最近的目标越近,胶囊标签越醒目;有新目标出现在视野外时会提示一次;激活后平滑滚动到最近的目标(居中,并避开用 offset 声明的吸顶标题),再按一次则前往下一个。每种类型都有自己的图标和刻度形状,从不只靠颜色区分。可选的 scale 会把每个目标画成滚动条一侧的刻度,或在二维模式下画成带可视区域的小地图上的圆点,两者都可点击。可见性来自以滚动容器为根的 IntersectionObserver;位置只在每次布局变化时测量一次,因此滚动时只读取滚动偏移。mv-track-jump 可取消,mv-track-change 报告分组,mv-track-seen 在目标进入视野时触发(已读回执),新目标的出现会以礼貌模式并经节流后播报。
| 分类 | 导航 |
|---|---|
| 类型 | Web Component(<mv-edge-tracker>) |
| 状态 | 稳定版 |
| 套件 | 高级用户导航 |
| 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
安装
node scripts/add.mjs edge-tracker --out ./src/marvelous使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["edge-tracker"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
复制的文件(包括依赖):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。
用法
快速开始,最小可运行标记:
<mv-edge-tracker targets="unread: [data-unread]">
<div style="height: 10rem; overflow: auto">
<p style="height: 20rem">Earlier messages…</p>
<p data-unread>Launch moved to Tuesday.</p>
</div>
</mv-edge-tracker>标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:
<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>Acme · 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>Store listing 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 (passkeys, SSO)<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>API
Attributes
| Name | 类型 | 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 | 类型 | 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.