TOC Minimap
hdprajwal/toc-minimapFreeComponent
A scrubbing table-of-contents rail with scroll spy, section previews, keyboard and screen-reader support, and a mobile scrub bar with an expandable section list. No dependencies beyond Tailwind and the cn utility.
Live preview
live · rendered by the registry
Rendered by hdprajwal on their own page — this is the component running, not a screenshot of it.Install it
npx shadcn@latest add https://hdprajwal.dev/r/toc-minimap.jsonSource
1'use client';23import {4 useCallback,5 useEffect,6 useRef,7 useState,8 type Dispatch,9 type MouseEvent as ReactMouseEvent,10 type PointerEvent as ReactPointerEvent,11 type SetStateAction,12} from 'react';13import { cn } from '@/lib/utils';1415export interface TocMinimapItem {16 id: string;17 text: string;18 // Heading depth; items deeper than level 2 are indented in the mobile list panel.19 level?: number;20 // Optional snippet shown under the section title in the tooltip.21 preview?: string | null;22}2324export interface TocMinimapProps {25 items: TocMinimapItem[];26 // Viewport-top inset in px treated as covered (e.g. a sticky header) by the scroll spy.27 scrollOffset?: number;28 // Runs the scroll spy and jumps against this scrollable element in place of the window.29 containerSelector?: string;30 // Replaces the default scroll-into-view and hash update when a section is chosen.31 onSelect?: (item: TocMinimapItem) => void;32 // Desktop rail wrapper.33 className?: string;34 // Mobile rail wrapper.35 mobileClassName?: string;36 tooltipClassName?: string;37 panelClassName?: string;38}3940const TOC_MINIMAP_ITEM_SPACING = 8;41const TOC_MINIMAP_MAX_HEIGHT_CSS = 'calc(100vh - 18rem)';42const TOC_MINIMAP_MOBILE_ITEM_SPACING = 10;43const TOC_MINIMAP_MOBILE_MAX_WIDTH_CSS = 'calc(100vw - 7rem)';44// Pointer movement under this many pixels between down and up counts as a tap, not a scrub.45const TOC_MINIMAP_TAP_THRESHOLD_PX = 8;4647// Shared by the vertical (desktop) and horizontal (mobile) rails: natural size grows with item count, capped by the viewport-relative CSS max.48export function resolveTocMinimapNaturalSizeStyle(49 itemCount: number,50 spacing: number,51 maxSizeCss: string52): string {53 const naturalSize = Math.max(1, (itemCount - 1) * spacing);54 return `min(${naturalSize}px, ${maxSizeCss})`;55}5657function resolveTocMinimapHeightStyle(itemCount: number): string {58 return resolveTocMinimapNaturalSizeStyle(59 itemCount,60 TOC_MINIMAP_ITEM_SPACING,61 TOC_MINIMAP_MAX_HEIGHT_CSS62 );63}6465function resolveTocMinimapMobileWidthStyle(itemCount: number): string {66 return resolveTocMinimapNaturalSizeStyle(67 itemCount,68 TOC_MINIMAP_MOBILE_ITEM_SPACING,69 TOC_MINIMAP_MOBILE_MAX_WIDTH_CSS70 );71}7273export function resolveTocMinimapTopPercent(74 index: number,75 itemCount: number76): number {77 if (itemCount <= 1) return 0;78 return (Math.max(0, Math.min(index, itemCount - 1)) / (itemCount - 1)) * 100;79}8081// … truncated
