zudo-codemirror-wisdom
GitHub repository

Type to search...

to open search from anywhere

Scrollbar Cursor Indicator

Show the cursor position as a thin horizontal line on the scrollbar track using a ViewPlugin with DOM manipulation.

Scrollbar Cursor Indicator

This recipe builds a CodeMirror extension that displays the cursor's position as a thin horizontal line on the scrollbar track, similar to the minimap cursor indicator in VS Code. It gives users a quick visual reference of where they are in a long document.

Overview

The extension creates a small <div> element positioned absolutely inside the editor's DOM. The element is positioned vertically based on the cursor's relative position in the document. It updates on selection changes, document changes, and geometry changes.

Implementation

import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";

const SCROLLBAR_WIDTH = "14px";

function scrollbarCursorIndicator() {
  return ViewPlugin.fromClass(
    class {
      private indicator: HTMLDivElement;
      private pendingFrame = 0;
      private focused: boolean;
      private readonly onFocus: () => void;
      private readonly onBlur: () => void;

      constructor(private view: EditorView) {
        this.indicator = document.createElement("div");
        Object.assign(this.indicator.style, {
          position: "absolute",
          right: "0",
          width: SCROLLBAR_WIDTH,
          height: "2px",
          pointerEvents: "none",
          zIndex: "10",
          background: "var(--palette-cursor)",
        });
        view.dom.appendChild(this.indicator);

        this.focused = view.hasFocus;
        this.updatePosition();

        this.onFocus = () => {
          this.focused = true;
          this.updatePosition();
        };
        this.onBlur = () => {
          this.focused = false;
          this.indicator.style.display = "none";
        };
        view.dom.addEventListener("focusin", this.onFocus);
        view.dom.addEventListener("focusout", this.onBlur);
      }

      update(update: ViewUpdate) {
        if (
          update.selectionSet ||
          update.docChanged ||
          update.geometryChanged
        ) {
          if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame);
          this.pendingFrame = requestAnimationFrame(() => {
            this.pendingFrame = 0;
            this.updatePosition();
          });
        }
      }

      private updatePosition() {
        if (!this.focused) {
          this.indicator.style.display = "none";
          return;
        }

        const { scrollDOM, state } = this.view;
        const { scrollHeight, clientHeight } = scrollDOM;

        // Hide when content doesn't scroll
        if (scrollHeight <= clientHeight) {
          this.indicator.style.display = "none";
          return;
        }
        this.indicator.style.display = "";

        const head = state.selection.main.head;
        const block = this.view.lineBlockAt(head);
        const contentH = this.view.contentHeight;
        const ratio = contentH > 0 ? block.top / contentH : 0;
        const scrollerTop = scrollDOM.offsetTop;
        const trackHeight = clientHeight;
        this.indicator.style.top = `${scrollerTop + ratio * trackHeight}px`;
      }

      destroy() {
        if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame);
        this.view.dom.removeEventListener("focusin", this.onFocus);
        this.view.dom.removeEventListener("focusout", this.onBlur);
        this.indicator.remove();
      }
    },
  );
}

Key Design Decisions

Position Calculation

The indicator's vertical position is calculated as a ratio of the cursor's line position to the total content height:

ratio = lineBlock.top / contentHeight
indicatorTop = scrollerTop + ratio * trackHeight

lineBlockAt(head) returns the visual line block at the cursor position, giving us the pixel offset from the top of the content. Dividing by contentHeight gives a 0-to-1 ratio that maps to the scrollbar track.

Note

The extension uses contentHeight (the height of actual document content) rather than scrollHeight (which may include scrollPastEnd() padding). This prevents the indicator from being pushed to an artificially low position when the editor has extra scrollable space after the last line.

Focus-Aware Visibility

The indicator is only shown when the editor has focus. In a split-pane editor, multiple CodeMirror instances may be visible simultaneously. Without focus awareness, each pane would show its own indicator, creating visual clutter.

this.onFocus = () => {
  this.focused = true;
  this.updatePosition();
};
this.onBlur = () => {
  this.focused = false;
  this.indicator.style.display = "none";
};

The plugin initializes this.focused from view.hasFocus rather than defaulting to true. This handles cases where the editor is created without receiving focus (such as a newly opened split pane).

DOM Approach vs Decoration

This extension uses direct DOM manipulation (appending a <div> to view.dom) rather than CodeMirror decorations. This is because the indicator is positioned relative to the scrollbar, not relative to document content. Decorations are designed to annotate document ranges and would not work for an overlay on the scrollbar track.

requestAnimationFrame

Position updates are batched with requestAnimationFrame to avoid layout thrashing when multiple events fire in quick succession (e.g., typing rapidly).

Cleanup

The destroy method removes the indicator element, cancels pending animation frames, and removes event listeners:

destroy() {
  if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame);
  this.view.dom.removeEventListener("focusin", this.onFocus);
  this.view.dom.removeEventListener("focusout", this.onBlur);
  this.indicator.remove();
}

Warning

Always remove event listeners in the destroy method. Without cleanup, listeners would accumulate if the extension is reconfigured or the editor is recreated.

Usage

const extensions = [
  // ... other extensions
  scrollbarCursorIndicator(),
];

To change the indicator color, set a CSS custom property:

.my-editor {
  --palette-cursor: #528bff;
}

Or modify the background value in the indicator style to use a fixed color:

background: "rgba(82, 139, 255, 0.8)",

Scrollbar Match Markers

A related technique is painting one tick per search match on the scrollbar track — the "minimap of matches" you see in VS Code when a find is active. The plugin is a pure consumer of another extension's StateField: it never owns any state itself, it only reads positions out of the field and repaints DOM nodes.

See Search & Replace for the custom highlight layer that exposes searchHighlightField.

Reading positions from another extension's field

view.state.field(field, false) is the safe way to read an optional field. Passing false as the second argument returns undefined instead of throwing when the field has not been mounted (e.g., during testing or when the search extension is not included):

const set = view.state.field(searchHighlightField, false);
if (!set) return []; // field not mounted — nothing to paint

This lets the scrollbar-marker plugin stand on its own without a hard dependency on the search extension being present.

Collapsing many matches per line into one tick

A document line can contain many search hits. Painting a separate tick for each one would clutter the scrollbar. The collapse strategy uses lineBlockAt(from).top as a unique key: two matches share the same top pixel when they fall on the same visual line, so they collapse to a single marker:

interface MarkerPos {
  top: number;
  active: boolean;
}

function collectMarkerPositions(view: EditorView): MarkerPos[] {
  const set = view.state.field(searchHighlightField, false);
  if (!set) return [];

  const seen = new Map<number, MarkerPos>();
  const docLength = view.state.doc.length;
  const cursor = set.iter();

  while (cursor.value !== null) {
    const from = cursor.from;
    // Decoration ranges past the live document length would throw
    // inside lineBlockAt. The StateField maps ranges through tr.changes,
    // so this is a defensive guard for stale positions only.
    if (from < 0 || from > docLength) {
      cursor.next();
      continue;
    }

    const block = view.lineBlockAt(from);
    const top = block.top; // pixel offset from content top — unique per visual line
    const spec = cursor.value.spec as { class?: string };
    const active = spec.class === "find-match-active";

    const existing = seen.get(top);
    if (existing) {
      // Promote to active if any range on this line is the active hit.
      if (active) existing.active = true;
    } else {
      seen.set(top, { top, active });
    }
    cursor.next();
  }

  return Array.from(seen.values());
}

The two-tier system — basic vs. active — preserves the distinction between regular search hits and the currently focused hit, even after collapsing.

Repaint triggers and why viewportChanged is excluded

The update method decides when to schedule a repaint:

update(update: ViewUpdate) {
  const prevField = update.startState.field(searchHighlightField, false);
  const nextField = update.state.field(searchHighlightField, false);
  const fieldChanged = prevField !== nextField;

  // Repaint on:
  //   fieldChanged   — a new decoration set was dispatched (new search results)
  //   docChanged     — doc edits remap positions through tr.changes,
  //                    shifting block.top even if the field reference is stable
  //   geometryChanged — window resize or line-wrap toggle shifts block.top
  //
  // NOT viewportChanged: the markers are absolute on the scrollbar track
  // and do not depend on which lines CodeMirror currently virtualizes.
  if (fieldChanged || update.docChanged || update.geometryChanged) {
    this.scheduleRepaint();
  }
}

viewportChanged fires whenever CodeMirror's virtualized viewport scrolls — frequently. Scrollbar marker positions are computed from absolute block.top values relative to contentHeight, so they do not change as the user scrolls. Repaining on viewportChanged would do unnecessary work on every scroll event.

Full plugin

import { type Extension } from "@codemirror/state";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { searchHighlightField } from "./search-highlight"; // your field

const SCROLLBAR_WIDTH = "14px";

export function searchScrollbarMarkersExtension(): Extension {
  return ViewPlugin.fromClass(
    class {
      private container: HTMLDivElement;
      private pendingFrame = 0;

      constructor(private view: EditorView) {
        this.container = document.createElement("div");
        Object.assign(this.container.style, {
          position: "absolute",
          right: "0",
          top: "0",
          width: SCROLLBAR_WIDTH,
          height: "100%",
          pointerEvents: "none",
          zIndex: "10",
        });
        view.dom.appendChild(this.container);
        this.scheduleRepaint();
      }

      update(update: ViewUpdate) {
        const prevField = update.startState.field(searchHighlightField, false);
        const nextField = update.state.field(searchHighlightField, false);
        const fieldChanged = prevField !== nextField;
        if (fieldChanged || update.docChanged || update.geometryChanged) {
          this.scheduleRepaint();
        }
      }

      private scheduleRepaint(): void {
        if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame);
        this.pendingFrame = requestAnimationFrame(() => {
          this.pendingFrame = 0;
          this.repaint();
        });
      }

      private repaint(): void {
        const { scrollDOM } = this.view;
        const { scrollHeight, clientHeight } = scrollDOM;

        if (scrollHeight <= clientHeight || clientHeight === 0) {
          this.container.replaceChildren();
          return;
        }

        const positions = collectMarkerPositions(this.view);
        if (positions.length === 0) {
          this.container.replaceChildren();
          return;
        }

        // contentHeight — same trap as the cursor indicator above.
        const contentH = this.view.contentHeight;
        if (contentH <= 0) {
          this.container.replaceChildren();
          return;
        }

        const scrollerTop = scrollDOM.offsetTop;
        const trackHeight = clientHeight;

        const fragment = document.createDocumentFragment();
        for (const pos of positions) {
          const ratio = pos.top / contentH;
          const clamped = Math.max(0, Math.min(1, ratio));
          const top = scrollerTop + clamped * trackHeight;
          const marker = document.createElement("div");
          marker.className = pos.active
            ? "cm-search-scrollbar-marker cm-search-scrollbar-marker-active"
            : "cm-search-scrollbar-marker";
          marker.style.top = `${top}px`;
          fragment.appendChild(marker);
        }
        this.container.replaceChildren(fragment);
      }

      destroy() {
        if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame);
        this.container.remove();
      }
    },
  );
}

CSS for the markers

The ticks are thin absolutely-positioned divs. A distinct color distinguishes the active hit:

.cm-search-scrollbar-marker {
  position: absolute;
  right: 0;
  width: 14px;
  height: 2px;
  background: rgba(255, 200, 0, 0.6);
}

.cm-search-scrollbar-marker-active {
  background: rgba(255, 140, 0, 0.9);
}

Note

Both the cursor-indicator and the match-marker container sit inside view.dom with position: absolute. They stack visually because both target the right edge of the editor. Give each a distinct z-index (or use CSS custom properties) if you need to control which one appears on top.

Revision History

Takeshi TakatsudoCreated: 2026-04-03T22:32:58+09:00Updated: 2026-05-29T05:57:21+09:00