UI

Section Rail

A responsive section navigation rail with scroll tracking, smooth anchor jumps, link mode, and optional rich hover previews.

Installation

Section Rail is published as a shadcn registry item. The CLI drops the file into your project, installs its dependencies, and adds any primitives it relies on.

$npx shadcn@latest add @akoder/section-rail

The @akoder namespace ships with the shadcn CLI, so there is nothing to configure first. Install any other component the same way from the Components page.

Usage

Give each rail item the id of its target section. Without an href, the rail follows the reading position and scrolls to sections when selected.

"use client";
 
import { SectionRail, type RailItem } from "@/components/section-rail";
 
const items: RailItem[] = [
  { id: "overview", label: "overview" },
  { id: "installation", label: "installation" },
  { id: "api", label: "api" },
];
 
export function Article() {
  return (
    <>
      <SectionRail items={items} />
      <article>
        <section id="overview">...</section>
        <section id="installation">...</section>
        <section id="api">...</section>
      </article>
    </>
  );
}

Contained scroll areas

Use the contained variant for a dialog, drawer, or overflow panel. The rail stays attached to the surrounding relative container while observing and scrolling the referenced element.

"use client";
 
import { useRef } from "react";
import ReactMarkdown from "react-markdown";
import { SectionRail } from "@/components/section-rail";
 
const sections = [
  { id: "intro", label: "intro", body: "## Introduction\n\nStart here." },
  { id: "details", label: "details", body: "## Details\n\nKeep reading." },
];
 
export function MarkdownGuide() {
  const scrollRootRef = useRef<HTMLDivElement>(null);
 
  return (
    <div className="relative">
      <SectionRail
        items={sections}
        variant="contained"
        scrollRootRef={scrollRootRef}
      />
      <div ref={scrollRootRef} className="h-96 overflow-y-auto pl-20">
        {sections.map((section) => (
          <section key={section.id} id={section.id} className="min-h-96">
            <ReactMarkdown>{section.body}</ReactMarkdown>
          </section>
        ))}
      </div>
    </div>
  );
}

Link rail

Items with an href navigate instead of scrolling. Pass activeId to pin the current page.

<SectionRail
  activeId="guides"
  items={[
    { id: "home", label: "home", href: "/" },
    { id: "guides", label: "guides", href: "/guides" },
  ]}
/>

Props

PropTypeDefaultNotes
items RequiredRailItem[]-Ordered marks. Each item needs id and label; add href for navigation or card for a rich tooltip.
activeIdstring—Pins the active mark. Most useful for link rails.
variant"viewport" | "contained""viewport"Viewport rails are fixed and appear at the lg breakpoint; contained rails are absolutely positioned and always visible.
scrollRootRefRefObject<HTMLElement | null>—Overflow element that owns the target sections in contained mode.
classNamestring—Additional classes for positioning or styling the rail.

RailItem

FieldTypeNotes
idstringUnique item key and the target element ID for scroll items.
labelstringAccessible name and default tooltip copy.
hrefstringTurns the mark into a Next.js link.
card{ title; description?; icon? }Replaces the plain tooltip with a rich preview card.

Notes & features

  • Scroll tracking. An intersection observer selects the target nearest the center reading band.
  • Reliable final section. Reaching the bottom of the viewport or contained scroller activates the last item even when it is short.
  • Reduced motion. Section jumps honor prefers-reduced-motion.
  • Accessible controls. Scroll marks are buttons, links stay links, and aria-current identifies the active destination.
  • Stable targets. IDs must be unique in the document. When scrollRootRef is provided, targets outside that element are ignored.
  • Tooltip setup. Mount shadcn's TooltipProvider once near the root of your application.

Setup notes

The source expects the standard @/lib/utils cn helper. Link mode uses the Next.js Link component: outside Next.js, swap it for an anchor or your router's own link.

Manual installation

Rather not use the CLI? Everything Section Rail needs is below. Install its dependencies, then copy the file into the matching path in your project.

Dependencies

$npm install radix-ui

shadcn primitives

$npx shadcn@latest add tooltip

Source

components/section-rail.tsx276 lines
"use client";

import Link from "next/link";
import {
  useEffect,
  useMemo,
  useState,
  type ReactNode,
  type RefObject,
} from "react";

import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";

export type RailItem = {
  id: string;
  label: string;
  /** With an href the item navigates to another page instead of scrolling. */
  href?: string;
  /** Rich hover preview; without it the tooltip shows the plain label. */
  card?: {
    title: string;
    description?: string;
    icon?: ReactNode;
  };
};

export type SectionRailProps = {
  items: RailItem[];
  /** Pin the active item (link rails). Omit it to follow scroll position. */
  activeId?: string;
  /** Use contained mode when the rail belongs to an embedded scroll area. */
  variant?: "viewport" | "contained";
  /** Scroll area that owns the target sections in contained mode. */
  scrollRootRef?: RefObject<HTMLElement | null>;
  /** Optional callback fired when a section rail item is clicked. */
  onSectionClick?: (targetSectionId: string, currentSectionId?: string) => void;
  className?: string;
};

const WAVE_WIDTHS = ["w-14", "w-11", "w-9", "w-7"];

export function SectionRail({
  items,
  activeId,
  variant = "viewport",
  scrollRootRef,
  onSectionClick,
  className,
}: SectionRailProps) {
  const [tracked, setTracked] = useState(() => items[0]?.id ?? "");
  const active = activeId ?? tracked;

  const scrollIds = useMemo(
    () => items.filter((item) => !item.href).map((item) => item.id),
    [items]
  );

  useEffect(() => {
    if (scrollIds.length === 0) return;

    const startTracking = () => {
      const scrollRoot = scrollRootRef?.current ?? null;
      const findTarget = (id: string) => {
        const target = document.getElementById(id);
        if (!target) return null;
        return scrollRoot && !scrollRoot.contains(target) ? null : target;
      };

      // Track every target currently inside the center band and keep the one
      // closest to its midpoint active: deterministic even when several small
      // targets intersect at once.
      const visible = new Map<string, number>();
      const pickClosest = () => {
        const rootTop = scrollRoot?.getBoundingClientRect().top ?? 0;
        const rootHeight = scrollRoot?.clientHeight ?? window.innerHeight;
        const bandCenter = rootTop + rootHeight * 0.4;
        let best: string | null = null;
        let bestDistance = Number.POSITIVE_INFINITY;
        for (const [id, top] of visible) {
          const distance = Math.abs(top - bandCenter);
          if (distance < bestDistance) {
            best = id;
            bestDistance = distance;
          }
        }
        if (best) setTracked(best);
      };

      const observer = new IntersectionObserver(
        (entries) => {
          for (const entry of entries) {
            if (entry.isIntersecting) {
              visible.set(entry.target.id, entry.boundingClientRect.top);
            } else {
              visible.delete(entry.target.id);
            }
          }
          pickClosest();
        },
        {
          root: scrollRoot,
          rootMargin: "-25% 0px -45% 0px",
          threshold: 0,
        }
      );

      for (const id of scrollIds) {
        const element = findTarget(id);
        if (element) observer.observe(element);
      }

      // The last section can be too short to cross the center band at max scroll.
      const onScroll = () => {
        const remaining = scrollRoot
          ? scrollRoot.scrollHeight - scrollRoot.scrollTop - scrollRoot.clientHeight
          : document.documentElement.scrollHeight -
            window.scrollY -
            window.innerHeight;
        if (remaining < 4) setTracked(scrollIds[scrollIds.length - 1]);
      };
      const scrollSource: Window | HTMLElement = scrollRoot ?? window;
      scrollSource.addEventListener("scroll", onScroll, { passive: true });

      return () => {
        observer.disconnect();
        scrollSource.removeEventListener("scroll", onScroll);
      };
    };

    // Contained rails are always visible and always track. The viewport rail
    // is display:none below the lg breakpoint, so its observer and scroll
    // listener stay parked until the breakpoint shows the rail.
    if (variant !== "viewport") return startTracking();

    const query = window.matchMedia("(min-width: 64rem)");
    let teardown: (() => void) | null = null;
    const syncTracking = () => {
      if (query.matches && !teardown) {
        teardown = startTracking();
      } else if (!query.matches && teardown) {
        teardown();
        teardown = null;
      }
    };
    syncTracking();
    query.addEventListener("change", syncTracking);

    return () => {
      query.removeEventListener("change", syncTracking);
      teardown?.();
    };
  }, [scrollIds, scrollRootRef, variant]);

  const scrollTo = (id: string) => {
    const scrollRoot = scrollRootRef?.current ?? null;
    const element = document.getElementById(id);
    if (!element || (scrollRoot && !scrollRoot.contains(element))) return;

    onSectionClick?.(id, active);

    setTracked(id);
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const behavior = reduced ? "auto" : "smooth";

    if (scrollRoot) {
      const rootRect = scrollRoot.getBoundingClientRect();
      const targetRect = element.getBoundingClientRect();
      scrollRoot.scrollTo({
        top: scrollRoot.scrollTop + targetRect.top - rootRect.top,
        behavior,
      });
      return;
    }

    element.scrollIntoView({ behavior, block: "start" });
  };

  const foundActiveIndex = items.findIndex((item) => item.id === active);
  const activeIndex = foundActiveIndex === -1 ? 0 : foundActiveIndex;

  return (
    <nav
      aria-label="Sections"
      className={cn(
        "top-1/2 left-0 z-30 -translate-y-1/2 flex-col items-start gap-2",
        variant === "contained" ? "absolute flex" : "fixed hidden lg:flex",
        className
      )}
    >
      <div
        aria-hidden
        className="absolute inset-y-1 left-0 border-l border-dashed border-border/60"
      />
      {items.map((item, index) => {
        const isActive = active === item.id;
        const distanceFromActive = Math.abs(activeIndex - index);
        // The dash stays 2px tall; a 14px row around it (offset by -my-1.5 so
        // the visible gap is unchanged) is what you actually click.
        const rowClass = cn(
          "group -my-1.5 flex cursor-pointer items-center py-1.5 transition-[width] duration-300 ease-out",
          "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
          "motion-reduce:transition-none",
          WAVE_WIDTHS[Math.min(distanceFromActive, WAVE_WIDTHS.length - 1)]
        );
        const dashClass = cn(
          "block h-0.5 w-full rounded-full transition-colors duration-300 ease-out motion-reduce:transition-none",
          isActive
            ? "bg-foreground"
            : "bg-muted-foreground/40 group-hover:bg-muted-foreground"
        );
        return (
          <Tooltip key={item.id}>
            <TooltipTrigger asChild>
              {item.href ? (
                <Link
                  href={item.href}
                  aria-label={item.label}
                  aria-current={isActive ? "page" : undefined}
                  className={rowClass}
                >
                  <span aria-hidden className={dashClass} />
                </Link>
              ) : (
                <button
                  type="button"
                  aria-label={item.label}
                  aria-current={isActive ? "location" : undefined}
                  onClick={() => scrollTo(item.id)}
                  className={rowClass}
                >
                  <span aria-hidden className={dashClass} />
                </button>
              )}
            </TooltipTrigger>
            <TooltipContent
              side="right"
              sideOffset={10}
              hideArrow={Boolean(item.card)}
              className={cn(
                item.card &&
                  "block w-60 rounded-xl bg-card p-3 text-card-foreground ring-1 ring-inset ring-foreground/10"
              )}
            >
              {item.card ? (
                <div className="text-left">
                  <div className="flex items-center gap-1">
                    {item.card.icon ? (
                      <span className="flex size-7 shrink-0 items-center justify-center rounded-sm bg-background text-muted-foreground ring-1 ring-inset ring-muted-foreground/10">
                        {item.card.icon}
                      </span>
                    ) : null}
                    <p className="text-sm font-medium leading-snug">
                      {item.card.title}
                    </p>
                  </div>
                  {item.card.description ? (
                    <p className="mt-2 line-clamp-3 text-sm leading-relaxed text-muted-foreground">
                      {item.card.description}
                    </p>
                  ) : null}
                </div>
              ) : (
                <p>{item.label}</p>
              )}
            </TooltipContent>
          </Tooltip>
        );
      })}
    </nav>
  );
}