익스클루시브

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”). 목록에서는 위아래, 2D 캔버스에서는 네 가장자리와 모서리 모두에 표시되며, 캔버스에서는 알약 버튼이 가장자리를 따라 대상 쪽으로 미끄러지고 화살표가 대상을 가리킵니다. 알약 버튼은 가장 가까운 대상이 다가올수록 강해지고, 새 대상이 화면 밖에 나타나면 한 번 핑을 보냅니다. 버튼을 활성화하면 가장 가까운 대상으로 부드럽게 스크롤하고(가운데 정렬, offset으로 선언한 고정 헤더를 피함), 다시 누르면 다음 대상으로 이동합니다. 종류마다 고유한 아이콘과 눈금 모양이 있어 색상에만 의존하지 않습니다. 선택 사항인 scale은 모든 대상을 스크롤바 쪽에 눈금으로 그리거나, 2D에서는 보이는 영역이 표시된 미니맵에 점으로 그리며, 둘 다 클릭할 수 있습니다. 가시성은 스크롤러를 루트로 하는 IntersectionObserver로 판단하고, 위치는 레이아웃이 바뀔 때 한 번만 측정하므로 스크롤 중에는 스크롤 오프셋만 읽습니다. mv-track-jump는 취소할 수 있고, mv-track-change는 그룹을 보고하며, mv-track-seen은 대상이 화면에 들어올 때 발생하고(읽음 확인), 새로 나타난 대상은 polite 방식으로 빈도를 제한해 안내됩니다.

카테고리내비게이션
유형Web Component (<mv-edge-tracker>)
상태안정
키트파워 유저를 위한 내비게이션
Keywordsexclusive, 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 &amp; 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유형DefaultDescription
targetsselector listWhat 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.
scrollerselector | "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.
axisauto | y | x | bothautoy: 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.
offsetpx, CSS shorthand ("56", "56 0 0 0")0Insets 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.
scalebooleanAdds 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.
aligncenter | start | endcenterWhere pills sit along a 1-D edge (e.g. end for a chat, like a “jump to latest” button).
detailfull | countfullcount keeps only the arrow, the icon and the number (crowded canvases, small screens). The accessible name stays complete.
announcepolite | offpoliteLive 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-targetbooleanAfter 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-coloron a targetPer-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-offscreennumberSet by the component on itself: how many targets are out of view (absent when none).
data-track-arrivedkindSet by the component on a target for 1.6 s after a jump lands on it (brief outline, styleable).

Properties

Name유형Description
groupsArray<{ 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.
trackedElement[]Every element currently tracked (read-only).
kindsRecord<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).
stringsPartial<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 / focusTargetreflectedMirror the attributes.

Methods

NameDescription
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

NameDescription
mv-track-jumpCancelable, 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-changeThe groups or their counts changed. detail: { groups (same shape as the groups property), offscreen (targets out of view), total (targets tracked) }.
mv-track-seenA 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

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

NameDescription
mv-edge-tracker-layerOverlay 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-edgeContainer for one edge or corner (data-edge="top" … "bottom-right").
mv-edge-tracker-pillThe <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 / -tickScale (aria-hidden, pointer only): the visible window and one tick per target (data-kind for its shape, data-in when on screen).

CSS variables

NameDefaultDescription
--mv-edge-tracker-unread / -mention / -error / -match / -selected / -comment / -cursor / -itemaccent, info, danger, warning, accent, success, accent, fg-mutedTint of each built-in kind (icon, border, ticks, arrival outline).
--mv-edge-tracker-colorvar(--mv-accent)Tint of custom kinds.
--mv-edge-tracker-gap8pxDistance between the pills and the edge, and between pills.
--mv-edge-tracker-arrivevar(--mv-accent)Arrival outline color for kinds without their own.
--mv-edge-tracker-z20 (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.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고