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
---
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
bunx shadcn@latest add @gnostic/scroll-spynpx shadcn@latest add @gnostic/scroll-spypnpm dlx shadcn@latest add @gnostic/scroll-spyComplete the Installation guide first. Then copy the component files below.
Install component packages
bun add motionCopy 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
---
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.
Orbit
Scroll through this section to fill the indicator.
0% at the start · 100% at the end
---
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.
Horizontal
Scroll through this section to fill the indicator.
0% at the start · 100% at the end
---
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.
---
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.
---
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.
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.
---
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>
depth?number
Heading depth for the `toc` spine. Depth 2 is a page section. Depth 3 and 4 step the line inward.
number2Position 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.
Related choices
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.