Display

Mini Map

A thumbnail navigator for any scroll container — a draggable viewport box over an auto-measured silhouette of the content, kept in sync by a passive rAF-throttled listener that writes transform straight to the DOM.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export type MiniMapOrientation = "vertical" | "both"

/** How a measured block is tinted in the automatic miniature. */
export type MiniMapBlockKind = "heading" | "media" | "body"

/** Everything a custom miniature needs in order to lay itself out. */
export interface MiniMapMiniature {
  /** Multiply a target-space x/width by this to get miniature pixels. */
  scaleX: number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/mini-map.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "MiniMap" component with NO dependency
beyond a `cn` class merger — the measurement, the scroll-thumb algebra and the
pointer logic are the product.

Contract
- export const MiniMap = React.forwardRef<HTMLDivElement, MiniMapProps>,
  remaining props spread onto the root:
  targetRef: React.RefObject<HTMLElement | null> (the scroll container this
  map navigates; it must already be mounted when the map mounts);
  orientation?: "vertical" | "both" (default "vertical");
  width?: number (88) and height?: number (240) — px, non-finite or <= 0 falls
  back to the default;
  blockSelector?: string — which descendants become blocks in the automatic
  miniature (default "[data-minimap-block], h1..h6, p, li, pre, blockquote,
  img, figure, table"); ignored when renderMiniature is given, and an invalid
  selector yields an empty map instead of throwing;
  renderMiniature?: (m: { scaleX, scaleY, contentWidth, contentHeight, width,
  height }) => React.ReactNode — draw the thumbnail yourself;
  step?: number (48) — target px per arrow key, clamped like width/height;
  watchContent?: boolean (true) — also watch the target's subtree with a
  MutationObserver;
  label?: string ("Content minimap") — the slider's accessible name.
- Also export the MiniMapOrientation / MiniMapBlockKind / MiniMapMiniature
  types so consumers can type their own renderer.

Behavior
- Scale. "vertical" maps the two axes INDEPENDENTLY: scaleY = boxH/contentH so
  a document of any length fills the column top to bottom, scaleX = boxW/contentW
  so its width is squeezed into the same narrow strip. "both" uses ONE uniform
  scale = min(boxW/contentW, boxH/contentH) and centres the result, so a canvas
  keeps its aspect ratio and is letterboxed. Both cap the scale at 1 — content
  smaller than the box stays life-size instead of being magnified into a slab.
- Viewport box position uses scrollbar-THUMB algebra, not `scrollTop * scale`:
  boxLen = clamp(clientLen/contentLen * trackLen, MIN 14px, trackLen) and
  pos = scroll/maxScroll * (trackLen - boxLen). The two agree exactly while the
  minimum is not binding; once a 30x-longer-than-the-viewport document forces
  the 14px minimum, only the thumb form keeps both ends aligned (the naive form
  would hang the box past the bottom of the track). The same mapping is
  inverted for every input, so drag, click and keyboard can never disagree with
  what is drawn.
- Scroll -> box. One passive, rAF-throttled "scroll" listener on the target.
  Its callback writes width/height/translate3d directly onto the box's DOM node
  and setAttribute's aria-valuenow/aria-valuetext on the root — no React state
  is touched, so scrolling a 1000-block document causes ZERO re-renders.
  aria-valuenow is rendered once as 0 in JSX and never changed there, so React
  reconciliation never overwrites the imperative value.
- Box -> scroll, in three deliberately separated phases:
  1. pointerdown records the pointerId, the press point in the map's own
     coordinates, the current box position and whether the press landed inside
     the box. It does NOT capture the pointer and does NOT preventDefault.
     Move/up/cancel go on `window`, because before capture exists the pointer
     can leave the element.
  2. The first pointermove past 4px of travel starts the drag: only THEN
     setPointerCapture (capturing on pointerdown retargets the compatibility
     click and kills clicks, focus and :active underneath). If the press had
     landed outside the box, re-anchor first so the press point becomes the
     box's centre. The target's scroll-behavior is switched to "auto" for the
     duration and restored after, or a consumer's `scroll-smooth` turns every
     drag frame into its own tween.
  3. Every later move applies the delta RELATIVE TO THE PRESS POINT, never an
     absolute pointer->position map: grabbing a box off-centre and nudging it
     4px must move it 4px, not snap its centre under the cursor.
  pointermove also bails out when event.buttons === 0 — the pointerup can go
  missing when the button is released outside the window before capture, and
  browsers REUSE pointerIds, so the next unpressed hover would otherwise keep
  dragging the box.
- Click (press and release under the slop, outside the box) scrolls so the
  press point becomes the centre of the viewport, with behavior "smooth" unless
  prefers-reduced-motion is set, in which case it jumps instantly. A press
  inside the box that never moves is a no-op. A drag never ends in a jump.
- Keyboard: the root is role="slider" aria-orientation="vertical" with
  aria-valuemin/max 0..100 and aria-controls pointing at the target's id when
  it has one. Up/Down move `step` px, PageUp/PageDown move 90% of the target's
  client height, Home/End go to the ends, and in "both" Left/Right pan by
  `step`. Keyboard scrolling is instant like the browser's own — a held arrow
  key must not queue a stack of tweens. Every handled key preventDefaults; the
  consumer's own onKeyDown runs first and can preventDefault to opt out.
- Measurement: a ResizeObserver watches the target, the map's own root and the
  target's first element child (the wrapper that usually grows), and an
  optional MutationObserver watches the target's subtree; both funnel into one
  rAF-coalesced measure. observe() fires immediately, which doubles as the
  first measurement, so nothing setStates from an effect body. The measure
  compares metrics and block rectangles field by field and returns the previous
  state when nothing changed, which is what keeps a busy subtree from
  re-rendering for nothing. Mutations originating inside the map's own subtree
  are ignored, so mounting the map inside its target cannot loop.
- Automatic miniature: query the target for blockSelector, convert each rect
  into the target's CONTENT coordinate space (getBoundingClientRect minus an
  origin of targetRect.left - scrollLeft + clientLeft), classify it as
  heading / media / body (H1-H6, then PRE/IMG/FIGURE/TABLE/BLOCKQUOTE/VIDEO/
  CANVAS/SVG, with a data-minimap-kind override), sort by y then x, then PACK:
  scale to map pixels and merge every run of same-kind rectangles that lands on
  overlapping pixels. Packing is what makes the strategy scale — a 1000-block
  article compressed 63x emits 160 nodes instead of 1000 sub-pixel divs — and
  because a different kind breaks the run, headings and code blocks still show
  up as separate stripes.
- SSR / hydration: nothing reads window, matchMedia or the DOM during render.
  The server and the pre-measurement client render an inert placeholder of the
  EXACT final size, so there is no crash and no layout shift; the miniature and
  the viewport box appear together on the first measured frame.
- Degraded state: when the target has no scroll range the root reports
  aria-disabled, pointerdown returns immediately, the keyboard handler is a
  no-op, the silhouette is dimmed and the frame outlines the whole document
  instead of a movable box. It keeps its slot — never unmount, or the layout
  jumps the moment content grows.
- Cleanup: the scroll and resize listeners, both observers, every pending
  requestAnimationFrame, the window pointer listeners, the pointer capture and
  the borrowed scroll-behavior are all released on unmount and on every gesture
  end.

Rendering & styling
- Semantic tokens only: bg-muted/40 + border + rounded-md (the map), the
  miniature blocks bg-foreground/70 (heading) / bg-primary/45 (media) /
  bg-muted-foreground/40 (body), the viewport box border-2 border-primary/50 +
  bg-primary/10 going to border-primary + bg-primary/20 while dragging,
  ring-ring for focus-visible. No hex, no rgb().
- The root is touch-none select-none overflow-hidden so a touch drag moves the
  box instead of scrolling the page, and cursor-pointer -> cursor-grabbing via
  a data-dragging attribute set imperatively (a React state flag would put a
  render on the drag path).
- The miniature layer is aria-hidden and pointer-events-none: it repeats
  content the screen reader already has, and every click on the map should
  navigate rather than select. The role="slider" root carries the whole
  accessible story.
- No animation anywhere except the smooth click-jump, which is dropped under
  prefers-reduced-motion — the navigation itself never depends on motion.

Customization levers
- Size and axis: width/height are the whole layout story (a 60px strip beside
  an editor, a 200x140 overview in a canvas corner); orientation switches the
  scale rule, the letterboxing, the extra Left/Right keys and the horizontal
  half of the drag together.
- Which blocks show up: blockSelector is the cheapest knob — narrow it to
  "[data-minimap-block]" and tag exactly the nodes you want, or widen it to
  include your own components. data-minimap-kind="heading|media|body" on a node
  overrides the tag-name classification; retint the three kinds in one
  BLOCK_CLASS record.
- Skip measurement entirely with renderMiniature when you already know your
  layout (fixed row heights, a scene graph, a virtualized list): you get
  scaleX/scaleY plus the content and layer sizes and draw whatever you like
  into an absolutely positioned layer. Rows of height H sit at index * H *
  scaleY — no DOM walk, no MutationObserver cost.
- Feel: MIN_BOX (14px) trades fidelity for grabbability on very long content;
  DRAG_SLOP (4px) decides how far a press may wander before it becomes a drag;
  `step` sets the arrow-key grain (a line height for code, a card height for a
  feed). watchContent={false} drops the MutationObserver for targets that
  mutate every frame and are re-measured by the ResizeObserver anyway.
- Chrome: the map is a plain bordered box — drop the border for a seamless
  gutter, or absolutely position it over the target's corner (it does not have
  to be a sibling, only a consumer of the same ref).

Concepts

  • Scroll-thumb algebra — the viewport box is not positioned by scrollTop × scale but by the scrollbar-thumb formula: pos = scroll/maxScroll × (track − box), with box floored at 14px. The two are numerically identical while the floor isn't binding; once content runs past 30× the viewport and the floor kicks in, only the thumb form keeps "scrolled to the bottom" aligned with "box against the bottom". The same formula is inverted for drag, click and keyboard, so the three input paths can never disagree.
  • Drag slop before capture — pointerdown does not capture the pointer and does not preventDefault; setPointerCapture waits until the pointer has travelled more than 4px. The other order makes the browser retarget the following compatibility click to the capturing element, killing clicks, focus and :active on everything underneath. The side effect is exactly the one you want: after a real drag, that click lands on the map rather than the content.
  • Relative-delta drag — movement is always a delta from the press point. Map the pointer's absolute position instead and grabbing the box by its edge and nudging 4px snaps the box's centre under the cursor; when the press lands on empty track, re-anchor the box on the press point first and then follow the delta.
  • Phantom-pointer guardpointermove bails out on event.buttons === 0. Release the button outside the window before capture exists and pointerup never arrives — and because browsers reuse pointerIds, the next unpressed hover would keep dragging the viewport box.
  • Silhouette packing — the automatic miniature converts every block element into content coordinates, then merges runs of same-kind rectangles that land on overlapping pixels. 1000 blocks compressed 63× come out as 160 nodes (node count is bounded by the map's pixel height, not by how much content there is), and a different kind breaks the run, so headings and code blocks stay distinct light/dark stripes.
  • Zero-render scroll path — the scroll listener is passive and rAF-throttled, and its callback writes transform and aria-valuenow straight to the DOM without touching React state; aria-valuenow is rendered once as 0 in JSX and never changed there, so reconciliation can't overwrite the imperative value.

On This Page