TOC Minimap
Navigate page sections with a compact, hoverable table-of-contents minimap.
import { TOCMinimap } from "@/components/ui/toc-minimap";export default function TocMinimapDemo() { return ( <div className="flex min-h-56 items-center justify-center"> <TOCMinimap className="ml-0" items={[ { title: "Introduction", url: "#introduction", depth: 2 }, { title: "Installation", url: "#installation", depth: 2 }, { title: "Usage", url: "#usage", depth: 2 }, { title: "Examples", url: "#examples", depth: 2 }, { title: "API Reference", url: "#api-reference", depth: 2 }, ]} /> </div> );}Installation
npx shadcn@latest add @cortexcn/toc-minimapnpx shadcn@latest add @cortexcn/hover-card"use client";import * as React from "react";import { uMiniMapOpenSound } from "@/lib/soundcn/u-mini-map-open";import { cn } from "@/lib/utils";import { useSound } from "@/hooks/soundcn/use-sound";import { HoverCard, HoverCardContent, HoverCardTrigger,} from "@/components/ui/hover-card";export type TOCItemType = { title: React.ReactNode; url: string; depth: number;};export type TOCMinimapProps = { /** Table-of-contents entries. Each `url` should be a `#hash` to a section id. */ items: TOCItemType[]; className?: string; /** IntersectionObserver options for scroll-spy. */ options?: IntersectionObserverInit; /** Play a soft sound when the outline expands on hover. Default: `true`. */ sound?: boolean;};/** * A compact, hoverable table-of-contents minimap. Renders the page outline as a * stack of dashes (indented by heading depth) that expands into a clickable list * on hover. The dash matching the section currently in view is highlighted. */export function TOCMinimap({ items, className, options, sound = true,}: TOCMinimapProps) { const itemIds = React.useMemo( () => items.map((item) => item.url.replace("#", "")), [items], ); const activeHeading = useActiveHeading(itemIds, options); const [play] = useSound(uMiniMapOpenSound, { volume: 0.3 }); if (!items.length) { return null; } return ( <div className={cn("ml-auto w-18", className)}> <HoverCard openDelay={0} closeDelay={0} onOpenChange={(open) => { if (open && sound) { play(); } }} > <HoverCardTrigger asChild> <div className="flex flex-col gap-3 py-3 pl-6 transition-opacity duration-200 data-[state=open]:opacity-0"> {items.map((item) => ( <div key={item.url} data-depth={item.depth} data-active={item.url === `#${activeHeading}`} className={cn( "pointer-events-none h-0.5 w-6 shrink-0 rounded-xs bg-muted-foreground/40 transition-colors duration-200", "data-[depth=3]:ml-2 data-[depth=3]:w-4", "data-[depth=4]:ml-4 data-[depth=4]:w-2", "data-[active=true]:bg-foreground", )} aria-hidden /> ))} </div> </HoverCardTrigger> <HoverCardContent align="start" side="left" sideOffset={-60} className="w-56 overflow-hidden p-0" > <ul className="flex max-h-[60dvh] flex-col overflow-y-auto overscroll-contain px-6 py-4 text-sm"> {items.map((item) => ( <li key={item.url} className="flex py-1"> <a href={item.url} data-depth={item.depth} data-active={item.url === `#${activeHeading}`} onClick={(event) => { event.preventDefault(); scrollToHeading(item.url); }} className={cn( "line-clamp-2 w-full text-muted-foreground transition-colors duration-200 hover:text-foreground", "data-[active=true]:text-foreground", "data-[depth=3]:pl-4 data-[depth=4]:pl-8", )} > {item.title} </a> </li> ))} </ul> </HoverCardContent> </HoverCard> </div> );}function useActiveHeading( itemIds: string[], options?: IntersectionObserverInit,) { const [activeId, setActiveId] = React.useState<string | null>(null); React.useEffect(() => { const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { setActiveId(entry.target.id); } } }, options ?? { rootMargin: "0% 0% -80% 0%" }, ); for (const id of itemIds) { const element = document.getElementById(id); if (element) { observer.observe(element); } } return () => observer.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [itemIds]); return activeId;}function scrollToHeading(url: string) { history.pushState(null, "", url); document.getElementById(url.replace("#", ""))?.scrollIntoView({ behavior: "smooth", });}Usage
import { TOCMinimap } from "@/components/ui/toc-minimap";<TOCMinimap
items={[
{ title: "Introduction", url: "#introduction", depth: 2 },
{ title: "Installation", url: "#installation", depth: 2 },
{ title: "Usage", url: "#usage", depth: 2 },
]}
/>Each url must point to the id of a section on the page. The dash for the
section currently in view is highlighted automatically.
Examples
Basic
A flat list of sections. Hover the dashes to reveal the section titles.
import { TOCMinimap } from "@/components/ui/toc-minimap";export default function TocMinimapDemo() { return ( <div className="flex min-h-56 items-center justify-center"> <TOCMinimap className="ml-0" items={[ { title: "Introduction", url: "#introduction", depth: 2 }, { title: "Installation", url: "#installation", depth: 2 }, { title: "Usage", url: "#usage", depth: 2 }, { title: "Examples", url: "#examples", depth: 2 }, { title: "API Reference", url: "#api-reference", depth: 2 }, ]} /> </div> );}Nested depths
Items with depth 3 and 4 render as shorter, indented dashes — mirror your
heading hierarchy for an at-a-glance map of the page.
import { TOCMinimap } from "@/components/ui/toc-minimap";// Mixed heading depths (2/3/4) — deeper items render as shorter, indented dashes.export default function TocMinimapNested() { return ( <div className="flex min-h-64 items-center justify-center"> <TOCMinimap className="ml-0" items={[ { title: "Getting Started", url: "#getting-started", depth: 2 }, { title: "Install the CLI", url: "#install-the-cli", depth: 3 }, { title: "Configure Tailwind", url: "#configure-tailwind", depth: 3 }, { title: "Add the preset", url: "#add-the-preset", depth: 4 }, { title: "Components", url: "#components", depth: 2 }, { title: "Button", url: "#button", depth: 3 }, { title: "Card", url: "#card", depth: 3 }, { title: "API Reference", url: "#api-reference", depth: 2 }, ]} /> </div> );}Long outline
When the outline is long, the expanded panel scrolls instead of overflowing.
import { TOCMinimap } from "@/components/ui/toc-minimap";// A long outline — the expanded panel scrolls once it exceeds its max height.const SECTIONS = [ "Overview", "Motivation", "Installation", "Quick Start", "Configuration", "Theming", "Components", "Hooks", "Utilities", "Accessibility", "Migration Guide", "Troubleshooting", "FAQ", "Changelog",];export default function TocMinimapMany() { return ( <div className="flex min-h-72 items-center justify-center"> <TOCMinimap className="ml-0" items={SECTIONS.map((title) => ({ title, url: `#${title.toLowerCase().replace(/\s+/g, "-")}`, depth: 2, }))} /> </div> );}In a page
Pin the minimap beside your content with sticky. Clicking an item smooth-scrolls
to its section, and scroll-spy keeps the active item highlighted as you read.
import { TOCMinimap } from "@/components/ui/toc-minimap";// A realistic layout: an article column with a minimap pinned beside it.// Hover the dashes to reveal titles; clicking one smooth-scrolls to its section,// and the dash for the section in view stays highlighted.const SECTIONS = [ { id: "overview", title: "Overview", body: "A vision-language-action model is the agent loop with motor commands swapped in for tool calls.", }, { id: "features", title: "Features", body: "Scroll-spy highlighting, a hover-to-expand outline, and depth-aware indentation out of the box.", }, { id: "pricing", title: "Pricing", body: "Free and open source. Copy the component into your project and own the code.", }, { id: "faq", title: "FAQ", body: "Works with any element that has an id. Pass your headings as items and you're done.", }, { id: "contact", title: "Contact", body: "Questions or ideas? Open an issue or reach out — contributions are welcome.", },];export default function TocMinimapPage() { return ( <div className="mx-auto flex max-w-xl gap-6 px-2"> <article className="flex-1 space-y-8"> {SECTIONS.map((section) => ( <section key={section.id} id={section.id} className="scroll-mt-4 space-y-2" > <h3 className="text-lg font-semibold">{section.title}</h3> <p className="text-sm text-muted-foreground">{section.body}</p> </section> ))} </article> <div className="sticky top-4 self-start"> <TOCMinimap items={SECTIONS.map((section) => ({ title: section.title, url: `#${section.id}`, depth: 2, }))} /> </div> </div> );}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | TOCItemType[] | — | Outline entries. Each url is a #hash to a section id. |
className | string | — | Classes merged onto the root (e.g. ml-0 to undo ml-auto). |
options | IntersectionObserverInit | { rootMargin: "0% 0% -80% 0%" } | Scroll-spy observer options. |
sound | boolean | true | Play a soft sound when the outline expands on hover. |
TOCItemType
type TOCItemType = {
title: React.ReactNode;
url: string; // "#section-id"
depth: number; // 2 | 3 | 4
};