Exclusivebeta

Reading Map — <mv-reading-map>

A live minimap of an article, built from its DOM rather than a screenshot: every line of text (with its real length), heading, bullet, image, table and code line becomes an abstract shape. A draggable lens shows the visible area, and a reading heatmap “inks in” over time what was actually read (time spent per block versus its expected reading time; fast scrolling = skimming), remembered per URL. Hover shows the section title and state; from the keyboard, sections are real links.

CategoryScroll
TypeWeb Component (<mv-reading-map>)
Statusbeta
Keywordsexclusive, minimap, reading, progress, heatmap, toc, table-of-contents, article, blog, scroll, navigation, dwell-time

When to use

Avoid when

Install

node scripts/add.mjs reading-map --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["reading-map"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/canvas.js, core/dom.js, core/element.js, core/motion.js, core/observe.js, core/position.js, components/reading-map/reading-map.js, components/reading-map/reading-map.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div id="rm-demo" style="width:min(100%,56rem);margin-inline:auto">
  <style>
    #rm-demo .rm-shell { display:flex; gap:1rem; align-items:stretch; height:34rem }
    #rm-demo .rm-scroll { flex:1; min-width:0; overflow:auto; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); scroll-behavior:auto; overscroll-behavior:contain }
    #rm-demo article { max-width:38rem; margin:0 auto; padding:2rem 1.75rem 3rem; font-size:.9375rem; line-height:1.7; color:var(--mv-fg) }
    #rm-demo .rm-kicker { margin:0 0 .5rem; color:var(--mv-accent-fg); font-size:.75rem; font-weight:600; letter-spacing:.06em; text-transform:uppercase }
    #rm-demo h1 { margin:0 0 .75rem; font-size:1.75rem; line-height:1.2; letter-spacing:-.02em }
    #rm-demo .rm-lead { margin:0 0 1.5rem; color:var(--mv-fg-muted); font-size:1.0625rem }
    #rm-demo .rm-byline { display:flex; gap:.5rem; align-items:center; margin:0 0 2rem; color:var(--mv-fg-muted); font-size:.8125rem }
    #rm-demo h2 { margin:2.25rem 0 .75rem; font-size:1.25rem; line-height:1.3; letter-spacing:-.01em; scroll-margin-top:1rem }
    #rm-demo h3 { margin:1.5rem 0 .5rem; font-size:1rem }
    #rm-demo p, #rm-demo ul, #rm-demo ol { margin:0 0 1rem }
    #rm-demo li { margin:.25rem 0 }
    #rm-demo pre { margin:0 0 1.25rem; padding:1rem 1.125rem; overflow:auto; border-radius:var(--mv-radius-lg); background:light-dark(var(--mv-gray-950), var(--mv-gray-900)); color:var(--mv-gray-100); font:.8125rem/1.6 var(--mv-font-mono) }
    #rm-demo code { font-family:var(--mv-font-mono); font-size:.85em }
    #rm-demo blockquote { margin:1.25rem 0; padding:.25rem 0 .25rem 1rem; border-left:3px solid var(--mv-accent); color:var(--mv-fg-muted); font-style:italic }
    #rm-demo figure { margin:1.5rem 0 }
    #rm-demo .rm-art { aspect-ratio:16/7; border-radius:var(--mv-radius-lg); background:
        linear-gradient(180deg, transparent 60%, color-mix(in oklab, var(--mv-accent) 30%, transparent)),
        radial-gradient(120% 90% at 20% 10%, color-mix(in oklab, var(--mv-info) 45%, transparent), transparent 60%),
        linear-gradient(135deg, color-mix(in oklab, var(--mv-accent) 55%, var(--mv-bg)), color-mix(in oklab, var(--mv-warning) 40%, var(--mv-bg))) }
    #rm-demo .rm-chart { aspect-ratio:16/6; display:flex; align-items:flex-end; gap:6%; padding:1rem 8% 0; border-radius:var(--mv-radius-lg); background:var(--mv-bg-muted) }
    #rm-demo .rm-chart span { flex:1; border-radius:6px 6px 0 0; background:var(--mv-accent); opacity:.85 }
    #rm-demo figcaption { margin-top:.5rem; color:var(--mv-fg-muted); font-size:.8125rem }
    #rm-demo table { width:100%; margin:0 0 1.25rem; border-collapse:collapse; font-size:.8125rem }
    #rm-demo th, #rm-demo td { padding:.45rem .5rem; border-bottom:1px solid var(--mv-border); text-align:left }
    #rm-demo td:last-child, #rm-demo th:last-child { text-align:right; font-variant-numeric:tabular-nums }
    #rm-demo mv-reading-map { --mv-reading-map-height: 100% }
    @media (max-width:40rem) { #rm-demo mv-reading-map { --mv-reading-map-width: 4.25rem } #rm-demo article { padding:1.5rem 1.25rem 2.5rem } }
  </style>

  <div class="rm-shell">
    <div class="rm-scroll" id="rm-scroll" tabindex="0" aria-label="Article">
      <article id="rm-article">
        <p class="rm-kicker">Engineering blog · Performance</p>
        <h1>How we made our store load three times faster</h1>
        <p class="rm-lead">Six weeks, no redesign, and a mobile Largest Contentful Paint down from 4.1 s to 1.3 s. Here’s what actually mattered — and what didn’t help at all.</p>
        <p class="rm-byline"><span>By Sofia Martinez, Front-end team</span><span aria-hidden="true">·</span><span>12 min read</span></p>

        <h2 id="rm-constat">Where we started</h2>
        <p>In March, our mobile conversion rate dropped for the third month in a row. User tests all told the same story: the product page “takes a while to make up its mind.” The hero image showed up last, buttons shifted right as you tapped them, and the cart responded half a second late.</p>
        <p>Our dashboards, meanwhile, were all green. They were measuring a MacBook on the office fiber line. Our customers were shopping on the subway, on mid-range phones.</p>
        <figure>
          <div class="rm-chart" role="img" aria-label="Load time per week, down from 4.1 to 1.3 seconds"><span style="height:92%"></span><span style="height:80%"></span><span style="height:61%"></span><span style="height:44%"></span><span style="height:33%"></span><span style="height:29%"></span></div>
          <figcaption>Median mobile LCP, week over week (field data, 75th percentile).</figcaption>
        </figure>

        <h2 id="rm-mesurer">Measure before you optimize</h2>
        <p>First decision: no more optimizing without field data. We hooked the <code>web-vitals</code> library up to 10% of traffic and sent the results to our data warehouse, broken down by phone model, page type and network quality.</p>
        <pre><code>import { onLCP, onINP, onCLS } from "web-vitals";

const send = (metric) =&gt; {
  navigator.sendBeacon("/rum", JSON.stringify({
    name: metric.name,
    value: Math.round(metric.value),
    page: document.body.dataset.template,
  }));
};

onLCP(send);
onINP(send);
onCLS(send);</code></pre>
        <p>Within a week, the picture was crystal clear. Three items explained most of the delay:</p>
        <ul>
          <li>product images served at 2,400 pixels to 390-pixel screens;</li>
          <li>180 KB of JavaScript executed before first paint;</li>
          <li>a CDN cache that expired every five minutes, for no good reason.</li>
        </ul>

        <h2 id="rm-images">Images, the biggest win</h2>
        <p>Our photographers deliver gorgeous files, and we were serving them as is. Switching to responsive images, in AVIF with a WebP fallback, was the simplest and most profitable project of the quarter.</p>
        <figure>
          <div class="rm-art" role="img" aria-label="Product shot: a ceramic lamp on a pastel background"></div>
          <figcaption>The hero image now weighs 38 KB instead of 610 KB, with no visible loss.</figcaption>
        </figure>
        <p>We also reserved space for every image with <code>width</code> and <code>height</code> attributes. No more buttons jumping under your thumb: CLS went from 0.21 to 0.02.</p>
        <p>One last, often forgotten detail: the hero image is the only one loaded with high priority. All the others wait patiently until they get close to the viewport.</p>

        <h2 id="rm-js">Less JavaScript</h2>
        <p>Most of our JavaScript powered features almost nobody used on load: product zoom, the engraving configurator, the chat. We now load them on first interaction.</p>
        <pre><code>button.addEventListener("pointerenter", async () =&gt; {
  const { openZoom } = await import("./zoom.js");
  button.onclick = openZoom;
}, { once: true });</code></pre>
        <blockquote>“The fastest code is the code you don’t ship. The second fastest is the code you ship later.”</blockquote>
        <p>The result: 112 KB less on the critical path, and INP under 200 ms for 80% of sessions.</p>

        <h2 id="rm-cache">Caching, finally done right</h2>
        <p>Our product pages rarely change more than once a day. Yet the CDN treated them as stale after five minutes. We adopted <code>stale-while-revalidate</code> and a targeted purge on every catalog update.</p>
        <table>
          <thead><tr><th>Page</th><th>Cache hit rate before</th><th>After</th></tr></thead>
          <tbody>
            <tr><td>Product page</td><td>41%</td><td>96%</td></tr>
            <tr><td>Category</td><td>55%</td><td>94%</td></tr>
            <tr><td>Home</td><td>72%</td><td>99%</td></tr>
          </tbody>
        </table>

        <h2 id="rm-resultats">Results</h2>
        <p>Six weeks later, median mobile LCP was at 1.3 s, INP at 160 ms and CLS close to zero. Above all, mobile conversion rose 11%, and the bounce rate on product pages fell by a quarter.</p>
        <p>None of these projects was spectacular. None required rewriting the app. They were simply tackled in the order the data dictated.</p>

        <h2 id="rm-suite">What’s next?</h2>
        <p>Performance isn’t a project, it’s a habit. Every pull request now shows its impact on page weight, and a budget blocks anything over 10 KB added to the critical path. Next up: fonts, which still account for 90 KB at first paint.</p>
        <p>Questions, or a story of your own? Write to us — we’ll publish the best replies in an upcoming post.</p>
      </article>
    </div>
    <mv-reading-map id="rm-map" target="#rm-article" scroller="#rm-scroll" label="Article reading map"></mv-reading-map>
  </div>

  <script type="module">
    const map = document.getElementById("rm-map");
    const box = document.getElementById("rm-scroll");
    await customElements.whenDefined("mv-reading-map");
    // First visit: pretend a previous reading session (the map remembers real ones per URL).
    requestAnimationFrame(() => {
      if (Object.keys(map.dwell).length) return;
      const dwell = {};
      const give = (key, seconds, n = 24) => { for (let i = 0; i < n; i++) dwell[`${key}:${i}`] = seconds; };
      give("intro", 999);
      give("rm-constat", 999);
      give("rm-mesurer", 999, 2);
      for (let i = 3; i < 8; i++) dwell[`rm-mesurer:${i}`] = 1.4;
      give("rm-cache", 2.5, 3);
      map.dwell = dwell;
      box.scrollTop += document.getElementById("rm-mesurer").getBoundingClientRect().top - box.getBoundingClientRect().top - 24;
    });
  </script>
</div>

API

Attributes

NameTypeDefaultDescription
targetselector | idContent to map (the article). Without the attribute: the previous sibling element.
scrollerselector | windowScroll container. Defaults to the content’s closest scrolling ancestor, otherwise the page.
sectionsselectorh2, h3Headings that split the sections (text before the first heading forms the introduction).
wpmnumber230Reading speed (words per minute) for the expected time per block; code counts double.
persistlocal | offlocalStores the reading heat in localStorage.
storage-keystringStorage key (default: page path + content id).
dockright | leftPins the map to the edge of the screen (full-page reading).
labelstringReading mapAccessible name of the navigation.
data-reading-mapheading | text | quote | code | media | table | ruleOn a content element: forces its shape on the map.
data-reading-map-ignorebooleanOn a content element: left out of the map.

Properties

NameTypeDescription
dwellRecord<string, number>Seconds of reading per block (“section:index”). Assignable, e.g. to sync reading progress across devices.
progress{ read, remaining, sections }Share read (0 to 1), estimated seconds remaining, and the state of each section (read | skimmed | unread).

Methods

NameDescription
reset()Clears the reading heat.
refresh()Rebuilds the map (automatic on resize, content changes, image and font loads).

Events

NameDescription
mv-progressA section changes state (unread → skimmed → read). detail: progress.

CSS classes

NameDescription
mv-reading-map-stage / -canvasDrawing area (canvas) for the shapes.
mv-reading-map-sectionSection link, laid over its height; data-state="read | skimmed | unread", aria-current="location" on the current section.
mv-reading-map-lensLens over the visible area (drag to scroll).
mv-reading-map-meta / -pct / -leftSummary: percent read and time remaining.
mv-reading-map-tipHover tooltip (popover).

CSS variables

NameDefaultDescription
--mv-reading-map-width6.5remMap width.
--mv-reading-map-height100% (dock: min(72vh, 36rem))Map height.
--mv-reading-map-read-colorvar(--mv-accent)Ink for what has been read, and the lens.
--mv-reading-map-ink / -heading / -codeColors of text lines, headings and the code block background.
--mv-reading-map-readShare read 0 → 1 (read-only, usable in your styles).

Accessibility

The map is a named <nav> holding an ordered list of real section links: Tab enters it, Up/Down arrows and Home/End move between sections, Enter scrolls and moves focus to the heading. Each link announces its state (“Measure before you optimize — skimmed (40%)”) and the visible section carries aria-current="location". The drawing, lens and tooltip are aria-hidden. Reduced motion: instant scrolling. The reading clock (1 Hz) only runs while the content is on screen, the tab is visible and the user was active within the last 45 s; no animation loop. Storage is wrapped in try/catch (private browsing, quotas).