vugluscr
v2.0.0
Published
A custom scrollbar with markers and a minimap, for a page or any inner scroller
Maintainers
Readme
Vugluscr
Vugluscr replaces the browser's scrollbar with one that tells you more: a track you can click anywhere to jump to, coloured markers showing where things of interest sit in the document, and an optional minimap of the content's block structure — the affordances IDEs and text editors have had for years.
It takes over the page, or any inner scroller, and several instances coexist happily on one page. No framework, no dependencies.
Try it → — a page rail and an embedded one on the same page, with markers you can toggle on.
Install
npm install vugluscrUse it with a bundler
import { Scrollbar } from "vugluscr";
import "vugluscr/theme.css"; // optional — the stock look
const rail = new Scrollbar();That much takes over the page. Markers are pushed in as pixel offsets down the content — the library never searches the DOM for what to mark, so what counts as interesting is entirely yours:
function paintMarkers() {
const contentTop = document.body.getBoundingClientRect().top;
rail.setMarkers(
Array.from(document.querySelectorAll("mark")).map((element) => {
const rect = element.getBoundingClientRect();
return { start: rect.top - contentTop, end: rect.bottom - contentTop, color: "crimson" };
}),
);
}
/* Offsets move whenever geometry does, so recompute rather than cache. */
rail.onLayout(paintMarkers);Use it as vanilla JS
One file, styles included. No bundler, no stylesheet link — from a CDN:
<script src="https://unpkg.com/vugluscr@2"></script>
<script>
var rail = new Vugluscr.Scrollbar();
</script>jsDelivr serves the same file from
https://cdn.jsdelivr.net/npm/vugluscr@2. Both resolve a bare
vugluscr to the latest release, which is fine for a try-out and a
liability in production — pin at least the major, as above.
Or from the installed package, at
node_modules/vugluscr/dist/vugluscr.standalone.js. That build is an IIFE
meant for a <script> tag: it sets the global and nothing else, so importing
it through a bundler gets you the side effect, not the exports. A bundler
should use the main entry.
The global is the library, not the class — a bare Scrollbar global would be an
easy collision on someone else's page.
An inner scroller
Pass the element that owns the scroll position. The rail forces overflow: auto
and min-block-size: 0 on it so it actually clips and scrolls, and makes the
container a positioning context — both reverted on dispose().
const rail = new Scrollbar({
scrollContainer: document.getElementById("panel"),
contentElement: document.getElementById("panelContent"),
});contentElement must be a descendant of scrollContainer — the content that
moves with scrolling, not the scroller itself. Marker offsets are measured from
it, and it is what the minimap draws.
Options
| Option | Default | |
| ----------------- | ------------------------------------------------------ | ----------------------------------------------------------- |
| scrollContainer | the page | Element that owns the scroll position. |
| container | the scroller's parent, or document.body | Where the rail mounts. |
| contentElement | the scroller's first element child, or document.body | Marker origin and minimap source. |
| showMinimap | true | Draw the minimap pane beside the track. |
| autoHide | true | Hide the rail while the content fits, and while fullscreen. |
| minimap | | { minimapWidth, showThumb: 'always' \| 'mouseover' } |
| track | | { width, cursorColor, minMarkerHeight } |
Methods
setMarkers(markers)— replaces the marker set. Each is{ start, end?, color, lane? }, wherestart/endare pixel offsets downcontentElementandlaneis'left' | 'center' | 'right' | 'full'(default'full') selecting which third of the track width to draw in. Very short markers are clamped to a minimum height so a single-line hit stays visible, and markers are batched by colour when painted.setCursorOffset(offset)— a current-position indicator on the track; negative hides it.onLayout(listener)— fires after each layout pass, once geometry has settled. Returns something with adispose()method.dispose()— removes the rail and restores everything it changed.
ScrollRail is also exported: the rendered widget without the mount layer, for
a host that already owns its scroll state. Scrollbar is that plus the
DOM and scroll-source plumbing.
There is no IDisposable to implement: teardown is structural. Every
subscription hands back something carrying a dispose() method, and disposing
a rail releases everything under it.
Styling
Two kinds of CSS, kept apart on purpose.
Layout is the library's. The positioning, the pointer-events routing, the
clipping, and the suppression of the native scrollbar are what make the thing
work — change them and you don't restyle the scrollbar, you break it. So they
are not shipped as a file to import: Scrollbar injects them into the document
itself, once, as <style data-vugluscr="structure"> in @layer
vugluscr.structure. Nothing to import, nothing to load in the right order,
nothing to forget.
Looks are yours. theme.css holds only what is safe to change — colours,
fills, transitions, cursors — in @layer vugluscr.theme inside
@scope (.vugluscr). Import it for the stock appearance or skip it and style
.vugluscr from scratch. Either way the scrollbar still works.
Retheming
Everything neutral is a tint of one colour, so matching the rail to a design is usually a single declaration:
:root {
--vugluscr_color: rebeccapurple;
}The tints, faintest to strongest:
| | |
| --------------- | ------------------------------------------------- |
| 11% | track and minimap backing |
| 17% | minimap containers (section, figure, ul, …) |
| 23% | minimap leaf blocks (p, li, figcaption, …) |
| 25% | minimap blocks of unrecognised elements |
| 30% · 50% · 60% | thumb · hovered · dragging |
| 90% | markers |
--vugluscr_accent_color is the exception: images in the minimap read as a
different kind of content rather than a louder tint of the same one, so they
carry their own colour.
Every derived value is also a token in its own right, so one thing can break ranks without giving up the knob:
:root {
--vugluscr_color: rebeccapurple; /* the whole rail */
--vugluscr_thumb_background: tomato; /* except the thumb */
}--vugluscr_track_background, --vugluscr_thumb_background (_hover,
_active), --vugluscr_minimap_background,
--vugluscr_minimap_thumb_background, --vugluscr_minimap_block (_group,
_unknown, _image, _stroke), --vugluscr_marker_color and
--vugluscr_marker_min_height.
Tokens are read but never declared: a declaration on .vugluscr inside the
theme would sit closer to the element than your :root and quietly win, so the
defaults live in var() fallbacks at the point of use.
Markers, which CSS cannot otherwise reach
Markers are painted onto a canvas, so a stylesheet cannot touch them the way it
touches an element. ScrollbarTrack reads two values back out of its own
computed style at paint time so their prominence stays a styling decision:
--vugluscr_marker_min_height— a single-position marker is drawn at exactly this height, which makes it the knob for how loudly occurrences read on the rail.--vugluscr_marker_color— for markers that carry no colour of their own.
The colour rides on the track's color property rather than being read raw, so
whatever you write is resolved by the browser first — color-mix(), a chain of
variables, currentColor — before the canvas, which cannot parse any of that,
ever sees it. A colour passed with an individual marker still wins over the
token, which is how a consumer with several kinds of marker keeps them apart.
Overriding selectors
Because the theme is layered, any unlayered rule of yours wins without
!important:
.vugluscr .track .thumb {
background-color: rebeccapurple;
}What you can target:
.vugluscr [.is_embedded]
└── .scrollbar
├── .minimap
│ ├── svg > rect.<element-name> one per content block
│ └── .thumb viewport indicator
└── .track
├── canvas markers
└── .thumb [.active] draggableThe rail also sets vugluscr_active on <html> (page mode) or
vugluscr_embedded on the scroller, which is how the native scrollbar gets
hidden. Both are removed on dispose().
State is set by the library, rendered by the theme: showThumb: 'mouseover'
toggles visible on the minimap's indicator from a pointer listener rather than
a :hover rule, so restyling the theme changes how the indicator looks, never
whether it appears.
Development
npm run dev # test rig at / — page and embedded rails side by side
npm test # unit tests
npm run test:e2e # Playwright, against the rig
npm run build # both package outputs into dist/
npm run build:demo # the rig as a static site, into demo-dist/The demo is the rig, built from src/ and deployed to GitHub Pages by
.github/workflows/pages.yml on every push to main.
The e2e suite is where the real behaviour is pinned — mount modes, scroll
driving, wheel forwarding, auto-hide, and the CSS-driven marker prominence
above. It runs against test-rig/, which mounts both a page-mode and an
embedded scrollbar on one page and exposes them on window.vugluscrRig so a
test can push exact marker offsets instead of inferring them from content.
test-rig/standalone.html exercises the vanilla build. Run npm run build
first, and open it through a plain static server rather than npm run dev —
Vite's dev server runs the IIFE bundle through its module transform, which
rewrites the global away.
How it is put together
| | |
| -------------------- | -------------------------------------------------------------------------------- |
| Scrollable | scroll state and smooth-scroll animation; no DOM |
| ScrollHost | the scroll source — the window, or an element |
| CachedDomNode | write-caching wrapper so repeated style writes don't hit the DOM |
| ScrollTrackOverlay | shared base for anything drawn on the rail: click-anywhere, drag-continue, thumb |
| ScrollbarTrack | the canvas marker track |
| Minimap | the SVG block map |
| ScrollRail | composes track + optional minimap over one Scrollable |
| Scrollbar | mounts a ScrollRail on a scroll source and keeps the two in sync |
| structure.ts | the load-bearing CSS, injected once |
Scrollbar is what a consumer wants. ScrollRail is exported for a host that
already owns its scroll state and only needs the rendered widget; its domNode
is the rail's root element, to place or measure. Everything else in the table
is internal.
Teardown is structural throughout: internally, anything with a dispose()
method can be registered with a store, and every subscription returns one. The
store and the emitter behind that are not exported — what a consumer needs is
the dispose() method it was handed, not the machinery under it.
Credits
The scroll engine, the write-caching DOM wrapper, the canvas marker track and
the minimap are adapted from VS Code's
editor internals (scrollable.ts, fastDomNode.ts,
decorationsOverviewRuler, the minimap). MIT, like this package — see
LICENSE for the terms and NOTICE for the attribution.
