Creating a scroll mini-map
Scrollbars are an essential part of web design, allowing for large and accessible documents to be easily used. Web developers can customize the appearance of scrollbars using CSS styles to match the overall design of the website, but it is quite restrictive.
Editors such as VSCode have improved this experience by adding a mini-map of the whole text editor to give the user a better idea of what is in a document. I thought it would be a good idea to bring this to a webpage.
Considerations
The page must still be accessible. To do this I am making the mini map as a bit of artistic flair, while keeping the original scrollbars for standard navigation. There is a lot more effort in accessibility put into default browser functionality than I could ever put into a feature like this.
How it works
For this to work we need to know the following:
- The height and width of the page. The
scrollWidth
andscrollHeight
properties of the scrollable element give us what is required. - The location and bounds of each element. We can use
getBoundingClientRect
, as well as theheight
andwidth
properties.
That is pretty much it. With these we can know the location of an element on the page, and what aspect ratio it should be given on the mini-map based on the dimensions of the page.
Implementation
What should be on the map
We need to decide which elements in a page should be given a spot on our mini-map as not everything is important enough for us to bother displaying. My implementation of this will have each element in an object where the key is the element, and the value will be a CSS variable that will be used to decide the colour on the mini-map. Below is my default list of elements that can match, which can be overwritten on the component if desired.
const elementMap = {
h1: '--minimap-heading',
h2: '--minimap-heading',
h3: '--minimap-heading',
h4: '--minimap-heading',
h5: '--minimap-heading',
h6: '--minimap-heading',
a: '--minimap-text',
p: '--minimap-text',
strong: '--minimap-text',
pre: '--minimap-text',
code: '--minimap-text',
blockquote: '--minimap-text',
img: '--minimap-text',
}
Web Components
Web components are a great way to have a framework agnostic component. They are native to the browser, compatible with all major frameworks, and have some nice features that encapsulate your component to make it safe from interference.
I have called the component mini-map
and defined it with a few properties that will
store the different HTML elements and public options.
export class Minimap extends HTMLElement {
// Element that is scrollable
_root?: HTMLElement;
// mini-map Scrollbar
canvas: HTMLCanvasElement;
// CSS styles that may change
styleElement: HTMLStyleElement;
// CSS styles that will not change
staticStyleElement: HTMLStyleElement;
// Element map described above
options: MinimapOptions;
constructor() {
super();
this.options = elementMap;
this.canvas = document.createElement("canvas");
this.styleElement = document.createElement("style");
this.staticStyleElement = document.createElement("style");
}
}
customElements.define("mini-map", Minimap);
Styling
My use case is to add a new scrollbar to the right of the page that will give a better experience by displaying the whole document. This should only be visible on larger screens that have the room to spare.
I have used two style components to make it clear what the dynamic styles are vs the ones that should never change. These could be put in a single style element instead but I feel there is a benefit to having two.
connectedCallback() {
// Add all child elements on create
const shadow = this.attachShadow({ mode: "open" });
shadow.appendChild(this.styleElement);
shadow.appendChild(this.staticStyleElement);
shadow.appendChild(this.canvas);
// Canvas should be as large as we let it
const canvas = `canvas { width: 100%; height: 100%; }`;
// Let the user know they can move about on hover
const hover = `canvas:hover { cursor: move; }`;
// Add to the right of the page, outside of the flow
const host = `
:host {
position: fixed;
right: 16px;
width: 100px;
}
`;
// Remove on small deviced
const hideOnSmall = `
@media (max-width: 999px) {
:host { display: none; }
}`
this.staticStyleElement.textContent = `
${hideOnSmall}
${host}
${hover}
${canvas}
`;
// larger pages will need more room than small
this.calculateSize();
// Give 50ms for the page draw to settle
setTimeout(() => {
this.redraw();
}, 50);
}
/**
* Resize the mini-map based on the size of the page
*/
calculateSize() {
if (this.root!.getBoundingClientRect().height > 5000) {
this.styleElement.textContent = `
:host {
top: 200px;
bottom: 200px;
}
`
} else {
this.styleElement.textContent = `
:host {
top: 400px;
bottom: 400px;
}
`
}
}
Binding to the root
There are a few things we need to do when the root element is first bound to the mini-map. These are:
- Listen for the element scroll when using native browser scrolling so we can update the mini-maps current location.
- Remove the listener when scrolling using the mini-map.
For the first part, all we need to do is to listen to the browsers native
scroll
event and redraw the mini-map.
set root(root: HTMLElement) {
this._root = root;
/* Trigger redraws on scrolling of the page */
const onScroll = () => {
this.redraw();
}
document.addEventListener("scroll", onScroll);
// ... part 2
}
For the second part we will need to check for a mouse down on the mini-map and stop listening for the scroll so we can scroll the map without causing a big loop.
const onMove = () => { /* TODO */ }
// Remove control of scrolling when the mouse is up
const onMouseUp = (e: MouseEvent) => {
e.preventDefault();
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onMouseUp);
this.root!.addEventListener("scroll", onScroll);
}
// Control scrolling with the mouse when the mouse is down over the canvas
this.canvas.addEventListener('mousedown', () => {
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onMouseUp);
this.root!.removeEventListener("scroll", onScroll);
})
Handle scrolling
The move function needs to do two things:
- Find the position of the page relating to where the mouse is
- Scroll the root
We already have the different elements that make up our mini-map, so all we need to do now is to convert between the two contexts - Canvas and Root.
const onMove = (e: MouseEvent) => {
// We are controlling the mouse
e.preventDefault();
// mini-map dimensions
const canvasBounds = this.canvas.getBoundingClientRect();
// Convert mini-map to page dimension
const canvasHeightWeighting = canvasBounds.height / this.root!.scrollHeight;
const viewpointCenterOffset = this.root!.clientHeight * canvasHeightWeighting / 2;
// Mouse position on the canvas
const pointOnCanvas = e.clientY - canvasBounds.y;
// Position the mouse is on in the context of the root element
const pointConvertedToPage =
(pointOnCanvas - viewpointCenterOffset) / (canvasBounds.height / this.root!.scrollHeight)
// Scroll the element to the new location using native scrolling
this.root!.scrollTo({
top: pointConvertedToPage
})
}
Drawing the page
We now have a functional scrollbar at the side of the page, but it is currently invisible to the user. The last part of the puzzle is to draw a very minimalist version of the current page onto the canvas.
For simplicity I am going to redraw the entire page whenever some scrolling occurs. In the future an extension could be to limit the frame rate so we don’t slow down the page too much.
redraw() {
const context = this.canvas.getContext('2d');
if (!context) throw new Error("Missing canvas context");
if (!this.root) return;
// reset the scale to default
context.setTransform(1, 0, 0, 1, 0, 0);
// Recalculate the scale based on the current page and canvas dimensions
const canvasBounds = this.canvas.getBoundingClientRect();
this.canvas.width = canvasBounds.width;
this.canvas.height = canvasBounds.height;
context.scale(
canvasBounds.width / this.root.scrollWidth,
canvasBounds.height / this.root.scrollHeight
);
// Blank the canvas
context.clearRect(0, 0, this.root.scrollWidth, this.root.scrollHeight);
const rootRect = this.root.getBoundingClientRect();
// Look through all tag configured to be rendered
for (const option of Object.entries(this.options)) {
const [elementSelector, colour] = option;
const elements = this.root.getElementsByTagName(elementSelector);
for (const element of elements) {
const elementRect = element.getBoundingClientRect();
// Style the element based on the configured css variable values
context.fillStyle = getComputedStyle(this).getPropertyValue(colour);
// Add the element to the calculated position on the canvas
context.fillRect(
elementRect.x - rootRect.x,
elementRect.y - rootRect.y,
elementRect.width,
elementRect.height
);
}
}
// Current page view
context.fillRect(
this.scrollLeft,
this.root.scrollTop,
this.root.clientWidth,
this.root.clientHeight
)
}
Summary
That is it. You can see the scrollbar in action to the right of this article if you are on a larger screen (sorry mobile users). It seems to work pretty nicely but there is always more polish that can be done.
Whats next
There are a few key things left in the future to make this a really nice scrollbar but they are out of scope for what I am trying to do at the moment. Some but not all of these things are:
- Limit the frame rate of the canvas redraw
- Completely remove the scrollbar from mobile, rather than just hide it.
- Handle extra large pages by only having a portion of the page rendered on the map.
- Figure out when the page is settled, rather than just waiting 50ms
References
- austin-rausch minimap-js - This was a big inspiration and I have used many ideas from this repository in the current implementation. I considered using it but I thought it was a bit over engineered, and relied on other libraries that I didn’t want to include.
- princejwesley minimap - JQuery based where I wanted a native solution.
What to read more? Check out more posts below!