Dice UI
Components

Scroll Spy

Automatically updates navigation links based on scroll position with support for nested sections and customizable behavior.

API
"use client";
 
import * as React from "react";
import {
  ScrollSpy,
  ScrollSpyLink,
  ScrollSpyNav,
  ScrollSpySection,
  ScrollSpyViewport,
} from "@/components/ui/scroll-spy";
 
export function ScrollSpyDemo() {
  const [scrollContainer, setScrollContainer] =
    React.useState<HTMLDivElement | null>(null);
 
  return (
    <ScrollSpy
      offset={16}
      scrollContainer={scrollContainer}
      className="h-[400px] w-full border"
    >
      <ScrollSpyNav className="w-40 border-r p-4">
        <ScrollSpyLink value="introduction">Introduction</ScrollSpyLink>
        <ScrollSpyLink value="getting-started">Getting Started</ScrollSpyLink>
        <ScrollSpyLink value="usage">Usage</ScrollSpyLink>
        <ScrollSpyLink value="api-reference">API Reference</ScrollSpyLink>
      </ScrollSpyNav>
      <ScrollSpyViewport
        ref={setScrollContainer}
        className="overflow-y-auto p-4"
      >
        <ScrollSpySection value="introduction">
          <h2 className="font-bold text-2xl">Introduction</h2>
          <p className="mt-2 text-muted-foreground">
            ScrollSpy automatically updates navigation links based on scroll
            position.
          </p>
          <div className="mt-4 h-64 rounded-lg bg-accent" />
        </ScrollSpySection>
        <ScrollSpySection value="getting-started">
          <h2 className="font-bold text-2xl">Getting Started</h2>
          <p className="mt-2 text-muted-foreground">
            Install the component using the CLI or copy the source code.
          </p>
          <div className="mt-4 h-64 rounded-lg bg-accent" />
        </ScrollSpySection>
        <ScrollSpySection value="usage">
          <h2 className="font-bold text-2xl">Usage</h2>
          <p className="mt-2 text-muted-foreground">
            Use the Provider, Root, Link, and Section components to create your
            scroll spy navigation.
          </p>
          <div className="mt-4 h-64 rounded-lg bg-accent" />
        </ScrollSpySection>
        <ScrollSpySection value="api-reference">
          <h2 className="font-bold text-2xl">API Reference</h2>
          <p className="mt-2 text-muted-foreground">
            Complete API documentation for all ScrollSpy components.
          </p>
          <div className="mt-4 h-64 rounded-lg bg-accent" />
        </ScrollSpySection>
      </ScrollSpyViewport>
    </ScrollSpy>
  );
}

Installation

CLI

npx shadcn@latest add @diceui/scroll-spy

Manual

Install the following dependencies:

npm install @radix-ui/react-slot

Copy and paste the refs composition utilities into your lib/compose-refs.ts file.

/**
 * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
 */
 
import * as React from "react";
 
type PossibleRef<T> = React.Ref<T> | undefined;
 
/**
 * Set a given ref to a given value
 * This utility takes care of different types of refs: callback refs and RefObject(s)
 */
function setRef<T>(ref: PossibleRef<T>, value: T) {
  if (typeof ref === "function") {
    return ref(value);
  }
 
  if (ref !== null && ref !== undefined) {
    ref.current = value;
  }
}
 
/**
 * A utility to compose multiple refs together
 * Accepts callback refs and RefObject(s)
 */
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  return (node) => {
    let hasCleanup = false;
    const cleanups = refs.map((ref) => {
      const cleanup = setRef(ref, node);
      if (!hasCleanup && typeof cleanup === "function") {
        hasCleanup = true;
      }
      return cleanup;
    });
 
    // React <19 will log an error to the console if a callback ref returns a
    // value. We don't use ref cleanups internally so this will only happen if a
    // user's ref callback returns a value, which we only expect if they are
    // using the cleanup functionality added in React 19.
    if (hasCleanup) {
      return () => {
        for (let i = 0; i < cleanups.length; i++) {
          const cleanup = cleanups[i];
          if (typeof cleanup === "function") {
            cleanup();
          } else {
            setRef(refs[i], null);
          }
        }
      };
    }
  };
}
 
/**
 * A custom hook that composes multiple refs
 * Accepts callback refs and RefObject(s)
 */
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
  return React.useCallback(composeRefs(...refs), refs);
}
 
export { composeRefs, useComposedRefs };

Copy and paste the following hooks into your hooks directory.

import * as React from "react";
 
const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
 
export { useIsomorphicLayoutEffect };
import * as React from "react";
 
function useLazyRef<T>(fn: () => T) {
  const ref = React.useRef<T | null>(null);
 
  if (ref.current === null) {
    ref.current = fn();
  }
 
  return ref as React.RefObject<T>;
}
 
export { useLazyRef };
import * as React from "react";
 
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
 
function useAsRef<T>(props: T) {
  const ref = React.useRef<T>(props);
 
  useIsomorphicLayoutEffect(() => {
    ref.current = props;
  });
 
  return ref;
}
 
export { useAsRef };

Copy and paste the following code into your project.

"use client";
 
import { useDirection } from "@radix-ui/react-direction";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/components/hooks/use-lazy-ref";
 
const ROOT_NAME = "ScrollSpy";
const NAV_NAME = "ScrollSpyNav";
const LINK_NAME = "ScrollSpyLink";
const VIEWPORT_NAME = "ScrollSpyViewport";
const SECTION_NAME = "ScrollSpySection";
 
type Direction = "ltr" | "rtl";
type Orientation = "horizontal" | "vertical";
 
type LinkElement = React.ComponentRef<typeof ScrollSpyLink>;
type SectionElement = React.ComponentRef<typeof ScrollSpySection>;
 
function getDefaultScrollBehavior(): ScrollBehavior {
  if (typeof window === "undefined") return "smooth";
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches
    ? "auto"
    : "smooth";
}
 
interface StoreState {
  value: string;
}
 
interface Store {
  subscribe: (callback: () => void) => () => void;
  getState: () => StoreState;
  setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
  notify: () => void;
}
 
const StoreContext = React.createContext<Store | null>(null);
 
function useStore<T>(
  selector: (state: StoreState) => T,
  ogStore?: Store | null,
): T {
  const contextStore = React.useContext(StoreContext);
 
  const store = ogStore ?? contextStore;
 
  if (!store) {
    throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``);
  }
 
  const getSnapshot = React.useCallback(
    () => selector(store.getState()),
    [store, selector],
  );
 
  return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
 
interface ScrollSpyContextValue {
  offset: number;
  scrollBehavior: ScrollBehavior;
  dir: Direction;
  orientation: Orientation;
  scrollContainer: HTMLElement | null;
  isScrollingRef: React.RefObject<boolean>;
  onSectionRegister: (id: string, element: SectionElement) => void;
  onSectionUnregister: (id: string) => void;
  onScrollToSection: (sectionId: string) => void;
}
 
const ScrollSpyContext = React.createContext<ScrollSpyContextValue | null>(
  null,
);
 
function useScrollSpyContext(consumerName: string) {
  const context = React.useContext(ScrollSpyContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}
 
interface ScrollSpyProps extends React.ComponentProps<"div"> {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  rootMargin?: string;
  threshold?: number | number[];
  offset?: number;
  scrollBehavior?: ScrollBehavior;
  scrollContainer?: HTMLElement | null;
  dir?: Direction;
  orientation?: Orientation;
  asChild?: boolean;
}
 
function ScrollSpy(props: ScrollSpyProps) {
  const {
    value,
    defaultValue,
    onValueChange,
    rootMargin,
    threshold = 0.1,
    offset = 0,
    scrollBehavior = getDefaultScrollBehavior(),
    scrollContainer = null,
    dir: dirProp,
    orientation = "horizontal",
    asChild,
    className,
    ...rootProps
  } = props;
 
  const dir = useDirection(dirProp);
 
  const stateRef = useLazyRef<StoreState>(() => ({
    value: value ?? defaultValue ?? "",
  }));
  const listenersRef = useLazyRef(() => new Set<() => void>());
  const onValueChangeRef = useAsRef(onValueChange);
 
  const store = React.useMemo<Store>(() => {
    return {
      subscribe: (cb) => {
        listenersRef.current.add(cb);
        return () => listenersRef.current.delete(cb);
      },
      getState: () => {
        return stateRef.current;
      },
      setState: (key, value) => {
        if (Object.is(stateRef.current[key], value)) return;
 
        stateRef.current[key] = value;
 
        if (key === "value" && value) {
          onValueChangeRef.current?.(value);
        }
 
        store.notify();
      },
      notify: () => {
        for (const cb of listenersRef.current) {
          cb();
        }
      },
    };
  }, [listenersRef, stateRef, onValueChangeRef]);
 
  const sectionMapRef = React.useRef(new Map<string, Element>());
  const isScrollingRef = React.useRef(false);
  const rafIdRef = React.useRef<number | null>(null);
  const isMountedRef = React.useRef(false);
  const scrollTimeoutRef = React.useRef<number | null>(null);
 
  const onSectionRegister = React.useCallback(
    (id: string, element: SectionElement) => {
      sectionMapRef.current.set(id, element);
    },
    [],
  );
 
  const onSectionUnregister = React.useCallback((id: string) => {
    sectionMapRef.current.delete(id);
  }, []);
 
  const onScrollToSection = React.useCallback(
    (sectionId: string) => {
      const section = scrollContainer
        ? scrollContainer.querySelector(`#${sectionId}`)
        : document.getElementById(sectionId);
 
      if (!section) {
        store.setState("value", sectionId);
        return;
      }
 
      // Set flag to prevent observer from firing during programmatic scroll
      isScrollingRef.current = true;
      store.setState("value", sectionId);
 
      if (scrollContainer) {
        const containerRect = scrollContainer.getBoundingClientRect();
        const sectionRect = section.getBoundingClientRect();
        const scrollTop = scrollContainer.scrollTop;
        const offsetPosition =
          sectionRect.top - containerRect.top + scrollTop - offset;
 
        scrollContainer.scrollTo({
          top: offsetPosition,
          behavior: scrollBehavior,
        });
      } else {
        const sectionPosition = section.getBoundingClientRect().top;
        const offsetPosition = sectionPosition + window.scrollY - offset;
 
        window.scrollTo({
          top: offsetPosition,
          behavior: scrollBehavior,
        });
      }
 
      if (scrollTimeoutRef.current !== null) {
        clearTimeout(scrollTimeoutRef.current);
      }
 
      scrollTimeoutRef.current = window.setTimeout(() => {
        isScrollingRef.current = false;
      }, 500);
    },
    [scrollContainer, offset, scrollBehavior, store],
  );
 
  useIsomorphicLayoutEffect(() => {
    const currentValue = value ?? defaultValue;
    if (currentValue === undefined) return;
 
    if (!isMountedRef.current) {
      isMountedRef.current = true;
      store.setState("value", currentValue);
      return;
    }
 
    onScrollToSection(currentValue);
  }, [value, onScrollToSection]);
 
  useIsomorphicLayoutEffect(() => {
    const sectionMap = sectionMapRef.current;
    if (sectionMap.size === 0) return;
 
    const observerRootMargin = rootMargin ?? `${-offset}px 0px -70% 0px`;
 
    const observer = new IntersectionObserver(
      (entries) => {
        if (isScrollingRef.current) return;
 
        if (rafIdRef.current !== null) {
          cancelAnimationFrame(rafIdRef.current);
        }
 
        rafIdRef.current = requestAnimationFrame(() => {
          const intersecting = entries.filter((entry) => entry.isIntersecting);
 
          if (intersecting.length === 0) return;
 
          const topmost = intersecting.reduce((prev, curr) => {
            return curr.boundingClientRect.top < prev.boundingClientRect.top
              ? curr
              : prev;
          });
 
          const id = topmost.target.id;
          if (id && sectionMap.has(id)) {
            store.setState("value", id);
          }
        });
      },
      {
        root: scrollContainer,
        rootMargin: observerRootMargin,
        threshold,
      },
    );
 
    for (const element of sectionMap.values()) {
      observer.observe(element);
    }
 
    return () => {
      observer.disconnect();
      if (rafIdRef.current !== null) {
        cancelAnimationFrame(rafIdRef.current);
      }
      if (scrollTimeoutRef.current !== null) {
        clearTimeout(scrollTimeoutRef.current);
      }
    };
  }, [offset, rootMargin, threshold, scrollContainer]);
 
  const contextValue = React.useMemo<ScrollSpyContextValue>(
    () => ({
      dir,
      orientation,
      offset,
      scrollBehavior,
      scrollContainer,
      isScrollingRef,
      onSectionRegister,
      onSectionUnregister,
      onScrollToSection,
    }),
    [
      dir,
      orientation,
      offset,
      scrollBehavior,
      scrollContainer,
      onSectionRegister,
      onSectionUnregister,
      onScrollToSection,
    ],
  );
 
  const RootPrimitive = asChild ? Slot : "div";
 
  return (
    <StoreContext.Provider value={store}>
      <ScrollSpyContext.Provider value={contextValue}>
        <RootPrimitive
          data-orientation={orientation}
          data-slot="scroll-spy"
          dir={dir}
          {...rootProps}
          className={cn(
            "flex",
            orientation === "horizontal" ? "flex-row" : "flex-col",
            className,
          )}
        />
      </ScrollSpyContext.Provider>
    </StoreContext.Provider>
  );
}
 
interface ScrollSpyNavProps extends React.ComponentProps<"nav"> {
  asChild?: boolean;
}
 
function ScrollSpyNav(props: ScrollSpyNavProps) {
  const { asChild, className, ...navProps } = props;
 
  const { dir, orientation } = useScrollSpyContext(NAV_NAME);
 
  const NavPrimitive = asChild ? Slot : "nav";
 
  return (
    <NavPrimitive
      data-orientation={orientation}
      data-slot="scroll-spy-nav"
      dir={dir}
      {...navProps}
      className={cn(
        "flex gap-2",
        orientation === "horizontal" ? "flex-col" : "flex-row",
        className,
      )}
    />
  );
}
 
interface ScrollSpyLinkProps extends React.ComponentProps<"a"> {
  value: string;
  asChild?: boolean;
}
 
function ScrollSpyLink(props: ScrollSpyLinkProps) {
  const { value: linkValue, asChild, onClick, className, ...linkProps } = props;
 
  const { orientation, onScrollToSection } = useScrollSpyContext(LINK_NAME);
  const value = useStore((state) => state.value);
  const isActive = value === linkValue;
 
  const onLinkClick = React.useCallback(
    (event: React.MouseEvent<LinkElement>) => {
      event.preventDefault();
      onClick?.(event);
      onScrollToSection(linkValue);
    },
    [linkValue, onClick, onScrollToSection],
  );
 
  const LinkPrimitive = asChild ? Slot : "a";
 
  return (
    <LinkPrimitive
      data-orientation={orientation}
      data-slot="scroll-spy-link"
      data-state={isActive ? "active" : "inactive"}
      {...linkProps}
      href={asChild ? undefined : `#${linkValue}`}
      className={cn(
        "rounded px-3 py-1.5 font-medium text-muted-foreground text-sm transition-colors hover:bg-accent hover:text-accent-foreground data-[state=active]:bg-accent data-[state=active]:text-foreground",
        className,
      )}
      onClick={onLinkClick}
    />
  );
}
 
interface ScrollSpyViewportProps extends React.ComponentProps<"div"> {
  asChild?: boolean;
}
 
function ScrollSpyViewport(props: ScrollSpyViewportProps) {
  const { asChild, className, ...viewportProps } = props;
 
  const { dir, orientation } = useScrollSpyContext(VIEWPORT_NAME);
 
  const ViewportPrimitive = asChild ? Slot : "div";
 
  return (
    <ViewportPrimitive
      data-orientation={orientation}
      data-slot="scroll-spy-viewport"
      dir={dir}
      {...viewportProps}
      className={cn("flex flex-1 flex-col gap-8", className)}
    />
  );
}
 
interface ScrollSpySectionProps extends React.ComponentProps<"div"> {
  value: string;
  asChild?: boolean;
}
 
function ScrollSpySection(props: ScrollSpySectionProps) {
  const { asChild, ref, value, ...sectionProps } = props;
 
  const { orientation, onSectionRegister, onSectionUnregister } =
    useScrollSpyContext(SECTION_NAME);
  const sectionRef = React.useRef<SectionElement>(null);
  const composedRef = useComposedRefs(ref, sectionRef);
 
  useIsomorphicLayoutEffect(() => {
    const element = sectionRef.current;
    if (!element || !value) return;
 
    onSectionRegister(value, element);
 
    return () => {
      onSectionUnregister(value);
    };
  }, [value, onSectionRegister, onSectionUnregister]);
 
  const SectionPrimitive = asChild ? Slot : "div";
 
  return (
    <SectionPrimitive
      data-orientation={orientation}
      data-slot="scroll-spy-section"
      {...sectionProps}
      id={value}
      ref={composedRef}
    />
  );
}
 
export {
  ScrollSpy,
  ScrollSpyLink,
  ScrollSpyNav,
  ScrollSpySection,
  ScrollSpyViewport,
  //
  type ScrollSpyProps,
};

Update the import paths to match your project setup.

Layout

Import the parts, and compose them together.

import {
  ScrollSpy,
  ScrollSpyNav,
  ScrollSpyLink,
  ScrollSpyViewport,
  ScrollSpySection,
} from "@/components/ui/scroll-spy";

return (
  <ScrollSpy>
    <ScrollSpyNav>
      <ScrollSpyLink />
    </ScrollSpyNav>
    <ScrollSpyViewport>
      <ScrollSpySection />
    </ScrollSpyViewport>
  </ScrollSpy>
)

Examples

Vertical Orientation

Set orientation="vertical" for content with vertical navigation.

"use client";
 
import * as React from "react";
import {
  ScrollSpy,
  ScrollSpyLink,
  ScrollSpyNav,
  ScrollSpySection,
  ScrollSpyViewport,
} from "@/components/ui/scroll-spy";
 
export function ScrollSpyVerticalDemo() {
  const [scrollContainer, setScrollContainer] =
    React.useState<HTMLDivElement | null>(null);
 
  return (
    <ScrollSpy
      offset={10}
      orientation="vertical"
      scrollContainer={scrollContainer}
      className="h-[400px] w-full border"
    >
      <ScrollSpyNav className="border-b p-4">
        <ScrollSpyLink value="overview">Overview</ScrollSpyLink>
        <ScrollSpyLink value="features">Features</ScrollSpyLink>
        <ScrollSpyLink value="installation">Installation</ScrollSpyLink>
        <ScrollSpyLink value="examples">Examples</ScrollSpyLink>
        <ScrollSpyLink value="api">API</ScrollSpyLink>
      </ScrollSpyNav>
 
      <ScrollSpyViewport
        ref={setScrollContainer}
        className="overflow-y-auto p-4"
      >
        <ScrollSpySection value="overview" className="min-w-[400px]">
          <h2 className="font-bold text-2xl">Overview</h2>
          <p className="mt-2 text-muted-foreground">
            ScrollSpy with horizontal orientation for side-scrolling content.
          </p>
          <div className="mt-4 h-64 rounded-lg bg-accent" />
        </ScrollSpySection>
 
        <ScrollSpySection value="features" className="min-w-[400px]">
          <h2 className="font-bold text-2xl">Features</h2>
          <p className="mt-2 text-muted-foreground">
            All the features available in this component.
          </p>
          <div className="mt-4 h-64 rounded-lg bg-accent" />
        </ScrollSpySection>
 
        <ScrollSpySection value="installation" className="min-w-[400px]">
          <h2 className="font-bold text-2xl">Installation</h2>
          <p className="mt-2 text-muted-foreground">
            How to install and set up the component.
          </p>
          <div className="mt-4 h-64 rounded-lg bg-accent" />
        </ScrollSpySection>
 
        <ScrollSpySection value="examples" className="min-w-[400px]">
          <h2 className="font-bold text-2xl">Examples</h2>
          <p className="mt-2 text-muted-foreground">
            Various examples showing different use cases.
          </p>
          <div className="mt-4 h-64 rounded-lg bg-accent" />
        </ScrollSpySection>
 
        <ScrollSpySection value="api" className="min-w-[400px]">
          <h2 className="font-bold text-2xl">API Reference</h2>
          <p className="mt-2 text-muted-foreground">
            Complete API documentation for all components.
          </p>
          <div className="mt-4 h-64 rounded-lg bg-accent" />
        </ScrollSpySection>
      </ScrollSpyViewport>
    </ScrollSpy>
  );
}

Controlled State

Use the value and onValueChange props to control the active section externally.

"use client";
 
import * as React from "react";
import {
  ScrollSpy,
  ScrollSpyLink,
  ScrollSpyNav,
  ScrollSpySection,
  ScrollSpyViewport,
} from "@/components/ui/scroll-spy";
 
export function ScrollSpyControlledDemo() {
  const [scrollContainer, setScrollContainer] =
    React.useState<HTMLDivElement | null>(null);
  const [value, setValue] = React.useState("getting-started");
 
  return (
    <ScrollSpy
      offset={16}
      scrollContainer={scrollContainer}
      value={value}
      onValueChange={setValue}
      defaultValue="getting-started"
      className="h-[400px] w-full border"
    >
      <ScrollSpyNav className="w-40 border-r p-4">
        <ScrollSpyLink value="introduction">Introduction</ScrollSpyLink>
        <ScrollSpyLink value="getting-started">Getting Started</ScrollSpyLink>
        <ScrollSpyLink value="usage">Usage</ScrollSpyLink>
        <ScrollSpyLink value="api-reference">API Reference</ScrollSpyLink>
      </ScrollSpyNav>
      <ScrollSpyViewport
        ref={setScrollContainer}
        className="overflow-y-auto p-4"
      >
        <ScrollSpySection value="introduction">
          <h2 className="font-bold text-2xl">Introduction</h2>
          <p className="mt-2 text-muted-foreground">
            ScrollSpy automatically updates navigation links based on scroll
            position.
          </p>
          <div className="mt-4 h-64 rounded-lg bg-accent" />
        </ScrollSpySection>
        <ScrollSpySection value="getting-started">
          <h2 className="font-bold text-2xl">Getting Started</h2>
          <p className="mt-2 text-muted-foreground">
            Install the component using the CLI or copy the source code.
          </p>
          <div className="mt-4 h-64 rounded-lg bg-accent" />
        </ScrollSpySection>
        <ScrollSpySection value="usage">
          <h2 className="font-bold text-2xl">Usage</h2>
          <p className="mt-2 text-muted-foreground">
            Use the Provider, Root, Link, and Section components to create your
            scroll spy navigation.
          </p>
          <div className="mt-4 h-64 rounded-lg bg-accent" />
        </ScrollSpySection>
        <ScrollSpySection value="api-reference">
          <h2 className="font-bold text-2xl">API Reference</h2>
          <p className="mt-2 text-muted-foreground">
            Complete API documentation for all ScrollSpy components.
          </p>
          <div className="mt-4 h-64 rounded-lg bg-accent" />
        </ScrollSpySection>
      </ScrollSpyViewport>
    </ScrollSpy>
  );
}

Sticky Layout

For full-page scroll behavior, you can use a sticky positioned navigation sidebar that stays fixed while the content scrolls. This works with the default window scroll (no scrollContainer prop needed).

<ScrollSpy offset={100}>
  <ScrollSpyNav className="sticky top-20 h-fit">
    <ScrollSpyLink value="introduction">Introduction</ScrollSpyLink>
    <ScrollSpyLink value="getting-started">Getting Started</ScrollSpyLink>
    <ScrollSpyLink value="usage">Usage</ScrollSpyLink>
    <ScrollSpyLink value="api-reference">API Reference</ScrollSpyLink>
  </ScrollSpyNav>

  <ScrollSpyViewport>
    <ScrollSpySection value="introduction">
      <h2>Introduction</h2>
      <p>Your content here...</p>
    </ScrollSpySection>
    
    <ScrollSpySection value="getting-started">
      <h2>Getting Started</h2>
      <p>Your content here...</p>
    </ScrollSpySection>
    
    {/* More content sections */}
  </ScrollSpyViewport>
</ScrollSpy>

The key is to apply sticky top-[offset] to the ScrollSpyNav to keep the navigation visible as the page scrolls.

API Reference

ScrollSpy

The root component that manages scroll tracking and contains all ScrollSpy parts.

Prop

Type

Data AttributeValue
[data-orientation]"horizontal" | "vertical"

The navigation container component.

Prop

Type

Data AttributeValue
[data-orientation]"horizontal" | "vertical"

Navigation link that scrolls to a section.

Prop

Type

Data AttributeValue
[data-orientation]"horizontal" | "vertical"
[data-state]"active" | "inactive"

Viewport

The viewport container component for sections.

Prop

Type

Data AttributeValue
[data-orientation]"horizontal" | "vertical"

Section

Content section that gets tracked by the scroll spy.

Prop

Type

Data AttributeValue
[data-orientation]"horizontal" | "vertical"

Accessibility

Keyboard Shortcuts

The ScrollSpy component follows standard link navigation patterns:

KeyDescription
TabMove focus between navigation items.
EnterActivate the focused navigation item and scroll to the associated section.
SpaceActivate the focused navigation item and scroll to the associated section.

On this page