Exclusifbêta
Reading Map <mv-reading-map>
Une minimap en direct d’un article, construite à partir de son DOM et non d’une capture d’écran : chaque ligne de texte (avec sa longueur réelle), titre, puce, image, tableau et ligne de code devient une forme abstraite. Une loupe déplaçable montre la zone visible, et une carte de chaleur de lecture « encre » au fil du temps ce qui a réellement été lu (temps passé par bloc comparé à son temps de lecture attendu ; défilement rapide = lecture en diagonale), mémorisée par URL. Le survol affiche le titre de la section et son état ; au clavier, les sections sont de vrais liens.
| Catégorie | Défilement |
|---|---|
| Type | Web Component (<mv-reading-map>) |
| Statut | bêta |
| 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
Installation
node scripts/add.mjs reading-map --out ./src/marvelousAgent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["reading-map"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : 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.
Utilisation
Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :
<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).