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 "https://diceui.com/r/scroll-spy"

Manual

Install the following dependencies:

npm install @radix-ui/react-slot

Copy 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 code into your project.

"use client";
 
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
 
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";
 
const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
 
function useAsRef<T>(props: T) {
  const ref = React.useRef<T>(props);
 
  useIsomorphicLayoutEffect(() => {
    ref.current = props;
  });
 
  return ref;
}
 
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>;
}
 
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 useStoreContext(consumerName: string) {
  const context = React.useContext(StoreContext);
  if (!context) {
    throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
  }
  return context;
}
 
function useStore<T>(selector: (state: StoreState) => T): T {
  const store = useStoreContext("useStore");
 
  const getSnapshot = React.useCallback(
    () => selector(store.getState()),
    [store, selector],
  );
 
  return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
 
const DirectionContext = React.createContext<Direction | undefined>(undefined);
 
function useDirection(dir?: Direction): Direction {
  const contextDir = React.useContext(DirectionContext);
  return dir ?? contextDir ?? "ltr";
}
 
type LinkElement = React.ComponentRef<typeof ScrollSpyLink>;
type SectionElement = React.ComponentRef<typeof ScrollSpySection>;
 
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, ...rootProps } = props;
 
  const stateRef = useLazyRef<StoreState>(() => ({
    value: value ?? defaultValue ?? "",
  }));
  const listenersRef = useLazyRef(() => new Set<() => void>());
  const onValueChangeRef = useAsRef(onValueChange);
 
  const store: Store = React.useMemo(() => {
    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]);
 
  return (
    <StoreContext.Provider value={store}>
      <ScrollSpyImpl value={value} defaultValue={defaultValue} {...rootProps} />
    </StoreContext.Provider>
  );
}
 
function ScrollSpyImpl(props: Omit<ScrollSpyProps, "onValueChange">) {
  const {
    value,
    defaultValue,
    rootMargin,
    threshold = 0.1,
    offset = 0,
    scrollBehavior = "smooth",
    scrollContainer = null,
    dir: dirProp,
    orientation = "horizontal",
    asChild,
    className,
    ...rootProps
  } = props;
 
  const dir = useDirection(dirProp);
  const store = useStoreContext(ROOT_NAME);
 
  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 (
    <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>
  );
}
 
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,
};

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

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.