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.
| Category | Scroll |
|---|---|
| Type | Web Component (<mv-reading-map>) |
| Status | beta |
| Keywords | exclusive, minimap, reading, progress, heatmap, toc, table-of-contents, article, blog, scroll, navigation, dwell-time |
When to use
- Long-form articles or docs need a minimap that shows the whole structure and lets readers jump to sections
- Readers should see which sections they actually read, skimmed or skipped, remembered across visits
- A docked side map should show time remaining and the current section during full-page reading
Avoid when
- A simple scroll percentage bar is enough → use Scroll Progress instead
- The content is short or has no headings; a minimap adds clutter without guidance
- The page is a narrow mobile layout without room for a side map beside the article
Install
node scripts/add.mjs reading-map --out ./src/marvelousAI 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) => {
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 () => {
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
| Name | Type | Default | Description |
|---|---|---|---|
target | selector | id | Content to map (the article). Without the attribute: the previous sibling element. | |
scroller | selector | window | Scroll container. Defaults to the content’s closest scrolling ancestor, otherwise the page. | |
sections | selector | h2, h3 | Headings that split the sections (text before the first heading forms the introduction). |
wpm | number | 230 | Reading speed (words per minute) for the expected time per block; code counts double. |
persist | local | off | local | Stores the reading heat in localStorage. |
storage-key | string | Storage key (default: page path + content id). | |
dock | right | left | Pins the map to the edge of the screen (full-page reading). | |
label | string | Reading map | Accessible name of the navigation. |
data-reading-map | heading | text | quote | code | media | table | rule | On a content element: forces its shape on the map. | |
data-reading-map-ignore | boolean | On a content element: left out of the map. |
Properties
| Name | Type | Description |
|---|---|---|
dwell | Record<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
| Name | Description |
|---|---|
reset() | Clears the reading heat. |
refresh() | Rebuilds the map (automatic on resize, content changes, image and font loads). |
Events
| Name | Description |
|---|---|
mv-progress | A section changes state (unread → skimmed → read). detail: progress. |
CSS classes
| Name | Description |
|---|---|
mv-reading-map-stage / -canvas | Drawing area (canvas) for the shapes. |
mv-reading-map-section | Section link, laid over its height; data-state="read | skimmed | unread", aria-current="location" on the current section. |
mv-reading-map-lens | Lens over the visible area (drag to scroll). |
mv-reading-map-meta / -pct / -left | Summary: percent read and time remaining. |
mv-reading-map-tip | Hover tooltip (popover). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-reading-map-width | 6.5rem | Map width. |
--mv-reading-map-height | 100% (dock: min(72vh, 36rem)) | Map height. |
--mv-reading-map-read-color | var(--mv-accent) | Ink for what has been read, and the lens. |
--mv-reading-map-ink / -heading / -code | Colors of text lines, headings and the code block background. | |
--mv-reading-map-read | Share 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).