---
title: Scroll Spy
description: "Composable scroll spy: a line, section rail, liquid dots, an orbit, or a contents spine."
sidebar:
    badge: new
seo:
    title: "Scroll Spy for Astro - examples and usage"
---

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](/examples/scroll-spy).

## 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:

```astro
<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.

```astro
---
import { ScrollSpy } from "@/components/ui/scroll-spy"
---

<section data-progress-section class="relative min-h-[160vh] w-full">
  <div class="sticky top-24 flex min-h-[60vh] flex-col items-center justify-center gap-8 rounded-xl border bg-background p-6">
    <div class="text-center">
      <p class="font-medium">Line</p>
      <p class="text-sm text-muted-foreground">
        Scroll through this section to fill the indicator.
      </p>
    </div>
    <ScrollSpy
      scrollTarget="html"
      progressTarget="[data-progress-section]"
      position="relative"
      aria-hidden="true"
    />
    <p class="text-xs text-muted-foreground">
      0% at the start · 100% at the end
    </p>
  </div>
</section>
```

## Installation

### Command

```sh
bunx --bun shadcn@latest add @gnostic/scroll-spy
```

### Manual

Complete the [Installation guide](/docs/installation) first. Then copy the component files below.

Install the component packages:

```sh
bun add motion
```

#### src/components/ui/scroll-spy/index.ts

```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

```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

```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

```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

```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

```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

```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

```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

```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

```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

```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>
```

Optional preset: inspect a stylesheet at /presets/{style}.css, save it to src/styles/presets/{style}.css, and import it after the global stylesheet. Set data-preset="{style}" on your layout or a component container. See [Theming](/docs/theming) for details.


## 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`.

```astro
---
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

```astro
---
import { ScrollSpy } from "@/components/ui/scroll-spy"
---

<section data-progress-section class="relative min-h-[160vh] w-full">
  <div class="sticky top-24 flex min-h-[60vh] flex-col items-center justify-center gap-8 rounded-xl border bg-background p-6">
    <div class="text-center">
      <p class="font-medium">Gooey</p>
      <p class="text-sm text-muted-foreground">
        Scroll through this section to fill the indicator.
      </p>
    </div>
    <ScrollSpy
      variant="gooey"
      scrollTarget="html"
      progressTarget="[data-progress-section]"
      position="relative"
      aria-hidden="true"
    />
    <p class="text-xs text-muted-foreground">
      0% at the start · 100% at the end
    </p>
  </div>
</section>
```

### Orbit

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

```astro
---
import { ScrollSpy } from "@/components/ui/scroll-spy"
---

<section data-progress-section class="relative min-h-[160vh] w-full">
  <div class="sticky top-24 flex min-h-[60vh] flex-col items-center justify-center gap-8 rounded-xl border bg-background p-6">
    <div class="text-center">
      <p class="font-medium">Orbit</p>
      <p class="text-sm text-muted-foreground">
        Scroll through this section to fill the indicator.
      </p>
    </div>
    <ScrollSpy
      variant="orbit"
      scrollTarget="html"
      progressTarget="[data-progress-section]"
      position="relative"
      aria-hidden="true"
    />
    <p class="text-xs text-muted-foreground">
      0% at the start · 100% at the end
    </p>
  </div>
</section>
```

### 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.

```astro
---
import { ScrollSpy } from "@/components/ui/scroll-spy"
---

<section data-progress-section class="relative min-h-[160vh] w-full">
  <div class="sticky top-24 flex min-h-[60vh] flex-col items-center justify-center gap-8 rounded-xl border bg-background p-6">
    <div class="text-center">
      <p class="font-medium">Horizontal</p>
      <p class="text-sm text-muted-foreground">
        Scroll through this section to fill the indicator.
      </p>
    </div>
    <ScrollSpy
      scrollTarget="html"
      progressTarget="[data-progress-section]"
      orientation="horizontal"
      position="relative"
      class="w-full max-w-64"
      aria-hidden="true"
    />
    <p class="text-xs text-muted-foreground">
      0% at the start · 100% at the end
    </p>
  </div>
</section>
```

## Section links

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.

```astro
---
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`:

```astro
---
import {
  ScrollSpy,
  ScrollSpyTrack,
  ScrollSpyIndicator,
  ScrollSpyItem,
} from "@/components/ui/scroll-spy"
const id = `rail-${crypto.randomUUID()}`
const sections = ["Start", "Middle", "Finish"]
---

<section
  data-progress-section
  class="relative grid w-full grid-cols-[auto_1fr] gap-6"
>
  <div class="relative">
    <ScrollSpy
      variant="rail"
      as="nav"
      aria-label="Demo sections"
      scrollTarget="html"
      progressTarget="[data-progress-section]"
      position="relative"
      class="sticky top-24"
    >
      <ScrollSpyTrack>
        <ScrollSpyIndicator>
          {sections.map((label, i) => (
            <ScrollSpyItem href={`#${id}-${i}`} aria-label={label}>
              {label}
            </ScrollSpyItem>
          ))}
        </ScrollSpyIndicator>
      </ScrollSpyTrack>
    </ScrollSpy>
  </div>
  <div>
    {sections.map((label, i) => (
      <section
        id={`${id}-${i}`}
        class="flex min-h-[70vh] scroll-mt-24 items-start border-t py-8"
      >
        <p class="text-sm text-muted-foreground">
          {label} — scroll to the next section.
        </p>
      </section>
    ))}
  </div>
</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](/examples/extended-blocks).

## 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.

```astro
---
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>
```

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

const id = `toc-${crypto.randomUUID()}`
const items = [
  { label: "Overview", depth: 2 },
  { label: "Install", depth: 2 },
  { label: "Flags", depth: 3 },
  { label: "Addons", depth: 3 },
  { label: "Deploy", depth: 2 },
]
---

<section
  data-progress-section
  class="relative grid w-full grid-cols-[11rem_1fr] gap-6"
>
  <div class="relative">
    <ScrollSpy
      variant="toc"
      as="nav"
      aria-label="On this page"
      scrollTarget="html"
      progressTarget="[data-progress-section]"
      class="sticky top-24"
    >
      <ScrollSpyTrack>
        {items.map((item, i) => (
          <ScrollSpyItem href={`#${id}-${i}`} depth={item.depth}>
            {item.label}
          </ScrollSpyItem>
        ))}
      </ScrollSpyTrack>
    </ScrollSpy>
  </div>
  <div>
    {items.map((item, i) => (
      <section
        id={`${id}-${i}`}
        class="flex min-h-[50vh] scroll-mt-24 items-start border-t py-8"
      >
        <p class="text-sm text-muted-foreground">
          {item.label} — scroll to move the bar.
        </p>
      </section>
    ))}
  </div>
</section>
```

<AutoTypeTable
	path="../../packages/ui/src/components/ui/scroll-spy/types.ts"
	name="ScrollSpyItemProps"
/>

## 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`](https://developer.mozilla.org/en-US/docs/Web/API/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`:

```astro
<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.

```astro
<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](/components/scroll-area) works with the same component. Give its
scrolling viewport an ID through `viewportProps`, then use that ID as the target:

```astro
<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](https://motion.dev/docs/scroll) 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.

## Related choices

Use [Progress](/components/progress) for task completion and
[Scroll Area](/components/scroll-area) for a bounded native scroll panel.
Use [Toc](/components/toc) for a static contents list.
The section rail takes its direction from [Albina Nikiforova's site](https://albinanikiforova.com/).
The contents spine takes its direction from the [Fumadocs table of contents](https://github.com/fuma-nama/fumadocs/blob/d6cfc48d62aa9ea413f0fc5033617c0c923681ec/packages/base-ui/src/components/toc/default.tsx), as used on the [Better-T-Stack docs](https://www.better-t-stack.dev/docs).
