Interaction
Document minimap rail
A table of contents that tracks reading position, not last-heading-crossed: an IntersectionObserver picks the topmost visible section, highlights its node, and keeps a live "3 of 6" position readout.
Reach for it when building
- long-form article tables of contents
- transcript and log navigation
- multi-section settings pages
- legal and policy documents
- threaded discussion overviews
- toc
- intersection-observer
- sticky
- position
- scrolling
<div class="dmr-demo">
<nav class="dmr-rail" aria-label="Document sections">
<p class="dmr-pos">1 of 6</p>
<div class="dmr-links"></div>
</nav>
<div class="dmr-scroll">
<section id="dmr-s0"><h4>Introduction</h4><p>Scroll this pane and watch the rail track your reading position, not the last heading you crossed.</p></section>
<section id="dmr-s1"><h4>Background</h4><p>Each rail node carries a marker dot, the section name, and a dimmed metadata line beneath it.</p></section>
<section id="dmr-s2"><h4>Approach</h4><p>The observer picks the topmost intersecting section inside the reading band.</p></section>
<section id="dmr-s3"><h4>Results</h4><p>The current node gets a background pill and a heavier weight, plus aria-current.</p></section>
<section id="dmr-s4"><h4>Discussion</h4><p>The position readout at the top of the rail recomputes from the current node.</p></section>
<section id="dmr-s5"><h4>Conclusion</h4><p>Below a minimum section count the rail renders nothing at all.</p></section>
</div>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.dmr-demo {
display: grid;
grid-template-columns: 170px minmax(0, 1fr);
gap: 1.25rem;
align-items: start;
max-width: 640px;
}
.dmr-rail { font-size: 0.9375rem; }
.dmr-pos {
margin: 0 0 0.5rem;
font-family: var(--mono);
font-weight: 700;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.dmr-links a {
display: block;
text-decoration: none;
color: var(--dim);
padding: 2px 6px;
border-radius: 6px;
margin-bottom: 2px;
line-height: 1.4;
}
.dmr-links a .dmr-name {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* The metadata line indents past the dot so text edges align. */
.dmr-links a .dmr-meta {
padding-left: 19px;
font-size: 0.875rem;
}
.dmr-dot {
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--accent);
flex: 0 0 auto;
}
.dmr-links a[aria-current='true'] {
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--ink);
font-weight: 700;
}
.dmr-scroll {
height: 260px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
padding: 0 1rem;
}
.dmr-scroll section {
min-height: 130px;
border-bottom: 1px solid var(--border);
padding: 0.75rem 0;
}
.dmr-scroll section:last-child { border-bottom: none; }
.dmr-scroll h4 { margin: 0 0 0.25rem; }
.dmr-scroll p { margin: 0; color: var(--dim); font-size: 0.9375rem; }var MINIMUM_SECTIONS = 4;
var scroller = document.querySelector('.dmr-scroll');
var rail = document.querySelector('.dmr-rail');
var linksHost = document.querySelector('.dmr-links');
var position = document.querySelector('.dmr-pos');
var sections = Array.prototype.slice.call(scroller.querySelectorAll('section'));
// Self-suppression: below the minimum there is nothing to navigate.
if (sections.length < MINIMUM_SECTIONS) {
rail.remove();
} else {
sections.forEach(function (section, index) {
var link = document.createElement('a');
link.href = '#' + section.id;
var title = section.querySelector('h4').textContent;
link.innerHTML =
'<span class="dmr-name"><i class="dmr-dot"></i>' + title + '</span>' +
'<span class="dmr-meta">section ' + (index + 1) + '</span>';
link.addEventListener('click', function (event) {
event.preventDefault();
section.scrollIntoView({ block: 'start', behavior: 'smooth' });
});
linksHost.appendChild(link);
});
var links = Array.prototype.slice.call(linksHost.querySelectorAll('a'));
function setCurrent(index) {
links.forEach(function (link, linkIndex) {
if (linkIndex === index) link.setAttribute('aria-current', 'true');
else link.removeAttribute('aria-current');
});
position.textContent = index + 1 + ' of ' + sections.length;
}
// Track the topmost section inside the reading band — the top 40% of the
// pane — so the highlight follows reading position, not last-crossed.
var observer = new IntersectionObserver(
function () {
var hostRect = scroller.getBoundingClientRect();
var band = hostRect.top + hostRect.height * 0.4;
var topmost = -1;
sections.forEach(function (section, index) {
var rect = section.getBoundingClientRect();
if (rect.bottom > hostRect.top + 10 && rect.top < band) {
if (topmost === -1 || rect.top < sections[topmost].getBoundingClientRect().top) {
topmost = index;
}
}
});
if (topmost !== -1) setCurrent(topmost);
},
{ root: scroller, threshold: [0, 0.25, 0.5, 0.75, 1] }
);
sections.forEach(function (section) { observer.observe(section); });
setCurrent(0);
}Paste this into an agent to rebuild the pattern from scratch.
Build a table of contents rail that tracks where the reader actually is, with one node per section, a live position readout, and a self-suppression rule.
Layout: a two-column grid — a fixed-width rail beside the scrollable content (in a real page, make the rail `position: sticky` with a small top offset and `max-height: calc(100vh - 2rem); overflow-y: auto`). Each rail node is a real anchor to its section containing two lines: a name row with an 11px marker dot and the section title, and a dimmed metadata line indented by the dot's width plus gap (19px) so its text aligns under the title, carrying whatever per-section fact is useful — a date, a count, an author.
Tracking: use an IntersectionObserver over all sections. On each callback, ignore the entries argument and instead scan every section's `getBoundingClientRect()` against the scroll container's rect, choosing the topmost section whose bottom is below the container top and whose top is above a reading band at roughly 40% of the container height. Picking topmost-in-band makes the highlight follow reading position; the naive "last heading crossed" approach jumps a full section early. The current node gets a tinted background pill, heavier weight, and `aria-current="true"`.
The position readout — "3 of 6" in the mono face at the top of the rail — recomputes from the current node. It earns its space in long documents where the scrollbar has stopped being a useful ruler.
Self-suppression: below a minimum section count (around four), render no rail at all. A three-item map next to three visible headings is furniture.
Every color comes from theme custom properties, and the current-node pill has to stay visible in both light and dark themes.