Skip to content
gnosticaignostic-ui
Esc
↑↓navigate↵open⌘Jpreview
On this page

Scroll Spy

Composable scroll spy: a line, section rail, liquid dots, an orbit, or a contents spine.

Scroll through the examples below. Each indicator stays in view while its section scrolls from start to finish. The next example then takes its place. You can also open the separate full page demo.

Composition

ScrollSpy is a polymorphic HTML container. It renders a div by default, or the tag given with as. It does not use a custom element or a client framework. Set variant once on the root. With no children, it supplies the parts for that variant:

<ScrollSpy />
<ScrollSpy orientation="horizontal" />
<ScrollSpy variant="gooey" />
<ScrollSpy variant="orbit" />

The default vertical line sits at the right edge. The horizontal line sits at the top. Both track vertical scroll distance, from 0 at the top to 1 at the bottom. A page with no scroll range stays at 0.

The previews use scrollTarget="html" and a progressTarget section. A sticky wrapper keeps the indicator visible during that section.

Line

Scroll through this section to fill the indicator.

0% at the start · 100% at the end

Installation

bunx shadcn@latest add @gnostic/scroll-spy
npx shadcn@latest add @gnostic/scroll-spy
pnpm dlx shadcn@latest add @gnostic/scroll-spy

Complete the Installation guide first. Then copy the component files below.

Install component packages

bun add motion

Copy the files

src/components/ui/scroll-spy/index.ts
export { default as ScrollSpy } from './scroll-spy.astro'
export { default as ScrollSpyTrack } from './scroll-spy-track.astro'
export { default as ScrollSpyIndicator } from './scroll-spy-indicator.astro'
export { default as ScrollSpyItem } from './scroll-spy-item.astro'
export { default as ScrollSpyFill } from './scroll-spy-fill.astro'
export {
    scrollSpyVariants,
    type ScrollSpyVariantProps,
} from './scroll-spy-variants.ts'
export type { ScrollSpyItemProps, ScrollSpyOptions } from './types.ts'
src/components/ui/scroll-spy/types.ts
import type { ScrollSpyVariantProps } from './scroll-spy-variants.ts'

/**
 * Props for the scroll spy root.
 * The root also accepts `class`, `style`, and the attributes of its element.
 */
export interface ScrollSpyOptions {
    /**
     * The appearance shared by the track, fill, and indicator.
     * `toc` draws a spine beside the section links. A primary bar and a dot stay on the current link.
     */
    variant?: NonNullable<ScrollSpyVariantProps['variant']>
    /** The visual direction. Both layouts track vertical scrolling. */
    orientation?: NonNullable<ScrollSpyVariantProps['orientation']>
    /**
     * Fixed to the viewport, absolute in a positioned ancestor, or in normal flow.
     * The `toc` variant uses normal flow when this is omitted.
     *
     * @default fixed
     */
    position?: NonNullable<ScrollSpyVariantProps['position']>
    /** Distance between rail notch centers. Numbers are pixels; strings are CSS lengths. Omit for standard link targets. */
    gap?: number | string
    /** CSS selector for the scroll container. Defaults to the nearest scrollable ancestor, then document.scrollingElement. */
    scrollTarget?: string
    /** Track one section from its top to its bottom. The nearest matching ancestor is preferred. */
    progressTarget?: string
}

/**
 * Props for one section link.
 * Anchor attributes such as `href`, `class`, and `aria-label` pass through.
 * `href` is required and must be a fragment, such as `#overview`.
 */
export interface ScrollSpyItemProps {
    /**
     * Heading depth for the `toc` spine.
     * Depth 2 is a page section. Depth 3 and 4 step the line inward.
     *
     * @default 2
     */
    depth?: number
}
src/components/ui/scroll-spy/scroll-spy-variants.ts
import { cva, type VariantProps } from 'class-variance-authority'

export const scrollSpyVariants = cva(
    'group/scroll-spy text-foreground pointer-events-none z-20 w-fit',
    {
        variants: {
            variant: {
                line: '',
                rail: '',
                gooey: '',
                orbit: '',
                toc: 'w-56 max-w-full',
            },
            orientation: { vertical: '', horizontal: '' },
            position: {
                fixed: 'fixed',
                absolute: 'absolute',
                relative: 'relative',
            },
        },
        compoundVariants: [
            {
                orientation: 'vertical',
                position: ['fixed', 'absolute'],
                class: 'inset-e-4 top-1/2 -translate-y-1/2',
            },
            {
                orientation: 'horizontal',
                position: ['fixed', 'absolute'],
                class: 'inset-x-0 top-0 w-full',
            },
        ],
        defaultVariants: {
            variant: 'line',
            orientation: 'vertical',
            position: 'fixed',
        },
    },
)

export type ScrollSpyVariantProps = VariantProps<typeof scrollSpyVariants>
src/components/ui/scroll-spy/scroll-spy.astro
---
import type { HTMLTag, Polymorphic } from "astro/types"
import { cn } from "cn"
import type { ScrollSpyOptions } from "./types.ts"
import { scrollSpyVariants } from "./scroll-spy-variants.ts"
import Track from "./scroll-spy-track.astro"
import Fill from "./scroll-spy-fill.astro"
import Indicator from "./scroll-spy-indicator.astro"
import "./scroll-spy.css"

type Props<Tag extends HTMLTag = "div"> = Polymorphic<{ as: Tag }> &
  ScrollSpyOptions
const {
  as: Tag = "div",
  variant = "line",
  orientation = "vertical",
  position: positionProp,
  gap,
  scrollTarget,
  progressTarget,
  class: className,
  style,
  ...props
} = Astro.props
// The contents spine sits in normal flow. Other variants stay fixed.
const position = positionProp ?? (variant === "toc" ? "relative" : "fixed")
const railGap = typeof gap === "number" ? `${gap}px` : gap
const rootStyle =
  railGap === undefined
    ? style
    : typeof style === "string"
      ? `--scroll-spy-gap:${railGap};${style}`
      : { "--scroll-spy-gap": railGap, ...style }
---

<Tag
  data-slot="scroll-spy"
  data-variant={variant}
  data-orientation={orientation}
  data-gap={railGap}
  data-scroll-target={scrollTarget}
  data-progress-target={progressTarget}
  class={cn(scrollSpyVariants({ variant, orientation, position }), className)}
  style={rootStyle}
  {...props}
>
  <slot>
    <Track>
      {(variant === "line" || variant === "orbit") && <Fill />}
      {variant !== "line" && <Indicator />}
    </Track>
  </slot>
</Tag>

<script>
  import "./scroll-spy"
</script>
src/components/ui/scroll-spy/scroll-spy-track.astro
---
import type { HTMLTag, Polymorphic } from "astro/types"
import { cn } from "cn"
import Spine from "./scroll-spy-spine.astro"

type Props<Tag extends HTMLTag = "div"> = Polymorphic<{ as: Tag }>

const { as: Tag = "div", class: className, ...props } = Astro.props
---

<Tag
  data-slot="scroll-spy-track"
  class={cn(
    "relative group-data-[variant=line]/scroll-spy:h-40 group-data-[variant=line]/scroll-spy:w-px group-data-[variant=line]/scroll-spy:bg-border group-data-[variant=gooey]/scroll-spy:h-44 group-data-[variant=gooey]/scroll-spy:w-12 group-data-[variant=orbit]/scroll-spy:size-14 group-data-[variant=line]/scroll-spy:group-data-[orientation=horizontal]/scroll-spy:h-px group-data-[variant=line]/scroll-spy:group-data-[orientation=horizontal]/scroll-spy:w-full group-data-[variant=gooey]/scroll-spy:group-data-[orientation=horizontal]/scroll-spy:h-12 group-data-[variant=gooey]/scroll-spy:group-data-[orientation=horizontal]/scroll-spy:w-44 group-data-[variant=toc]/scroll-spy:w-full",
    className,
  )}
  {...props}
>
  <Spine />
  <slot />
</Tag>
src/components/ui/scroll-spy/scroll-spy-fill.astro
---
/**
 * The fill for the scroll spy track.
 */
import type { HTMLTag, Polymorphic } from "astro/types"
import { cn } from "cn"

type Props<Tag extends HTMLTag = "span"> = Polymorphic<{ as: Tag }>

const { as: Tag = "span", class: className, ...props } = Astro.props
---

<Tag
  data-slot="scroll-spy-fill"
  aria-hidden="true"
  class={cn(
    "pointer-events-none absolute inset-0 hidden group-data-[variant=line]/scroll-spy:block group-data-[variant=line]/scroll-spy:origin-top group-data-[variant=line]/scroll-spy:bg-primary group-data-[variant=orbit]/scroll-spy:block group-data-[variant=line]/scroll-spy:group-data-[orientation=horizontal]/scroll-spy:origin-left",
    className,
  )}
  {...props}
>
  <slot>
    <svg
      viewBox="0 0 56 56"
      class="hidden size-full -rotate-90 fill-none group-data-[variant=orbit]/scroll-spy:block"
      focusable="false"
    >
      <circle cx="28" cy="28" r="24" class="stroke-border" stroke-width="1.5" />
      <circle
        data-slot="scroll-spy-ring"
        cx="28"
        cy="28"
        r="24"
        pathLength="100"
        class="stroke-primary"
        stroke-width="2"
        stroke-linecap="round"
      />
    </svg>
  </slot>
</Tag>
src/components/ui/scroll-spy/scroll-spy-indicator.astro
---
/**
 * Section marks, liquid/gooey dots, or orbiting dot. Its slot accepts custom content.
 *
 * @optional - not required
 */
import type { HTMLTag, Polymorphic } from "astro/types"
import { cn } from "cn"
type Props<Tag extends HTMLTag = "div"> = Polymorphic<{ as: Tag }>
const { as: Tag = "div", class: className, ...props } = Astro.props
const filterId = `scroll-goo-${crypto.randomUUID()}`
---

<Tag
  data-slot="scroll-spy-indicator"
  class={cn(
    "hidden group-data-[variant=rail]/scroll-spy:flex group-data-[variant=rail]/scroll-spy:flex-col group-data-[variant=rail]/scroll-spy:group-data-[orientation=horizontal]/scroll-spy:flex-row group-data-[variant=gooey]/scroll-spy:absolute group-data-[variant=gooey]/scroll-spy:inset-0 group-data-[variant=gooey]/scroll-spy:block group-data-[variant=orbit]/scroll-spy:absolute group-data-[variant=orbit]/scroll-spy:inset-1 group-data-[variant=orbit]/scroll-spy:block",
    className,
  )}
  {...props}
>
  <slot>
    <svg class="absolute size-0" aria-hidden="true" focusable="false">
      <defs>
        <filter
          id={filterId}
          x="-100%"
          y="-100%"
          width="300%"
          height="300%"
          color-interpolation-filters="sRGB"
        >
          <feGaussianBlur in="SourceGraphic" stdDeviation="4" result="blur" />
          <feColorMatrix
            in="blur"
            mode="matrix"
            values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -8"
          />
        </filter>
      </defs>
    </svg>
    <div
      data-slot="scroll-spy-goo"
      class="absolute inset-0 hidden flex-col items-center justify-around group-data-[variant=gooey]/scroll-spy:flex group-data-[orientation=horizontal]/scroll-spy:flex-row"
      style={{ filter: `url(#${filterId})` }}
      aria-hidden="true"
    >
      {Array.from({ length: 4 }, () => (
        <span class="size-3 shrink-0 rounded-full bg-primary" />
      ))}
      <span
        data-slot="scroll-spy-flow"
        class="absolute size-5 rounded-full bg-primary"
      />
    </div>
    <span
      data-slot="scroll-spy-dot"
      class="absolute -top-1 left-1/2 hidden size-2 -translate-x-1/2 rounded-full bg-primary ring-4 ring-primary/15 group-data-[variant=orbit]/scroll-spy:block"
      aria-hidden="true"
    />
  </slot>
</Tag>
src/components/ui/scroll-spy/scroll-spy-item.astro
---
/**
 * Each item is a trackable target in the scroll spy timeline.
 *
 * @optional - not required
 */
import type { HTMLAttributes } from "astro/types"
import { cn } from "cn"
import type { ScrollSpyItemProps } from "./types.ts"

type Props = HTMLAttributes<"a"> &
  ScrollSpyItemProps & {
    /** Fragment of the section this link tracks. */
    href: `#${string}`
  }

const {
  class: className,
  "aria-label": label,
  depth = 2,
  ...props
} = Astro.props
---

<a
  data-slot="scroll-spy-item"
  data-depth={depth}
  aria-label={
    label ??
    (Astro.slots.has("default") ? undefined : `Go to ${props.href.slice(1)}`)
  }
  class={cn(
    "pointer-events-auto relative flex min-w-11 items-center justify-end gap-3 rounded-sm px-2 text-muted-foreground outline-none hover:text-foreground focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring aria-[current=location]:text-primary group-data-[orientation=horizontal]/scroll-spy:flex-col group-data-[orientation=horizontal]/scroll-spy:justify-start group-data-[orientation=horizontal]/scroll-spy:py-2",
    "min-h-8",
    className,
  )}
  {...props}
>
  {Astro.slots.has("default") && (
    <span
      data-slot="scroll-spy-label"
      class="flex items-center gap-2 whitespace-nowrap rounded-sm bg-background/95 px-2 py-1 font-mono text-[0.625rem] uppercase tracking-wider"
    >
      <slot />
    </span>
  )}
  <span
    data-slot="scroll-spy-mark"
    class="h-px w-3 shrink-0 bg-current group-data-[orientation=horizontal]/scroll-spy:h-3 group-data-[orientation=horizontal]/scroll-spy:w-px"
    aria-hidden="true"
  />
</a>
src/components/ui/scroll-spy/scroll-spy.css
/* Tailwind owns layout and color. One value drives all continuous effects. */
@property --track-top {
    syntax: "<length>";
    inherits: true;
    initial-value: 0px;
}
@property --track-bottom {
    syntax: "<length>";
    inherits: true;
    initial-value: 0px;
}
@property --offset-distance {
    syntax: "<length>";
    inherits: true;
    initial-value: 0px;
}
[data-slot="scroll-spy"] {
    --scroll-spy: 0;
}
[data-slot="scroll-spy"][data-variant="line"] [data-slot="scroll-spy-fill"] {
    transform: scaleY(var(--scroll-spy));
}
[data-slot="scroll-spy"][data-variant="line"][data-orientation="horizontal"]
    [data-slot="scroll-spy-fill"] {
    transform: scaleX(var(--scroll-spy));
}
[data-slot="scroll-spy-ring"] {
    stroke-dasharray: 100;
    stroke-dashoffset: calc(100 * (1 - var(--scroll-spy)));
}
[data-slot="scroll-spy"][data-variant="orbit"]
    [data-slot="scroll-spy-indicator"] {
    transform: rotate(calc(var(--scroll-spy) * 1turn));
}
[data-slot="scroll-spy-flow"] {
    top: calc(12.5% + var(--scroll-spy) * 75%);
    left: 50%;
    transform: translate(-50%, -50%);
}
[data-slot="scroll-spy"][data-orientation="horizontal"]
    [data-slot="scroll-spy-flow"] {
    top: 50%;
    left: calc(12.5% + var(--scroll-spy) * 75%);
}
[data-slot="scroll-spy"][data-variant="rail"][data-gap]
    [data-slot="scroll-spy-item"] {
    flex: none;
    height: max(1px, var(--scroll-spy-gap));
    min-height: 0;
}
[data-slot="scroll-spy"][data-variant="rail"][data-gap]
    [data-slot="scroll-spy-label"] {
    position: absolute;
    inset-inline-end: 3rem;
    top: 50%;
    transform: translateY(-50%);
    pointer-events: none;
}
[data-slot="scroll-spy"][data-variant="rail"][data-gap][data-orientation="horizontal"]
    [data-slot="scroll-spy-item"] {
    width: max(1px, var(--scroll-spy-gap));
    min-width: 0;
    height: 2.75rem;
    padding: 0;
}
[data-slot="scroll-spy"][data-variant="rail"][data-gap][data-orientation="horizontal"]
    [data-slot="scroll-spy-label"] {
    inset-inline-end: auto;
    left: 50%;
    top: auto;
    bottom: calc(100% + 0.75rem);
    transform: translateX(-50%);
}
[data-slot="scroll-spy-label"] {
    opacity: 0;
    transition: opacity 180ms ease;
}
[data-slot="scroll-spy-label"]:empty {
    display: none;
}
[data-slot="scroll-spy-item"]:is(
        [aria-current="location"],
        :hover,
        :focus-visible
    )
    [data-slot="scroll-spy-label"] {
    opacity: 1;
}
[data-slot="scroll-spy-mark"] {
    transition:
        width 200ms ease,
        height 200ms ease;
}
/* Depth padding matches tocLineOffset() in scroll-spy.ts, plus 12px of text clearance. */
[data-slot="scroll-spy"][data-variant="toc"] [data-slot="scroll-spy-item"] {
    width: 100%;
    min-width: 0;
    min-height: 0;
    justify-content: flex-start;
    gap: 0;
    padding-block: 0.375rem;
    padding-inline: 0 0.5rem;
    font-size: 0.875rem;
    line-height: 1.5rem;
}
[data-slot="scroll-spy"][data-variant="toc"]
    [data-slot="scroll-spy-item"][data-depth="2"],
[data-slot="scroll-spy"][data-variant="toc"]
    [data-slot="scroll-spy-item"]:not([data-depth]) {
    padding-inline-start: 20px;
}
[data-slot="scroll-spy"][data-variant="toc"]
    [data-slot="scroll-spy-item"][data-depth="3"] {
    padding-inline-start: 32px;
}
[data-slot="scroll-spy"][data-variant="toc"]
    [data-slot="scroll-spy-item"][data-depth="4"],
[data-slot="scroll-spy"][data-variant="toc"]
    [data-slot="scroll-spy-item"][data-depth="5"],
[data-slot="scroll-spy"][data-variant="toc"]
    [data-slot="scroll-spy-item"][data-depth="6"] {
    padding-inline-start: 44px;
}
[data-slot="scroll-spy"][data-variant="toc"]
    [data-slot="scroll-spy-track"]
    > [data-slot="scroll-spy-item"]:first-of-type {
    padding-top: 0;
}
[data-slot="scroll-spy"][data-variant="toc"]
    [data-slot="scroll-spy-track"]
    > [data-slot="scroll-spy-item"]:last-of-type {
    padding-bottom: 0;
}
[data-slot="scroll-spy"][data-variant="toc"] [data-slot="scroll-spy-label"] {
    position: static;
    transform: none;
    opacity: 1;
    background: transparent;
    padding: 0;
    font-family: inherit;
    font-size: inherit;
    font-weight: inherit;
    letter-spacing: normal;
    text-transform: none;
    white-space: normal;
}
[data-slot="scroll-spy"][data-variant="toc"] [data-slot="scroll-spy-mark"] {
    display: none;
}
[data-slot="scroll-spy-spine"],
[data-slot="scroll-spy-spine"] * {
    pointer-events: none;
}
[data-slot="scroll-spy-spine-active"] {
    clip-path: polygon(
        0 var(--track-top),
        100% var(--track-top),
        100% var(--track-bottom),
        0 var(--track-bottom)
    );
    transition: clip-path 200ms ease;
}
[data-slot="scroll-spy-thumb"] {
    offset-anchor: center;
    offset-distance: var(--offset-distance);
    offset-rotate: 0deg;
    transition:
        offset-distance 200ms ease,
        opacity 200ms ease;
}
[data-slot="scroll-spy-spine"][data-ready] [data-slot="scroll-spy-thumb"] {
    opacity: 1;
}
[data-slot="scroll-spy-item"][aria-current="location"]
    [data-slot="scroll-spy-mark"] {
    width: 2rem;
}
[data-slot="scroll-spy"][data-orientation="horizontal"]
    [data-slot="scroll-spy-item"][aria-current="location"]
    [data-slot="scroll-spy-mark"] {
    width: 1px;
    height: 2rem;
}
@media (prefers-reduced-motion: reduce) {
    [data-slot="scroll-spy-label"],
    [data-slot="scroll-spy-mark"],
    [data-slot="scroll-spy-spine-active"],
    [data-slot="scroll-spy-thumb"] {
        transition: none;
    }
    [data-slot="scroll-spy"][data-variant="orbit"]
        [data-slot="scroll-spy-indicator"] {
        display: none;
    }
}
@media (prefers-reduced-motion: reduce), (forced-colors: active) {
    [data-slot="scroll-spy-goo"] {
        filter: none !important;
    }
}
src/components/ui/scroll-spy/scroll-spy.ts
import { scroll } from 'motion'

const selector = '[data-slot="scroll-spy"]'
const instances = new Map<HTMLElement, () => void>()

/** Line x positions for heading depth. Depth 2, 3, and 4 match the Fumadocs TOC. */
const TOC_BASE = 8

/**
 * Horizontal position of the toc spine for one heading depth.
 * The link padding in scroll-spy.css uses these values plus 12px.
 */
function tocLineOffset(depth: number) {
    if (depth <= 2) return TOC_BASE
    if (depth === 3) return TOC_BASE + 8
    return TOC_BASE + 16
}

/** Heading depth from the item. Missing or invalid values use a page section. */
function itemDepth(link: HTMLElement) {
    const depth = Number(link.dataset.depth)
    return Number.isFinite(depth) && depth > 0 ? depth : 2
}

interface TocLayout {
    positions: [top: number, bottom: number, x: number][]
    itemLineLengths: [start: number, end: number][]
}

/**
 * Draw the toc spine and keep its bar on the current link.
 * A muted path shows every heading. A clipped copy and a dot mark the current one.
 * The curve follows the Fumadocs table of contents.
 */
function trackToc(root: HTMLElement, links: HTMLAnchorElement[]) {
    const spine = [
        ...root.querySelectorAll<HTMLElement>('[data-slot="scroll-spy-spine"]'),
    ].find((element) => element.closest(selector) === root)
    const rest = spine?.querySelector<SVGPathElement>(
        '[data-slot="scroll-spy-spine-rest"]',
    )
    const activePath = spine?.querySelector<SVGPathElement>(
        '[data-slot="scroll-spy-spine-active"]',
    )
    const svg = spine?.querySelector<SVGSVGElement>(
        '[data-slot="scroll-spy-spine-svg"]',
    )
    const thumb = spine?.querySelector<HTMLElement>(
        '[data-slot="scroll-spy-thumb"]',
    )
    const track = spine?.closest<HTMLElement>('[data-slot="scroll-spy-track"]')
    if (!spine || !rest || !activePath || !svg || !thumb || !track)
        return {
            place(_link: HTMLAnchorElement | undefined) {},
            stop() {},
        }

    let layout: TocLayout | undefined
    let currentLink: HTMLAnchorElement | undefined
    let previous: { index: number; isUp: boolean } | undefined

    const apply = () => {
        if (!layout || !currentLink) {
            spine.removeAttribute('data-ready')
            return
        }
        const index = links.indexOf(currentLink)
        const position = layout.positions[index]
        const length = layout.itemLineLengths[index]
        if (!position || !length) {
            spine.removeAttribute('data-ready')
            return
        }
        let isUp = false
        if (previous)
            isUp =
                previous.index > index ||
                (previous.index === index && previous.isUp)
        previous = { index, isUp }
        const [top, bottom] = position
        spine.style.setProperty('--track-top', `${top}px`)
        spine.style.setProperty('--track-bottom', `${bottom}px`)
        spine.style.setProperty(
            '--offset-distance',
            `${isUp ? length[0] : length[1]}px`,
        )
        spine.dataset.ready = ''
    }

    const draw = () => {
        const trackTop = track.getBoundingClientRect().top
        let width = 0
        let height = 0
        let d = ''
        const positions: TocLayout['positions'] = []
        links.forEach((link, index) => {
            const style = getComputedStyle(link)
            const x = tocLineOffset(itemDepth(link)) + 0.5
            const origin = link.getBoundingClientRect().top - trackTop
            const top = origin + Number.parseFloat(style.paddingTop)
            const bottom =
                origin +
                link.clientHeight -
                Number.parseFloat(style.paddingBottom)
            width = Math.max(width, x + 8)
            height = Math.max(height, bottom)
            if (index === 0) d += `M${x} ${top}L${x} ${bottom}`
            else {
                const [, upperBottom, upperX] = positions[index - 1]
                d += `C${upperX} ${top - 4} ${x} ${upperBottom + 4} ${x} ${top}L${x} ${bottom}`
            }
            positions.push([top, bottom, x])
        })
        if (!d || width === 0 || height === 0) {
            layout = undefined
            spine.removeAttribute('data-ready')
            return
        }
        rest.setAttribute('d', d)
        activePath.setAttribute('d', d)
        svg.setAttribute('viewBox', `0 0 ${width} ${height}`)
        svg.setAttribute('width', String(width))
        svg.setAttribute('height', String(height))
        spine.style.width = `${width}px`
        spine.style.height = `${height}px`
        thumb.style.setProperty('offset-path', `path("${d}")`)
        const total = rest.getTotalLength()
        const itemLineLengths: TocLayout['itemLineLengths'] = []
        for (let index = 0; index < positions.length; index++) {
            const [top, bottom] = positions[index]
            let distance =
                index > 0
                    ? itemLineLengths[index - 1][1] +
                      (top - positions[index - 1][1])
                    : top
            while (distance < total && rest.getPointAtLength(distance).y < top)
                distance += 1
            itemLineLengths.push([distance, distance + bottom - top])
        }
        layout = { positions, itemLineLengths }
        apply()
    }

    const observer = new ResizeObserver(draw)
    observer.observe(track)
    for (const link of links) observer.observe(link)
    draw()
    return {
        place(link: HTMLAnchorElement | undefined) {
            currentLink = link
            apply()
        },
        stop() {
            observer.disconnect()
        },
    }
}

function initialize(root: HTMLElement) {
    const links = [
        ...root.querySelectorAll<HTMLAnchorElement>(
            '[data-slot="scroll-spy-item"]',
        ),
    ].filter((link) => link.closest(selector) === root)
    links.forEach((link) => link.removeAttribute('aria-current'))
    root.style.setProperty('--scroll-spy', '0')

    const scrollTarget = root.dataset.scrollTarget?.trim()
    const targetSelector = root.dataset.progressTarget?.trim()
    let container: HTMLElement | null = root.parentElement
    let target: HTMLElement | undefined
    try {
        if (scrollTarget) {
            const element = document.querySelector(scrollTarget)
            if (!(element instanceof HTMLElement)) return () => {}
            container = element
        } else {
            while (
                container &&
                container !== document.body &&
                container !== document.documentElement
            ) {
                if (
                    /(auto|scroll|hidden)/.test(
                        getComputedStyle(container).overflowY,
                    )
                )
                    break
                container = container.parentElement
            }
        }
        if (
            container === document.body ||
            container === document.documentElement
        )
            container = null
        if (targetSelector) {
            const element =
                root.closest(targetSelector) ??
                document.querySelector(targetSelector)
            if (
                !(element instanceof HTMLElement) ||
                (container && !container.contains(element))
            )
                return () => {}
            target = element
        }
    } catch {
        // An invalid selector must not select a different scroll source.
        return () => {}
    }

    const sections = links.flatMap((link) => {
        let id: string
        try {
            id = decodeURIComponent(link.hash.slice(1))
        } catch {
            return []
        }
        const section = document.getElementById(id)
        return section && (!container || container.contains(section))
            ? [{ link, section }]
            : []
    })
    let active: HTMLAnchorElement | undefined
    const toc =
        root.dataset.variant === 'toc' ? trackToc(root, links) : undefined
    const documentScroller = document.scrollingElement
    const options: Parameters<typeof scroll>[1] & {
        trackContentSize: boolean
    } = {
        container:
            container ??
            (documentScroller instanceof HTMLElement
                ? documentScroller
                : document.documentElement),
        target,
        offset: ['start start', 'end end'],
        trackContentSize: true,
    }

    const stopScroll = scroll((progress, { y }) => {
        // Keep empty containers and sections shorter than the viewport at zero.
        const hasRange = target
            ? y.targetLength > y.containerLength
            : y.scrollLength > 0
        const value = hasRange ? progress : 0
        root.style.setProperty('--scroll-spy', String(value))
        if (!sections.length) return

        const line =
            (container
                ? container.getBoundingClientRect().top + container.clientTop
                : 0) +
            y.containerLength * 0.45
        let current: HTMLAnchorElement | undefined = sections[0]?.link
        if (
            value === 1 ||
            (y.scrollLength > 0 && y.current >= y.scrollLength - 1)
        ) {
            // A short final section must also be selected at the bottom edge.
            current = sections.at(-1)?.link
        } else if (value > 0 && y.current > 0) {
            for (const { link, section } of sections) {
                if (section.getBoundingClientRect().top <= line) current = link
            }
        }
        if (current !== active) {
            active?.removeAttribute('aria-current')
            active = current
            active?.setAttribute('aria-current', 'location')
            toc?.place(active)
        }
    }, options)
    return () => {
        stopScroll()
        toc?.stop()
    }
}

function refresh() {
    for (const [root, cleanup] of instances) {
        if (!root.isConnected) {
            cleanup()
            instances.delete(root)
        }
    }
    document.querySelectorAll<HTMLElement>(selector).forEach((root) => {
        if (!instances.has(root)) instances.set(root, initialize(root))
    })
}

refresh()
document.addEventListener('astro:page-load', refresh)
document.addEventListener('astro:before-swap', () => {
    instances.forEach((cleanup) => cleanup())
    instances.clear()
})
// Support roots moved by a client page and selectors updated by inline previews.
new MutationObserver((records) => {
    for (const record of records) {
        if (
            record.type === 'attributes' &&
            record.target instanceof HTMLElement
        ) {
            instances.get(record.target)?.()
            instances.delete(record.target)
        }
        for (const node of record.removedNodes) {
            if (!(node instanceof Element)) continue
            for (const [root, cleanup] of instances) {
                if (node === root || node.contains(root)) {
                    cleanup()
                    instances.delete(root)
                }
            }
        }
    }
    if (
        records.some(
            (record) =>
                record.type === 'attributes' ||
                [...record.addedNodes, ...record.removedNodes].some(
                    (node) =>
                        node instanceof Element &&
                        (node.matches(selector) ||
                            node.querySelector(selector)),
                ),
        )
    )
        refresh()
}).observe(document.documentElement, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ['data-scroll-target', 'data-progress-target'],
})
src/components/ui/scroll-spy/scroll-spy-spine.astro
---
/**
 * Depth spine for the `toc` variant.
 * The script writes the path, the active bar, and the traveling dot.
 */
---

<div
  data-slot="scroll-spy-spine"
  class="pointer-events-none absolute inset-s-0 top-0 hidden overflow-visible group-data-[variant=toc]/scroll-spy:block rtl:-scale-x-100"
  aria-hidden="true"
>
  <svg
    data-slot="scroll-spy-spine-svg"
    class="absolute overflow-visible"
    fill="none"
    aria-hidden="true"
  >
    <path
      data-slot="scroll-spy-spine-rest"
      class="stroke-foreground/10"
      stroke-width="1"
    />
    <path
      data-slot="scroll-spy-spine-active"
      class="stroke-primary"
      stroke-width="1"
    />
  </svg>
  <div
    data-slot="scroll-spy-thumb"
    class="absolute inset-s-0 size-1 rounded-full bg-primary opacity-0"
  />
</div>

Add a preset (optional)

Default Coss works with the component classes. To use another design, inspect and save its CSS to src/styles/presets/. Import it after the global stylesheet, then set data-preset on your layout or a container. These files also work with JavaScript disabled.

Tracks and fills

Use parts when you need more control. Each part accepts class, style, native HTML attributes, and a slot. The root, track, fill, and indicator also accept as.

---
import {
  ScrollSpy,
  ScrollSpyTrack,
  ScrollSpyFill,
} from "@/components/ui/scroll-spy"
---

<ScrollSpy orientation="horizontal">
  <ScrollSpyTrack class="h-1!">
    <ScrollSpyFill />
  </ScrollSpyTrack>
</ScrollSpy>

ScrollSpyTrack supplies the dimensions and background for the root’s line, rail, gooey, or orbit variant. ScrollSpyFill supplies a growing line or an SVG ring. You can omit the fill. Parts read the root’s data-variant attribute through CSS; they do not need a second variant prop or client state.

The --scroll-spy CSS property is a number from 0 to 1. Custom content can use it to draw a different shape without another scroll script.

Indicators

ScrollSpyIndicator is optional. The root’s rail variant groups section items. The gooey variant supplies four liquid dots and a moving blob. The orbit variant supplies a dot that travels around the ring. A slot replaces its default content, so you can supply your own marks or SVG.

Gooey

Gooey

Scroll through this section to fill the indicator.

0% at the start · 100% at the end

Orbit

The ring and orbiting dot are separate parts. Omit the indicator for a plain ring.

Orbit

Scroll through this section to fill the indicator.

0% at the start · 100% at the end

Horizontal

Orientation changes the layout, not the scroll axis. A horizontal line or a row of dots can show progress through the same vertical page.

Horizontal

Scroll through this section to fill the indicator.

0% at the start · 100% at the end

Use as="nav" and give the navigation an accessible name. Items are native anchors. Use href="#section-id" to refer to a section; id remains the native ID of the link itself. This avoids duplicate IDs on the link and its target.

---
import {
  ScrollSpy,
  ScrollSpyTrack,
  ScrollSpyIndicator,
  ScrollSpyItem,
} from "@/components/ui/scroll-spy"

const items = [
  { id: "about", label: "About" },
  { id: "work", label: "Work" },
]
---

<ScrollSpy variant="rail" as="nav" aria-label="Page sections">
  <ScrollSpyTrack>
    <ScrollSpyIndicator>
      {items.map(item => (
        <ScrollSpyItem href={`#${item.id}`}>
          {item.label}
        </ScrollSpyItem>
      ))}
    </ScrollSpyIndicator>
  </ScrollSpyTrack>
</ScrollSpy>

<section id="about">...</section>
<section id="work">...</section>

Keep items in document order. Content can be text, an icon, or both. A current, hovered, or focused item shows its content and expands its mark. To omit visible labels, leave the slot empty and supply an aria-label:

Start — scroll to the next section.

Middle — scroll to the next section.

Finish — scroll to the next section.

Empty items get a fallback accessible name from their fragment target. Supply a clear aria-label for an icon-only item. No item list, labels, or section IDs are needed when you only want total progress.

For a compact rail, set gap={8} on ScrollSpy. This sets an 8px distance between notch centers. A number uses pixels; a string accepts a CSS length, such as gap="0.5rem". It works in both orientations. Labels sit beside a vertical rail or above a horizontal rail, so they do not increase the spacing. Omit gap to keep the standard link target sizes. For a dense rail, also provide a section menu with larger touch targets, as on the extended blocks page.

Table of contents

variant="toc" draws a spine beside the links. The line steps inward when the heading depth changes. A primary bar and a dot stay on the current link. Omit position to keep the list in normal flow. Add sticky when the list should remain on screen.

Set depth to the heading level. Depth 2 is a page section. Depth 3 and 4 step the spine inward. The default is 2.

---
import {
  ScrollSpy,
  ScrollSpyTrack,
  ScrollSpyItem,
} from "@/components/ui/scroll-spy"

const items = [
  { id: "overview", label: "Overview", depth: 2 },
  { id: "flags", label: "Flags", depth: 3 },
]
---

<ScrollSpy variant="toc" as="nav" aria-label="On this page">
  <ScrollSpyTrack>
    {items.map(item => (
      <ScrollSpyItem href={`#${item.id}`} depth={item.depth}>
        {item.label}
      </ScrollSpyItem>
    ))}
  </ScrollSpyTrack>
</ScrollSpy>

Overview — scroll to move the bar.

Install — scroll to move the bar.

Flags — scroll to move the bar.

Addons — scroll to move the bar.

Deploy — scroll to move the bar.

PropType
depth?number

Heading depth for the `toc` spine. Depth 2 is a page section. Depth 3 and 4 step the line inward.

Typenumber
Default2

Position and scroll source

position="fixed" keeps the indicator at the viewport edge. Use position="absolute" to anchor it inside a positioned ancestor, or position="relative" to keep it in normal flow. Tailwind classes can set offsets or change positioning to sticky.

The script finds the nearest ancestor with vertical overflow: auto, scroll, or hidden. If there is none, it uses document.scrollingElement. It uses the document’s <html> element if that property is null. A relative class controls the containing block for absolute positioning; it does not make that element scrollable. Use overflow-clip when you need clipping without a scroll source. Fixed positioning does not change which ancestor the script tracks.

To select a scroll container yourself, pass a CSS selector to scrollTarget:

<div id="reader" class="h-96 overflow-y-auto">
  <article class="min-h-[72rem]">...</article>
</div>

<ScrollSpy scrollTarget="#reader" />

The indicator can be outside the selected container. The selector uses the first matching HTML element in the document. Section links must point to sections inside that container. Use scrollTarget="html" or scrollTarget="body" to track the page explicitly.

The target must exist when the component initializes. An invalid selector or a missing target leaves the indicator empty. It does not track a different source. An omitted or blank selector keeps automatic parent detection.

Track one section

Use progressTarget to measure one section within the scroll container. The nearest matching ancestor is used first, then the first document match.

<section data-reading-section class="min-h-[160vh]">
  <div class="sticky top-24">
    <ScrollSpy
      scrollTarget="html"
      progressTarget="[data-reading-section]"
      position="relative"
    />
  </div>
</section>

For a nested scroll container, also set position: relative on that container so Motion can measure the section’s position.

Progress starts when the section top reaches the scroll viewport top. It ends when the section bottom reaches the viewport bottom. The section must be taller than the viewport; otherwise progress stays at 0. A missing or invalid target also stays at 0. Omit progressTarget to measure the whole scroll container.

With Scroll Area

Scroll Area works with the same component. Give its scrolling viewport an ID through viewportProps, then use that ID as the target:

<ScrollArea class="h-80" viewportProps={{ id: "reading-viewport" }}>
  <article>...</article>
</ScrollArea>

<ScrollSpy scrollTarget="#reading-viewport" />

The viewport must have enough content to scroll. Target the inner viewport, not the outer ScrollArea wrapper. A ScrollSpy placed inside the viewport can omit scrollTarget and use automatic parent detection. Progress tracks the vertical scroll distance, including when the indicator uses a horizontal layout.

Behavior and access

Motion’s scroll() utility measures progress and writes --scroll-spy; CSS draws each part. Motion shares its scroll listener between instances that use the same container. Section links use the same callback to select the last section above a reading line 45% down the viewport. The first and last links are selected at the scroll boundaries, including a short final section.

This implementation uses JavaScript for progress in all browsers. This keeps page scrolling and nested scrolling on the same path, including fixed elements inside a scroll container. With JavaScript off, native links still work and the fill stays empty.

Motion refreshes progress when the viewport or scrollable content size changes. The script calls Motion’s cleanup function when a root is removed or Astro changes the page. The fill is decorative and does not announce every scroll update. Links retain native keyboard and fragment navigation.

A reduced-motion preference removes mark transitions, the gooey filter, and the orbiting dot. The line and ring still show progress. The component does not set smooth scrolling.

API reference

Root prop Values Default
as An HTML tag div
variant line, rail, gooey, orbit, toc line
orientation vertical, horizontal vertical
position fixed, absolute, relative fixed
gap Rail notch spacing: pixels or a CSS length Standard link target sizes
scrollTarget A CSS selector for the scroll container Nearest scrollable ancestor, then document.scrollingElement
progressTarget A CSS selector for the section to measure Whole scroll container

The track and indicator default to div; the fill defaults to span. Each accepts an as prop. ScrollSpyItem is an anchor with a required href fragment, optional slot content, and native anchor attributes.

Theme and layout

Parts use the primary, foreground, background, and border tokens. Use Tailwind classes to set dimensions and offsets. Leave room beside content for fixed section links. Keep the rail short enough to fit on the smallest screen.

The root uses CVA. Its scrollSpyVariants function and inferred ScrollSpyVariantProps type are exported from the component index.

Move any earlier part variant props to the root. Parts keep as, class, style, native HTML attributes, and slots. The earlier part variant functions and types are removed. Use slots for items and scrollTarget to select a scroll source.

Use Progress for task completion and Scroll Area for a bounded native scroll panel. Use Toc for a static contents list. The section rail takes its direction from Albina Nikiforova’s site. The contents spine takes its direction from the Fumadocs table of contents, as used on the Better-T-Stack docs.

Was this page helpful?