Skip to content
Notra logoNotra UI
Esc
↑↓navigate↵open⌘Jpreview
On this page

Chat Minimap

A rail of lines that maps a conversation, one line per turn.

"use client";

import { cn } from "cn";
import { useState } from "react";

import {
  ChatMinimap,
  ChatMinimapItem,
  ChatMinimapNavButton,
} from "../components/chat-minimap";
import {
  CHAT_MINIMAP_DEMO_TURNS,
  CHAT_MINIMAP_VISIBLE_TURNS,
} from "../constants/chat-minimap";
import type { ChatMinimapExampleProps } from "../types/chat-minimap";

export default function ChatMinimapExample({
  initialStart = 2,
  side = "right",
  turns = CHAT_MINIMAP_DEMO_TURNS,
}: ChatMinimapExampleProps) {
  const [start, setStart] = useState(initialStart);
  const lastStart = turns.length - CHAT_MINIMAP_VISIBLE_TURNS;
  const clampStart = (next: number) => Math.min(Math.max(next, 0), lastStart);

  return (
    <div
      className={cn(
        "flex w-full px-6 py-10",
        side === "left" ? "justify-end" : "justify-start"
      )}
    >
      <ChatMinimap side={side}>
        <ChatMinimapNavButton
          direction="previous"
          disabled={start === 0}
          onClick={() => setStart((current) => clampStart(current - 1))}
        />
        {turns.map((turn, index) => (
          <ChatMinimapItem
            active={
              index >= start && index < start + CHAT_MINIMAP_VISIBLE_TURNS
            }
            description={turn.description}
            key={turn.id}
            onClick={() => setStart(clampStart(index - 1))}
            title={turn.title}
          />
        ))}
        <ChatMinimapNavButton
          direction="next"
          disabled={start === lastStart}
          onClick={() => setStart((current) => clampStart(current + 1))}
        />
      </ChatMinimap>
    </div>
  );
}
$ bunx --bun shadcn@latest add @notra/chat-minimap

Each line is a shadcn Button holding an SVG. The lines share one length at rest. The hovered line grows the most and its neighbours grow less the further away they are. Hovering a line opens a shadcn HoverCard with a Card inside. The up and down buttons stay hidden until they are hovered or focused, show all the time on touch screens, and use a shadcn Tooltip.

<ChatMinimap side="right">
  <ChatMinimapNavButton direction="previous" onClick={previous} />
  {turns.map((turn, index) => (
    <ChatMinimapItem
      active={index === activeIndex}
      description={turn.preview}
      key={turn.id}
      onClick={() => scrollToTurn(index)}
      title={turn.title}
    />
  ))}
  <ChatMinimapNavButton direction="next" onClick={next} />
</ChatMinimap>
Component Prop Default Description
ChatMinimap side "right" Side the preview card opens on. "left" also aligns the lines to the right.
ChatMinimapItem title Card title and the line’s accessible name.
ChatMinimapItem description Card text, clamped to two lines.
ChatMinimapItem active false Highlights the line and sets data-active.
ChatMinimapNavButton direction "previous" or "next".
ChatMinimapNavButton label "Previous turn" or "Next turn" Tooltip text and aria-label.

Left side

Set side="left" to put the rail on the right edge of a thread. The lines line up on the right and the card opens to the left.

import ChatMinimapExample from "./chat-minimap";

export default function ChatMinimapLeftExample() {
  return <ChatMinimapExample side="left" />;
}

Long threads

The rail stays compact on long conversations. This one has 50 turns.

import { CHAT_MINIMAP_LONG_TURNS } from "../constants/chat-minimap";
import ChatMinimapExample from "./chat-minimap";

export default function ChatMinimapLongExample() {
  return (
    <ChatMinimapExample initialStart={20} turns={CHAT_MINIMAP_LONG_TURNS} />
  );
}

Scroll spy

Lines light up for every turn in view as the thread scrolls. useChatMinimapScrollSpy watches the turns with an IntersectionObserver and returns the visible indexes:

const { rootRef, scrollToTurn, turnRef, visible } = useChatMinimapScrollSpy(turns.length);

<div className="overflow-y-auto" ref={rootRef}>
  {turns.map((turn, index) => (
    <section key={turn.id} ref={turnRef(index)}>…</section>
  ))}
</div>

<ChatMinimapItem active={visible.has(index)} onClick={() => scrollToTurn(index)} … />