cortexcn

TOC Minimap

Navigate page sections with a compact, hoverable table-of-contents minimap.

Loading…
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-minimap
Install the following dependencies:
npx shadcn@latest add @cortexcn/hover-card
Copy and paste the following code into your project.
components/ui/toc-minimap.tsx
"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",  });}
Update the import paths to match your project setup.

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.

Loading…
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.

Loading…
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.

Loading…
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.

Loading…
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

PropTypeDefaultDescription
itemsTOCItemType[]Outline entries. Each url is a #hash to a section id.
classNamestringClasses merged onto the root (e.g. ml-0 to undo ml-auto).
optionsIntersectionObserverInit{ rootMargin: "0% 0% -80% 0%" }Scroll-spy observer options.
soundbooleantruePlay a soft sound when the outline expands on hover.

TOCItemType

type TOCItemType = {
  title: React.ReactNode;
  url: string; // "#section-id"
  depth: number; // 2 | 3 | 4
};

On this page