@gavinlees/dom-minimap
v0.4.1
Published
A true-render minimap for HTML documents: scales a live DOM clone instead of drawing a canvas approximation, so SVG, math, and highlighted code stay pixel-faithful in miniature.
Maintainers
Readme
dom-minimap
A true-render minimap for HTML documents. Live demo →
Existing HTML minimap libraries (pagemap, minimap.js) draw a canvas approximation of the page: each element becomes a grey rectangle. That works for plain prose, but SVG diagrams, math markup, images, and syntax-highlighted code all degrade to indistinguishable boxes.
dom-minimap takes the other approach: it clones the live DOM and scales the clone down with a CSS transform. The browser's own renderer draws the miniature, so anything the engine can render — Mermaid diagrams, KaTeX equations, Prism-highlighted code, inline images — appears pixel-faithful at minimap scale. No dependencies, one file, works in any modern browser.
Install
npm install @gavinlees/dom-minimapOr just copy src/dom-minimap.js — it's a single
dependency-free UMD file.
<script src="dom-minimap.js"></script>Usage
var mm = DomMinimap.create({ content: document.body, width: 88 });
mm.refresh(); // call after the content changes
mm.setEnabled(false); // hide (and stop updating); true to show again
mm.destroy(); // remove from the DOM and detach all listeners
mm.element; // the minimap strip's root elementThe minimap renders as a fixed strip on the right edge of the viewport. Click or drag to scroll; the mouse wheel scrolls the document too.
Options
All options are optional.
| Option | Default | Description |
| ------------------ | --------------------------- | ----------- |
| content | document.body | Element to miniaturize. |
| host | document.documentElement | Element the strip is appended to. The default survives wholesale replacement of body.innerHTML. |
| scrollElement | document.scrollingElement | Element that scrolls the content. Set this when content lives inside a fixed-height overflow: auto container instead of scrolling the page itself. |
| width | 88 | Strip width in px. |
| zIndex | 2147483000 | Strip z-index. |
| background | 'transparent' | Strip background. |
| borderColor | rgba(128, 128, 128, 0.25) | Left border of the strip. |
| sliderColor | rgba(128, 128, 128, 0.20) | Viewport slider fill. |
| sliderHoverColor | rgba(128, 128, 128, 0.32) | Slider fill while dragging. |
Responsive width
The strip's width option only sets its initial size — the effective width
used for scaling is read live from the rendered element, so a host stylesheet
can resize the strip with a media query (e.g. narrower on small screens) and
the miniature will scale to match on the next refresh:
.dom-minimap { width: 40px !important; }
@media (min-width: 600px) {
.dom-minimap { width: 88px !important; }
}While the strip is enabled, create() and setEnabled(true) add a
dom-minimap-active class to host (removed on setEnabled(false) and
destroy()). Use it to reserve layout space for the strip so it doesn't
overlap document content, e.g.:
html.dom-minimap-active { padding-right: 88px; }Keeping it fresh
- Window
resizeand content resizes (viaResizeObserver, so late image loads and font-size changes are caught) trigger an automatic refresh. - Scrolling only moves the slider and slides the miniature — no re-clone.
- After mutating content in a way that doesn't change its size, call
mm.refresh()yourself.
Design notes
Two-layer transform. The miniature is positioned by two nested layers: an
inner layer carries a static scale() so its raster can be cached, and an
outer layer carries the per-scroll translate3d() so scroll updates stay
compositor-only. Combining both into one transform makes WebKit re-rasterize
the scaled text on every scroll frame, which shows up as visible shimmer.
Offsets are snapped to device pixels for the same reason.
Ids are kept, but prefixed. The clone's elements keep their id
attributes — stripping them would break SVG content (e.g. Mermaid output)
that depends on id-scoped <style> rules and url(#…) references, such as
markers, gradients, and clip paths — but each id is rewritten with a prefix
unique to the minimap instance, and every reference to it within the
clone (url(#…), href/xlink:href, for, aria-labelledby and similar
idref attributes, and id selectors inside <style> text) is rewritten to
match. So the clone's SVG output still renders correctly, while
getElementById, #anchor navigation, and querySelectorAll-based
consumer code all see exactly one match for any id in content — the
original — regardless of where host is positioned relative to content.
<script> elements and nested minimaps are removed from the clone
entirely.
Limitations
- Each refresh clones the content, roughly doubling the DOM node count. Fine
for documents; for enormous pages consider
content-visibility: autoon the clone or capping its length. - Nested-container scrolling is supported via the
scrollElementoption, but wheel/drag-to-scroll writes to that element'sscrollTopare not throttled the way page-level writes historically needed to be (see the note below) — a container has its own scroll/compositor node, so it doesn't share the touch-delivery pipeline the waywindow.scrollTo()does on iOS WKWebView. - On iOS WKWebView, scrolling
document.scrollingElement(the default, page-level mode) shares its nativeUIScrollViewwith the touch-delivery pipeline: writing to it while a touch is still active can corrupt theclientYWebKit reports for later samples of that same touch, causing jumpy/blanking drags on long pages. UsescrollElementto point at a CSSoverflow: autocontainer instead to avoid this entirely.
Tests
Browser-driven via Playwright (uses your installed Chrome):
npm install
npm test