Hero image

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:

  1. The height and width of the page. The scrollWidth and scrollHeight properties of the scrollable element give us what is required.
  2. The location and bounds of each element. We can use getBoundingClientRect, as well as the height and width 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:

  1. Listen for the element scroll when using native browser scrolling so we can update the mini-maps current location.
  2. 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:

  1. Find the position of the page relating to where the mouse is
  2. 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:

  1. Limit the frame rate of the canvas redraw
  2. Completely remove the scrollbar from mobile, rather than just hide it.
  3. Handle extra large pages by only having a portion of the page rendered on the map.
  4. Figure out when the page is settled, rather than just waiting 50ms

References

  1. 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.
  2. princejwesley minimap - JQuery based where I wanted a native solution.


What to read more? Check out more posts below!