ScrollSpy
scroll-spyuiIn-page navigation list with scroll-spy. Renders a vertical list of links; the active item highlights as the user scrolls through anchored sections.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/scroll-spy.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/scroll-spy.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/scroll-spy.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/scroll-spy.jsonManifest:
https://uipkge.dev/r/svelte/scroll-spy.jsonInstalls to:src/lib/components/ui/scroll-spy/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
modelValue | string | — | optional |
items | ScrollSpyItemData[] | — | optional |
title | string | — | optional |
offsetTop | number | 0 | optional |
bounds | number | 5 | optional |
scrollContainer | HTMLElement | string | null | — | optional |
affix | boolean | false | optional |
variant | ScrollSpyVariant | — | optional |
turn | ScrollSpyTurn | — | optional |
indicator | ScrollSpyIndicatorMode | line | optional |
keepScrolled | boolean | false | optional |
highlightParent | boolean | true | optional |
lineWidth | ScrollSpyLineWidth | default | optional |
color | ScrollSpyColor | primary | optional |
position | ScrollSpyPosition | right | optional |
railPosition | ScrollSpyRailPosition | — | optional |
onChange | (value: string) => void | — | optional |
onProgress | (value: number) => void | — | optional |
children | Snippet | — | optional |
ref | HTMLElement | null | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
HandleColorResolvedinterface HandleColorResolved {
bgClass: string
strokeClass: string
borderClass: string
customColor?: string
}RegisteredIteminterface RegisteredItem {
value: string
depth: number
el: HTMLElement | null
title?: string
parentValue?: string | null
}ScrollSpyContextinterface ScrollSpyContext {
readonly activeValue: string
setActiveValue: (value: string) => void
readonly scrollProgress: number
registerItem: (item: RegisteredItem) => void
unregisterItem: (value: string) => void
readonly variant: ScrollSpyVariant
readonly turn: ScrollSpyTurn
readonly indicator: ScrollSpyIndicatorMode
readonly keepScrolled: boolean
readonly highlightParent: boolean
readonly lineWidth: ScrollSpyLineWidth
readonly resolvedLineWidth: number
readonly color: ScrollSpyColor
readonly position: ScrollSpyPosition
readonly railPosition: ScrollSpyRailPosition
readonly scrollContainer: HTMLElement | Window | null
readonly offsetTop: number
readonly items: RegisteredItem[]
getListEl: () => HTMLElement | null
setListEl: (el: HTMLElement | null) => void
scrollToHref: (href: string) => void
goToPrev: () => void
goToNext: () => void
isItemActive: (value: string) => boolean
isItemParentActive: (value: string) => boolean
isItemScrolled: (value: string) => boolean
}Files installed (9)
src/lib/components/ui/scroll-spy/ScrollSpy.svelte15.9 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' import type { ScrollSpyColor, ScrollSpyIndicatorMode, ScrollSpyLineWidth, ScrollSpyPosition, ScrollSpyRailPosition, ScrollSpyTurn, ScrollSpyVariant, } from './context' export interface ScrollSpyItemData { href: string title: string depth?: number children?: ScrollSpyItemData[] } export interface ScrollSpyProps extends HTMLAttributes<HTMLElement> { modelValue?: string items?: ScrollSpyItemData[] title?: string offsetTop?: number bounds?: number scrollContainer?: HTMLElement | string | null affix?: boolean variant?: ScrollSpyVariant turn?: ScrollSpyTurn indicator?: ScrollSpyIndicatorMode keepScrolled?: boolean highlightParent?: boolean lineWidth?: ScrollSpyLineWidth color?: ScrollSpyColor position?: ScrollSpyPosition railPosition?: ScrollSpyRailPosition onChange?: (value: string) => void onProgress?: (value: number) => void children?: Snippet ref?: HTMLElement | null } </script> <script lang="ts"> import { setContext } from 'svelte' import { cn } from '$lib/utils' import { SCROLL_SPY_CONTEXT_KEY, type RegisteredItem, type ScrollSpyContext } from './context' import ScrollSpyTitle from './ScrollSpyTitle.svelte' import ScrollSpyList from './ScrollSpyList.svelte' import ScrollSpyIndicator from './ScrollSpyIndicator.svelte' import ScrollSpyItemComp from './ScrollSpyItem.svelte' import ScrollSpyLink from './ScrollSpyLink.svelte' import ScrollSpyStepper from './ScrollSpyStepper.svelte' let { class: className, modelValue = $bindable(''), items = [], title, offsetTop = 0, bounds = 5, scrollContainer = null, affix = false, variant, turn, indicator = 'line', keepScrolled = false, highlightParent = true, lineWidth = 'default', color = 'primary', position = 'right', railPosition, onChange, onProgress, children, ref = $bindable(null), ...restProps }: ScrollSpyProps = $props() const resolvedPosition = $derived(position ?? 'right') const resolvedRailPosition = $derived.by(() => { if (railPosition) return railPosition return position === 'left' ? 'right' : 'left' }) const resolvedTurn = $derived.by(() => { if (turn) return turn if (variant === 'angle' || variant === 'rounded') return 'rounded' if (variant === 'sharp') return 'sharp' if (variant === 'line' || variant === 'default') return 'straight' return 'straight' }) const resolvedVariant = $derived.by(() => { if (variant) return variant if (turn === 'sharp') return 'angle' if (turn === 'rounded') return 'rounded' if (position === 'top' || position === 'bottom') return 'stepper' return 'line' }) const resolvedIndicator = $derived(indicator ?? 'line') let internalActive = $state(modelValue || (items[0]?.href ?? '')) const activeValue = $derived(modelValue || internalActive) function setActiveValue(val: string) { if (internalActive !== val) { internalActive = val modelValue = val onChange?.(val) } } function flattenItems(rawItems: ScrollSpyItemData[]): RegisteredItem[] { const result: RegisteredItem[] = [] function walk(list: ScrollSpyItemData[], depth = 1, parentVal: string | null = null) { for (const it of list) { result.push({ value: it.href, depth: it.depth ?? depth, el: null, title: it.title, parentValue: parentVal, }) if (it.children && it.children.length > 0) { walk(it.children, depth + 1, it.href) } } } walk(rawItems) return result } let registeredItems = $state<RegisteredItem[]>(items && items.length > 0 ? flattenItems(items) : []) let listEl = $state<HTMLElement | null>(null) let resolvedContainer = $state<HTMLElement | Window | null>(null) let readingProgress = $state(0) function resolveContainer(): HTMLElement | Window | null { if (typeof window === 'undefined') return null const sc = scrollContainer if (!sc) return window if (typeof sc === 'function') { return ((sc as () => HTMLElement | Window | null)() ?? window) as HTMLElement | Window } if (typeof sc === 'string') { return (document.querySelector(sc) as HTMLElement) ?? window } return sc as HTMLElement | Window } function registerItem(item: RegisteredItem) { const existingIdx = registeredItems.findIndex((i) => i.value === item.value) if (existingIdx >= 0) { registeredItems[existingIdx] = { ...registeredItems[existingIdx]!, ...item, title: item.title ?? registeredItems[existingIdx]!.title, } } else { registeredItems.push(item) } if (!activeValue && registeredItems.length > 0) { setActiveValue(registeredItems[0]!.value) } } // Re-flatten when the items prop changes, preserving measured elements. $effect(() => { const newItems = items if (newItems && newItems.length > 0) { const flattened = flattenItems(newItems) const existingMap = new Map(registeredItems.map((i) => [i.value, i])) registeredItems = flattened.map((item) => { const existing = existingMap.get(item.value) return { ...item, el: existing?.el ?? null, title: item.title ?? existing?.title, } }) if (!activeValue && registeredItems.length > 0) { setActiveValue(registeredItems[0]!.value) } } }) function unregisterItem(value: string) { registeredItems = registeredItems.filter((i) => i.value !== value) } function scrollToHref(href: string) { setActiveValue(href) const container = resolvedContainer || resolveContainer() const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches const behavior: ScrollBehavior = smooth ? 'smooth' : 'auto' if (container && container !== window) { const cEl = container as HTMLElement const target = cEl.querySelector(href) as HTMLElement | null if (target) { const cRect = cEl.getBoundingClientRect() const tRect = target.getBoundingClientRect() const top = cEl.scrollTop + (tRect.top - cRect.top) - offsetTop cEl.scrollTo({ top, behavior }) } } else { const target = document.querySelector(href) as HTMLElement | null if (target) { const top = window.scrollY + target.getBoundingClientRect().top - offsetTop window.scrollTo({ top, behavior }) } } if (typeof history !== 'undefined') { history.replaceState(null, '', href) } } function goToPrev() { const list = registeredItems const active = activeValue const idx = list.findIndex((i) => i.value === active || i.value.replace(/^#/, '') === active.replace(/^#/, '')) if (idx > 0 && list[idx - 1]) { scrollToHref(list[idx - 1]!.value) } } function goToNext() { const list = registeredItems const active = activeValue const idx = list.findIndex((i) => i.value === active || i.value.replace(/^#/, '') === active.replace(/^#/, '')) if (idx >= 0 && idx < list.length - 1 && list[idx + 1]) { scrollToHref(list[idx + 1]!.value) } } const activeIndex = $derived.by(() => { const current = activeValue if (!current) return -1 const clean = current.replace(/^#/, '') return registeredItems.findIndex((i) => i.value === current || i.value.replace(/^#/, '') === clean) }) function isItemActive(value: string): boolean { if (!value || !activeValue) return false const cleanVal = value.replace(/^#/, '') const cleanActive = activeValue.replace(/^#/, '') return cleanVal === cleanActive } function isItemParentActive(value: string): boolean { if (!highlightParent || !value || !activeValue) return false const cleanVal = value.replace(/^#/, '') const cleanActive = activeValue.replace(/^#/, '') if (cleanVal === cleanActive) return false const activeItem = registeredItems.find( (i) => i.value === activeValue || i.value.replace(/^#/, '') === cleanActive, ) let parent = activeItem?.parentValue while (parent) { if (parent === value || parent.replace(/^#/, '') === cleanVal) return true const pItem = registeredItems.find((i) => i.value === parent || i.value.replace(/^#/, '') === parent!.replace(/^#/, '')) parent = pItem?.parentValue } return false } function isItemScrolled(value: string): boolean { if (!keepScrolled || activeIndex < 0) return false const cleanVal = value.replace(/^#/, '') const idx = registeredItems.findIndex((i) => i.value === value || i.value.replace(/^#/, '') === cleanVal) return idx >= 0 && idx <= activeIndex } const resolvedLineWidth = $derived.by(() => { const lw = lineWidth if (typeof lw === 'number') return Math.max(1, lw) if (lw === 'thin') return 1.5 if (lw === 'thick') return 3.5 return 2.5 }) const ctx: ScrollSpyContext = { get activeValue() { return activeValue }, setActiveValue, get scrollProgress() { return readingProgress }, registerItem, unregisterItem, get variant() { return resolvedVariant }, get turn() { return resolvedTurn }, get indicator() { return resolvedIndicator }, get keepScrolled() { return keepScrolled }, get highlightParent() { return highlightParent }, get lineWidth() { return lineWidth }, get resolvedLineWidth() { return resolvedLineWidth }, get color() { return color }, get position() { return resolvedPosition }, get railPosition() { return resolvedRailPosition }, get scrollContainer() { return resolvedContainer }, get offsetTop() { return offsetTop }, get items() { return registeredItems }, getListEl: () => listEl, setListEl: (el) => { listEl = el }, scrollToHref, goToPrev, goToNext, isItemActive, isItemParentActive, isItemScrolled, } setContext(SCROLL_SPY_CONTEXT_KEY, ctx) // Scroll Spy tracking for both local container and window, plus reading progress function recomputeActive() { const container = resolvedContainer if (!container || registeredItems.length === 0) return const isWin = container === window let currentScroll = 0 let maxScroll = 0 if (isWin) { currentScroll = window.scrollY maxScroll = Math.max(1, document.documentElement.scrollHeight - window.innerHeight) } else { const cEl = container as HTMLElement currentScroll = cEl.scrollTop maxScroll = Math.max(1, cEl.scrollHeight - cEl.clientHeight) } const prog = Math.min(1, Math.max(0, currentScroll / maxScroll)) readingProgress = prog onProgress?.(prog) const containerTop = isWin ? 0 : (container as HTMLElement).getBoundingClientRect().top const triggerThreshold = containerTop + offsetTop + bounds + 40 let currentTarget = '' for (const item of registeredItems) { const selector = item.value.startsWith('#') ? item.value : `#${item.value}` const target = isWin ? document.querySelector(selector) : (container as HTMLElement).querySelector(selector) if (!target) continue const targetRect = target.getBoundingClientRect() if (targetRect.top <= triggerThreshold) { currentTarget = item.value } else if (currentTarget) { break } } const atBottom = isWin ? window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 6 : (container as HTMLElement).scrollTop + (container as HTMLElement).clientHeight >= (container as HTMLElement).scrollHeight - 6 if (atBottom && registeredItems.length > 0) { currentTarget = registeredItems[registeredItems.length - 1]!.value } if (!currentTarget && registeredItems.length > 0) { currentTarget = registeredItems[0]!.value } if (currentTarget && currentTarget !== activeValue) { setActiveValue(currentTarget) } } let rafScroll = 0 function onScroll() { if (typeof cancelAnimationFrame !== 'undefined') { cancelAnimationFrame(rafScroll) } if (typeof requestAnimationFrame !== 'undefined') { rafScroll = requestAnimationFrame(() => { recomputeActive() }) } else { recomputeActive() } } function bindScrollListener() { unbindScrollListener() resolvedContainer = resolveContainer() const c = resolvedContainer if (c && typeof (c as EventTarget).addEventListener === 'function') { ;(c as EventTarget).addEventListener('scroll', onScroll, { passive: true }) } } function unbindScrollListener() { if (typeof cancelAnimationFrame !== 'undefined') { cancelAnimationFrame(rafScroll) } const c = resolvedContainer if (c && typeof (c as EventTarget).removeEventListener === 'function') { ;(c as EventTarget).removeEventListener('scroll', onScroll) } } $effect(() => { // Re-bind when the scroll container identity changes. void scrollContainer bindScrollListener() recomputeActive() const mountTimer = setTimeout(() => { bindScrollListener() recomputeActive() }, 100) return () => { clearTimeout(mountTimer) unbindScrollListener() } }) </script> <nav bind:this={ref} data-uipkge data-slot="scroll-spy" data-position={resolvedPosition} data-rail-position={resolvedRailPosition} data-variant={resolvedVariant} data-turn={resolvedTurn} data-indicator={resolvedIndicator} data-keep-scrolled={keepScrolled ? 'true' : undefined} data-highlight-parent={highlightParent ? 'true' : undefined} data-line-width={lineWidth} data-color={color} aria-label="Scroll spy navigation" class={cn( 'relative flex text-sm', resolvedPosition === 'top' || resolvedPosition === 'bottom' ? 'w-full flex-col' : 'flex-col', affix && (resolvedPosition === 'bottom' ? 'sticky bottom-4' : 'sticky'), className, )} style={affix && resolvedPosition !== 'bottom' ? `top: ${offsetTop}px` : undefined} {...restProps} > {#if items && items.length > 0} <!-- Top Sticky Stepper Mode --> {#if resolvedPosition === 'top'} <ScrollSpyStepper /> {:else if resolvedPosition !== 'bottom'} <!-- Standard Left or Right Vertical Rail --> {#if title} <ScrollSpyTitle>{title}</ScrollSpyTitle> {/if} <ScrollSpyList> <ScrollSpyIndicator /> {#each items as item, itemIdx (item.href)} <ScrollSpyItemComp value={item.href} depth={item.depth ?? 1} class={itemIdx > 0 && items[itemIdx - 1]?.children?.length ? 'mt-2' : undefined} > <ScrollSpyLink href={item.href} title={item.title} /> </ScrollSpyItemComp> {#each (item.children ?? []) as child, childIdx (child.href)} <ScrollSpyItemComp value={child.href} depth={child.depth ?? 2} class={childIdx === 0 || (childIdx > 0 && item.children?.[childIdx - 1]?.children?.length) ? 'mt-2' : undefined} > <ScrollSpyLink href={child.href} title={child.title} /> </ScrollSpyItemComp> {#each (child.children ?? []) as grandchild, gIdx (grandchild.href)} <ScrollSpyItemComp value={grandchild.href} depth={grandchild.depth ?? 3} class={gIdx === 0 ? 'mt-2' : undefined}> <ScrollSpyLink href={grandchild.href} title={grandchild.title} /> </ScrollSpyItemComp> {/each} {/each} {/each} </ScrollSpyList> {/if} <!-- Bottom Floating Stepper Capsule Mode --> {#if resolvedPosition === 'bottom'} <ScrollSpyStepper /> {/if} {:else} {@render children?.()} {/if} </nav>src/lib/components/ui/scroll-spy/ScrollSpyIndicator.svelte11.2 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { ScrollSpyColor } from './context' export interface ScrollSpyIndicatorProps extends HTMLAttributes<HTMLDivElement> { color?: ScrollSpyColor } </script> <script lang="ts"> import { tick } from 'svelte' import { cn } from '$lib/utils' import { getScrollSpyContext, resolveScrollSpyColor } from './context' let { class: className, color, ...restProps }: ScrollSpyIndicatorProps = $props() const ctx = getScrollSpyContext('ScrollSpyIndicator') const resolvedColor = $derived(color ?? ctx.color ?? 'primary') const handleColor = $derived(resolveScrollSpyColor(resolvedColor)) interface Marker { value: string depth: number x: number y: number top: number bottom: number } let measurePathEl: SVGPathElement | null = $state(null) let trackPath = $state('') let pathLength = $state(0) let activeStart = $state(0) let activeEnd = $state(0) let straightHighlight = $state({ top: 0, height: 0, visible: false }) let canAnimate = $state(false) let activeMarker = $state<Marker | null>(null) function depthX(relDepth: number, isRightRail: boolean, listWidth: number): number { if (!isRightRail) { if (relDepth <= 1) return 1 if (relDepth === 2) return 13 if (relDepth === 3) return 21 return 21 + (relDepth - 3) * 8 } // Right side rail const base = listWidth - 1 if (relDepth <= 1) return base if (relDepth === 2) return base - 12 if (relDepth === 3) return base - 20 return base - 20 - (relDepth - 3) * 8 } function collectMarkers(): Marker[] { const list = ctx.getListEl() if (!list) return [] const listRect = list.getBoundingClientRect() const validItems = ctx.items.filter((i) => i.el && i.el.isConnected) if (validItems.length === 0) return [] const isRightRail = ctx.position === 'left' && ctx.railPosition === 'right' const listWidth = listRect.width || 180 const minDepth = Math.min(...validItems.map((i) => i.depth)) const markers: Marker[] = [] for (const item of validItems) { if (!item.el) continue const rect = item.el.getBoundingClientRect() const relDepth = Math.max(1, item.depth - minDepth + 1) const top = rect.top - listRect.top const bottom = rect.bottom - listRect.top markers.push({ value: item.value, depth: item.depth, x: depthX(relDepth, isRightRail, listWidth), y: top + rect.height / 2, top, bottom, }) } return markers.sort((a, b) => a.y - b.y) } function buildCircuitPath(markers: Marker[], endIndex: number, rounded: boolean, edge: 'top' | 'bottom'): string { if (markers.length === 0 || endIndex < 0) return '' const end = Math.min(endIndex, markers.length - 1) const first = markers[0]! const parts: string[] = [`M ${first.x} ${first.top}`] for (let i = 0; i <= end; i++) { const curr = markers[i]! if (i === end) { const targetY = edge === 'top' ? curr.top : curr.bottom parts.push(`L ${curr.x} ${targetY}`) break } const next = markers[i + 1] if (!next) { parts.push(`L ${curr.x} ${curr.bottom}`) break } // Always draw down the full height of curr at curr.x first parts.push(`L ${curr.x} ${curr.bottom}`) if (curr.x === next.x) { parts.push(`L ${curr.x} ${next.top}`) continue } // Smooth monotonic depth transition strictly bounded within [curr.bottom, next.top] const gap = Math.max(0, next.top - curr.bottom) const absDx = Math.abs(next.x - curr.x) const transitionH = Math.min(gap, absDx) if (transitionH <= 1) { parts.push(`L ${next.x} ${next.top}`) continue } const y1 = curr.bottom + (gap - transitionH) / 2 let y2 = y1 + transitionH if (next.top - y2 <= 0.5) { y2 = next.top } // 1. Straight rail down to y1 at curr.x if (y1 - curr.bottom > 0.5) { parts.push(`L ${curr.x} ${y1}`) } // 2. Transition from (curr.x, y1) to (next.x, y2) if (rounded) { const midY = (y1 + y2) / 2 parts.push(`C ${curr.x} ${midY}, ${next.x} ${midY}, ${next.x} ${y2}`) } else { parts.push(`L ${next.x} ${y2}`) } // 3. Connect to next.top if next.top > y2 if (next.top - y2 > 0.5) { parts.push(`L ${next.x} ${next.top}`) } } return parts.join(' ') } function measurePathLength(pathD: string): number { const el = measurePathEl if (!el || !pathD) return 0 el.setAttribute('d', pathD) if (typeof el.getTotalLength === 'function') { try { return el.getTotalLength() } catch { // ignore } } return 100 } async function updateGeometry() { const markers = collectMarkers() const activeValue = ctx.activeValue const activeIndex = markers.findIndex( (m) => m.value === activeValue || m.value.replace(/^#/, '') === activeValue.replace(/^#/, ''), ) const turn = ctx.turn const indicator = ctx.indicator const isRightRail = ctx.position === 'left' && ctx.railPosition === 'right' if (markers.length === 0) { trackPath = '' pathLength = 0 activeStart = 0 activeEnd = 0 activeMarker = null straightHighlight = { top: 0, height: 0, visible: false } return } const first = markers[0]! const last = markers[markers.length - 1]! if (turn === 'straight') { const railX = isRightRail ? first.x : 1 trackPath = `M ${railX} ${first.top} L ${railX} ${last.bottom}` pathLength = 0 activeStart = 0 activeEnd = 0 if (indicator === 'progress') { const list = ctx.getListEl() const totalHeight = list ? list.clientHeight : last.bottom - first.top straightHighlight = { top: first.top, height: Math.max(0, totalHeight * ctx.scrollProgress), visible: true, } activeMarker = null enableAnimation() return } if (activeIndex < 0) { straightHighlight = { top: 0, height: 0, visible: false } activeMarker = null return } const active = markers[activeIndex]! activeMarker = active const isFillMode = indicator === 'fill' || ctx.keepScrolled if (isFillMode) { straightHighlight = { top: first.top, height: Math.max(active.bottom - first.top, 14), visible: true, } } else { straightHighlight = { top: active.top, height: Math.max(active.bottom - active.top, 14), visible: true, } } enableAnimation() return } // Circuit Mode: sharp 45° angle or rounded curves const rounded = turn === 'rounded' const fullPath = buildCircuitPath(markers, markers.length - 1, rounded, 'bottom') trackPath = fullPath straightHighlight = { top: 0, height: 0, visible: false } await tick() const total = measurePathLength(fullPath) pathLength = total if (total === 0) { activeStart = 0 activeEnd = 0 activeMarker = null return } if (indicator === 'progress') { activeStart = 0 activeEnd = total * ctx.scrollProgress activeMarker = null enableAnimation() return } if (activeIndex < 0) { activeStart = 0 activeEnd = 0 activeMarker = null return } const active = markers[activeIndex]! activeMarker = active const isFillMode = indicator === 'fill' || ctx.keepScrolled const startLength = isFillMode ? 0 : measurePathLength(buildCircuitPath(markers, activeIndex, rounded, 'top')) const endLength = measurePathLength(buildCircuitPath(markers, activeIndex, rounded, 'bottom')) activeStart = startLength activeEnd = endLength enableAnimation() } function enableAnimation() { if (!canAnimate) { if (typeof requestAnimationFrame !== 'undefined') { requestAnimationFrame(() => { canAnimate = true }) } else { canAnimate = true } } } let rafId = 0 function scheduleUpdate() { if (typeof window === 'undefined') return if (typeof cancelAnimationFrame !== 'undefined') { cancelAnimationFrame(rafId) } if (typeof requestAnimationFrame !== 'undefined') { rafId = requestAnimationFrame(() => { updateGeometry() }) } else { updateGeometry() } } $effect(() => { scheduleUpdate() window.addEventListener('resize', scheduleUpdate) return () => { if (typeof cancelAnimationFrame !== 'undefined') { cancelAnimationFrame(rafId) } if (typeof window !== 'undefined') { window.removeEventListener('resize', scheduleUpdate) } } }) // Re-measure whenever anything the geometry depends on changes. $effect(() => { void ctx.activeValue void ctx.items void ctx.turn void ctx.indicator void ctx.keepScrolled void ctx.resolvedLineWidth void ctx.position void ctx.railPosition void ctx.scrollProgress scheduleUpdate() }) const turn = $derived(ctx.turn) const indicator = $derived(ctx.indicator) const keepScrolled = $derived(ctx.keepScrolled) const lineWidth = $derived(ctx.resolvedLineWidth) const isRightRail = $derived(ctx.position === 'left' && ctx.railPosition === 'right') </script> <div data-slot="scroll-spy-indicator" class={cn('pointer-events-none absolute inset-0 overflow-visible', className)} aria-hidden="true" {...restProps}> {#if turn !== 'straight'} <svg class="absolute inset-0 size-full overflow-visible" fill="none"> <path bind:this={measurePathEl} class="invisible" fill="none" /> <path d={trackPath} class="stroke-border" stroke-width={lineWidth} stroke-linecap="round" stroke-linejoin="round" /> {#if trackPath && activeEnd > 0} <path d={trackPath} class={handleColor.strokeClass} stroke-width={lineWidth} stroke-linecap="butt" stroke-linejoin="round" style="stroke: {handleColor.customColor}; stroke-dasharray: {Math.max( activeEnd - activeStart, 0, )} {Math.max(pathLength, 1)}; stroke-dashoffset: {-activeStart}; transition: {canAnimate && indicator !== 'progress' ? 'stroke-dashoffset 260ms cubic-bezier(0.16, 1, 0.3, 1), stroke-dasharray 260ms cubic-bezier(0.16, 1, 0.3, 1)' : 'none'};" /> {/if} </svg> {/if} {#if turn === 'straight' && straightHighlight.visible && (indicator !== 'segment' || keepScrolled)} <div class={cn('absolute z-10 rounded-none', handleColor.bgClass)} style="top: {straightHighlight.top}px; height: {straightHighlight.height}px; width: {lineWidth}px; background-color: {handleColor.customColor}; {isRightRail ? `right: -${lineWidth}px;` : `left: -${lineWidth}px;`} transition: {canAnimate && indicator !== 'progress' ? 'top 260ms cubic-bezier(0.16, 1, 0.3, 1), height 260ms cubic-bezier(0.16, 1, 0.3, 1), opacity 200ms ease-out' : 'none'};" ></div> {/if} </div>src/lib/components/ui/scroll-spy/ScrollSpyItem.svelte1.3 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface ScrollSpyItemProps extends HTMLAttributes<HTMLLIElement> { value: string title?: string depth?: number children?: Snippet ref?: HTMLLIElement | null } </script> <script lang="ts"> import { getContext, setContext } from 'svelte' import { cn } from '$lib/utils' import { SCROLL_SPY_CONTEXT_KEY, SCROLL_SPY_ITEM_DEPTH_KEY, type ScrollSpyContext } from './context' let { class: className, value, title, depth, children, ref = $bindable(null), ...restProps }: ScrollSpyItemProps = $props() const ctx = getContext<ScrollSpyContext | undefined>(SCROLL_SPY_CONTEXT_KEY) ?? null const parentDepth = getContext<number | undefined>(SCROLL_SPY_ITEM_DEPTH_KEY) ?? 0 const computedDepth = depth ?? parentDepth + 1 setContext(SCROLL_SPY_ITEM_DEPTH_KEY, computedDepth) $effect(() => { const v = value if (!ctx) return ctx.registerItem({ value: v, title, depth: computedDepth, el: ref }) return () => ctx.unregisterItem(v) }) </script> <li bind:this={ref} data-slot="scroll-spy-item" data-depth={computedDepth} class={cn('relative flex flex-col', className)} {...restProps}> {@render children?.()} </li>src/lib/components/ui/scroll-spy/ScrollSpyLink.svelte4.8 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAnchorAttributes } from 'svelte/elements' export interface ScrollSpyLinkProps extends HTMLAnchorAttributes { href: string title?: string depth?: number children?: Snippet ref?: HTMLAnchorElement | null } </script> <script lang="ts"> import { getContext } from 'svelte' import { cn } from '$lib/utils' import { SCROLL_SPY_ITEM_DEPTH_KEY, resolveScrollSpyColor, getScrollSpyContextOptional } from './context' let { class: className, href, title, depth, children, ref = $bindable(null), ...restProps }: ScrollSpyLinkProps = $props() const ctx = getScrollSpyContextOptional() const itemDepth = getContext<number | undefined>(SCROLL_SPY_ITEM_DEPTH_KEY) ?? 1 const selfDepth = $derived(depth ?? itemDepth) const isActive = $derived(ctx?.isItemActive(href) ?? false) const isParentActive = $derived(ctx?.isItemParentActive(href) ?? false) const isScrolled = $derived(ctx?.isItemScrolled(href) ?? false) const isCircuit = $derived(ctx !== null && ctx.turn !== 'straight') const isLeftWithRightRail = $derived(ctx?.position === 'left' && ctx?.railPosition === 'right') const hasIndicatorBar = $derived( ctx !== null && ctx.turn === 'straight' && (ctx.indicator !== 'segment' || ctx.keepScrolled), ) const handleColor = $derived(resolveScrollSpyColor(ctx?.color ?? 'primary')) const borderActiveClass = $derived.by(() => { if (hasIndicatorBar) { if (isActive || isParentActive) return 'text-foreground font-medium' if (isScrolled) return 'text-foreground/85' return 'text-muted-foreground hover:text-foreground' } if (isActive) return cn(handleColor.borderClass, 'text-foreground font-medium') if (isParentActive) return 'border-border/50 text-foreground font-medium' if (isScrolled) return 'border-border/70 text-foreground/85' return 'text-muted-foreground hover:border-foreground/40 hover:text-foreground' }) const activeBorderStyle = $derived.by(() => { if (isCircuit || hasIndicatorBar || (!isActive && !isParentActive && !isScrolled)) return undefined const w = ctx?.resolvedLineWidth ?? 2.5 if (isLeftWithRightRail) { return `border-right-width: ${w}px; margin-right: -${w}px;${isActive && handleColor.customColor ? ` border-color: ${handleColor.customColor};` : ''}` } return `border-left-width: ${w}px; margin-left: -${w}px;${isActive && handleColor.customColor ? ` border-color: ${handleColor.customColor};` : ''}` }) function onClick(e: MouseEvent) { e.preventDefault() if (!ctx) return ctx.scrollToHref(href) } </script> <a bind:this={ref} {href} data-slot="scroll-spy-link" aria-current={isActive ? 'location' : undefined} data-active={isActive ? 'true' : 'false'} data-parent-active={isParentActive ? 'true' : 'false'} data-scrolled={isScrolled ? 'true' : 'false'} data-depth={selfDepth} style={activeBorderStyle} class={cn( 'group block rounded-none leading-snug no-underline transition-[color,border-color,background-color,opacity,border-width,margin] duration-200 ease-out', 'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none', isCircuit ? [ 'py-1', isLeftWithRightRail ? [ 'text-right', selfDepth <= 1 && 'pr-4 pl-2 text-sm', selfDepth === 2 && 'pr-7 pl-2 text-xs', selfDepth === 3 && 'pr-10 pl-2 text-xs', selfDepth >= 4 && 'pr-12 pl-2 text-xs', ] : [ selfDepth <= 1 && 'pr-2 pl-4 text-sm', selfDepth === 2 && 'pr-2 pl-7 text-xs', selfDepth === 3 && 'pr-2 pl-10 text-xs', selfDepth >= 4 && 'pr-2 pl-12 text-xs', ], isActive || isParentActive ? 'text-foreground font-medium' : isScrolled ? 'text-foreground/85' : 'text-muted-foreground hover:text-foreground', ] : isLeftWithRightRail ? [ '-mr-px border-r border-transparent py-0.5 pr-3 pl-2 text-right', selfDepth <= 1 && 'text-sm', selfDepth === 2 && 'pr-6 text-xs', selfDepth === 3 && 'pr-9 text-xs', selfDepth >= 4 && 'pr-11 text-xs', borderActiveClass, ] : [ '-ml-px border-l border-transparent py-0.5 pr-2 pl-3', selfDepth <= 1 && 'text-sm', selfDepth === 2 && 'pl-6 text-xs', selfDepth === 3 && 'pl-9 text-xs', selfDepth >= 4 && 'pl-11 text-xs', borderActiveClass, ], className, )} onclick={onClick} {...restProps} > {#if children} {@render children()} {:else} {title} {/if} </a>src/lib/components/ui/scroll-spy/ScrollSpyList.svelte1.2 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface ScrollSpyListProps extends HTMLAttributes<HTMLUListElement> { children?: Snippet ref?: HTMLUListElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getScrollSpyContextOptional } from './context' let { class: className, children, ref = $bindable(null), ...restProps }: ScrollSpyListProps = $props() const ctx = getScrollSpyContextOptional() const isStraight = $derived(ctx?.turn === 'straight') const isRightRail = $derived(ctx?.position === 'left' && ctx?.railPosition === 'right') const lineWidthPx = $derived(`${ctx?.resolvedLineWidth ?? 2.5}px`) $effect(() => { if (ctx && ref) ctx.setListEl(ref) }) </script> <ul bind:this={ref} data-slot="scroll-spy-list" style="border-left-width: {isStraight && !isRightRail ? lineWidthPx : '0'}; border-right-width: {isStraight && isRightRail ? lineWidthPx : '0'};" class={cn( 'relative flex flex-col space-y-1 text-sm', isStraight && (isRightRail ? 'border-border border-r' : 'border-border border-l'), className, )} {...restProps} > {@render children?.()} </ul>src/lib/components/ui/scroll-spy/ScrollSpyTitle.svelte0.8 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface ScrollSpyTitleProps extends HTMLAttributes<HTMLParagraphElement> { children?: Snippet } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getScrollSpyContextOptional } from './context' let { class: className, children, ...restProps }: ScrollSpyTitleProps = $props() const ctx = getScrollSpyContextOptional() const isRightRail = $derived(ctx?.position === 'left' && ctx?.railPosition === 'right') </script> <p data-slot="scroll-spy-title" class={cn('text-foreground mb-3 text-sm font-semibold tracking-tight', isRightRail && 'pr-3 text-right', className)} {...restProps}> {@render children?.()} </p>src/lib/components/ui/scroll-spy/ScrollSpyStepper.svelte8.4 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface ScrollSpyStepperProps extends HTMLAttributes<HTMLDivElement> {} </script> <script lang="ts"> import { cn } from '$lib/utils' import { getScrollSpyContext } from './context' let { class: className, ...restProps }: ScrollSpyStepperProps = $props() const ctx = getScrollSpyContext('ScrollSpyStepper') const items = $derived(ctx.items) const activeValue = $derived(ctx.activeValue) const scrollProgress = $derived(ctx.scrollProgress) const position = $derived(ctx.position) const activeIndex = $derived.by(() => { const list = items const active = activeValue const idx = list.findIndex((i) => i.value === active || i.value.replace(/^#/, '') === active.replace(/^#/, '')) return idx >= 0 ? idx : 0 }) function displayTitle(value: string | undefined, title: string | undefined): string { if (title) return title if (!value) return '' return value .replace(/^#/, '') .replace(/[-_]/g, ' ') .replace(/\b\w/g, (c) => c.toUpperCase()) } const activeItem = $derived(items[activeIndex]) const activeTitle = $derived(displayTitle(activeItem?.value, activeItem?.title)) const isTop = $derived(position === 'top') const isBottom = $derived(position === 'bottom') const variant = $derived(ctx.variant) const indicator = $derived(ctx.indicator) const isScrollSpy = $derived( variant === 'scrollspy' || variant === 'tabs' || variant === 'pills' || indicator === 'pill' || indicator === 'dot', ) </script> <!-- Top Sticky Scroll Spy Bar (Highlights ONLY the active section) --> {#if isTop && isScrollSpy} <div data-slot="scroll-spy-top" class={cn( 'border-border/70 bg-card/85 relative sticky top-0 z-20 flex w-full scrollbar-none items-center gap-1 overflow-x-auto rounded-xl border p-1.5 shadow-xs backdrop-blur-md', className, )} {...restProps} > {#each items as item, idx (item.value)} <button type="button" data-active={idx === activeIndex ? 'true' : 'false'} class={cn( 'group relative flex shrink-0 items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-[color,background-color,box-shadow,scale] duration-150 outline-none select-none', 'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-1 active:scale-[0.98]', idx === activeIndex ? 'bg-primary/10 text-primary font-semibold shadow-2xs' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground', )} onclick={() => ctx.scrollToHref(item.value)} > <!-- Active Dot Indicator --> {#if idx === activeIndex} <span class="bg-primary size-1.5 shrink-0 rounded-full"></span> {/if} <!-- Section Title --> <span class="truncate"> {displayTitle(item.value, item.title)} </span> </button> {/each} <!-- Slim Bottom Reading Progress Line --> <div class="bg-border/20 absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-xl"> <div class="bg-primary h-full transition-[width] duration-150 ease-out" style="width: {Math.round(scrollProgress * 100)}%"></div> </div> </div> {:else if isTop} <!-- Top Sticky Stepper Bar (Cumulative Step Wizard with Step Numbers & Connecting Lines) --> <div data-slot="scroll-spy-stepper-top" class={cn( 'border-border/70 bg-card/85 sticky top-0 z-20 flex w-full scrollbar-none items-center gap-1.5 overflow-x-auto rounded-xl border p-2 shadow-xs backdrop-blur-md', className, )} {...restProps} > {#each items as item, idx (item.value)} <!-- Step Node Button --> <button type="button" data-active={idx === activeIndex ? 'true' : 'false'} class={cn( 'group flex shrink-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-xs font-medium transition-[color,background-color,scale] duration-150 outline-none select-none', 'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-1 active:scale-[0.98]', idx === activeIndex ? 'bg-primary/10 text-foreground font-medium shadow-2xs' : idx < activeIndex ? 'text-foreground/80 hover:bg-muted/50' : 'text-muted-foreground hover:bg-muted/30 hover:text-foreground', )} onclick={() => ctx.scrollToHref(item.value)} > <!-- Step Number / Dot --> <span class={cn( 'flex size-5 shrink-0 items-center justify-center rounded-full font-mono text-[10px] transition-[color,background-color,box-shadow,scale] duration-200', idx === activeIndex ? 'bg-primary text-primary-foreground scale-105 font-semibold shadow-2xs' : idx < activeIndex ? 'bg-primary/15 text-primary font-medium' : 'bg-muted text-muted-foreground/70', )} > {#if idx < activeIndex} <svg class="size-3 stroke-[2.5]" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /> </svg> {:else} <span>{idx + 1}</span> {/if} </span> <!-- Step Title --> <span class="max-w-[120px] truncate"> {displayTitle(item.value, item.title)} </span> </button> <!-- Connecting Progress Line --> {#if idx < items.length - 1} <div class={cn( 'bg-border/70 h-0.5 max-w-10 min-w-4 flex-1 rounded-full transition-colors duration-200', idx < activeIndex && 'bg-primary', )} ></div> {/if} {/each} </div> {:else if isBottom} <!-- Bottom Floating Stepper Capsule --> <div data-slot="scroll-spy-stepper-bottom" class={cn( 'border-border/80 bg-background/95 sticky bottom-3 z-30 mx-auto flex items-center gap-2 rounded-full border px-3 py-1.5 shadow-md backdrop-blur-md select-none', className, )} {...restProps} > <!-- Previous Section Button --> <button type="button" aria-label="Previous section" disabled={activeIndex <= 0} class="text-muted-foreground hover:bg-muted/80 hover:text-foreground flex size-7 items-center justify-center rounded-full transition-[color,background-color,opacity,scale] active:scale-95 disabled:pointer-events-none disabled:opacity-25" onclick={ctx.goToPrev} > <svg class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /> </svg> </button> <!-- Segment / Dot Indicators --> <div class="flex items-center gap-1.5 px-1"> {#each items as item, idx (item.value)} <button type="button" aria-label={`Jump to section ${idx + 1}`} class={cn( 'h-1.5 cursor-pointer rounded-full transition-[width,background-color] duration-200', idx === activeIndex ? 'bg-primary w-5' : idx < activeIndex ? 'bg-primary/40 hover:bg-primary/60 w-2' : 'bg-muted-foreground/30 hover:bg-muted-foreground/50 w-2', )} onclick={() => ctx.scrollToHref(item.value)} ></button> {/each} </div> <!-- Active Section Name --> <div class="border-border/60 flex items-center gap-2 border-l pl-2"> <span class="text-foreground max-w-[130px] truncate text-xs font-medium tracking-tight"> {activeTitle} </span> <span class="bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 font-mono text-[10px] tabular-nums"> {Math.round(scrollProgress * 100)}% </span> </div> <!-- Next Section Button --> <button type="button" aria-label="Next section" disabled={activeIndex >= items.length - 1} class="text-muted-foreground hover:bg-muted/80 hover:text-foreground flex size-7 items-center justify-center rounded-full transition-[color,background-color,opacity,scale] active:scale-95 disabled:pointer-events-none disabled:opacity-25" onclick={ctx.goToNext} > <svg class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> </svg> </button> </div> {/if}src/lib/components/ui/scroll-spy/context.ts4 kB
import { getContext } from 'svelte' export type ScrollSpyTurn = 'straight' | 'sharp' | 'rounded' export type ScrollSpyVariant = 'default' | 'line' | 'angle' | 'sharp' | 'rounded' | 'stepper' | 'scrollspy' | 'tabs' | 'pills' export type ScrollSpyIndicatorMode = 'segment' | 'fill' | 'progress' | 'pill' | 'dot' | 'line' export type ScrollSpyPosition = 'right' | 'left' | 'top' | 'bottom' export type ScrollSpyRailPosition = 'left' | 'right' export type ScrollSpyLineWidth = 'thin' | 'default' | 'thick' | number export type ScrollSpyColor = 'primary' | 'foreground' | 'destructive' | 'secondary' | (string & {}) export interface HandleColorResolved { bgClass: string strokeClass: string borderClass: string customColor?: string } export function resolveScrollSpyColor(color: ScrollSpyColor = 'primary'): HandleColorResolved { if (color === 'primary') return { bgClass: 'bg-primary', strokeClass: 'stroke-primary', borderClass: 'border-primary', customColor: undefined, } if (color === 'foreground') return { bgClass: 'bg-foreground', strokeClass: 'stroke-foreground', borderClass: 'border-foreground', customColor: undefined, } if (color === 'destructive') return { bgClass: 'bg-destructive', strokeClass: 'stroke-destructive', borderClass: 'border-destructive', customColor: undefined, } if (color === 'secondary') return { bgClass: 'bg-secondary', strokeClass: 'stroke-secondary', borderClass: 'border-secondary', customColor: undefined, } if (color.startsWith('bg-')) { const raw = color.replace(/^bg-/, '') return { bgClass: color, strokeClass: `stroke-${raw}`, borderClass: `border-${raw}`, customColor: undefined } } if ( color.startsWith('#') || color.startsWith('oklch') || color.startsWith('rgb') || color.startsWith('hsl') || color.startsWith('var(') ) { return { bgClass: '', strokeClass: '', borderClass: '', customColor: color } } return { bgClass: `bg-${color}`, strokeClass: `stroke-${color}`, borderClass: `border-${color}`, customColor: undefined, } } export interface RegisteredItem { value: string depth: number el: HTMLElement | null title?: string parentValue?: string | null } /** * Shared scroll-spy state, provided by `ScrollSpy` and consumed by the parts. * Readonly fields are implemented as getters over runes state in `ScrollSpy`, * so `$derived(ctx.activeValue)` in a part stays reactive. */ export interface ScrollSpyContext { readonly activeValue: string setActiveValue: (value: string) => void readonly scrollProgress: number registerItem: (item: RegisteredItem) => void unregisterItem: (value: string) => void readonly variant: ScrollSpyVariant readonly turn: ScrollSpyTurn readonly indicator: ScrollSpyIndicatorMode readonly keepScrolled: boolean readonly highlightParent: boolean readonly lineWidth: ScrollSpyLineWidth readonly resolvedLineWidth: number readonly color: ScrollSpyColor readonly position: ScrollSpyPosition readonly railPosition: ScrollSpyRailPosition readonly scrollContainer: HTMLElement | Window | null readonly offsetTop: number readonly items: RegisteredItem[] getListEl: () => HTMLElement | null setListEl: (el: HTMLElement | null) => void scrollToHref: (href: string) => void goToPrev: () => void goToNext: () => void isItemActive: (value: string) => boolean isItemParentActive: (value: string) => boolean isItemScrolled: (value: string) => boolean } export const SCROLL_SPY_CONTEXT_KEY = Symbol('uipkge-scroll-spy-context') export const SCROLL_SPY_ITEM_DEPTH_KEY = Symbol('uipkge-scroll-spy-depth') export function getScrollSpyContext(component: string): ScrollSpyContext { const ctx = getContext<ScrollSpyContext | undefined>(SCROLL_SPY_CONTEXT_KEY) if (!ctx) throw new Error(`${component} must be used inside <ScrollSpy>.`) return ctx } export function getScrollSpyContextOptional(): ScrollSpyContext | null { return getContext<ScrollSpyContext | undefined>(SCROLL_SPY_CONTEXT_KEY) ?? null }src/lib/components/ui/scroll-spy/index.ts1 kB
export { default as ScrollSpy, type ScrollSpyProps, type ScrollSpyItemData } from './ScrollSpy.svelte' export { default as ScrollSpyRoot, type ScrollSpyProps as ScrollSpyRootProps } from './ScrollSpy.svelte' export { default as ScrollSpyTitle, type ScrollSpyTitleProps } from './ScrollSpyTitle.svelte' export { default as ScrollSpyList, type ScrollSpyListProps } from './ScrollSpyList.svelte' export { default as ScrollSpyIndicator, type ScrollSpyIndicatorProps } from './ScrollSpyIndicator.svelte' export { default as ScrollSpyItem, type ScrollSpyItemProps } from './ScrollSpyItem.svelte' export { default as ScrollSpyLink, type ScrollSpyLinkProps } from './ScrollSpyLink.svelte' export { default as ScrollSpyStepper, type ScrollSpyStepperProps } from './ScrollSpyStepper.svelte' export { type ScrollSpyVariant, type ScrollSpyTurn, type ScrollSpyIndicatorMode, type ScrollSpyPosition, type ScrollSpyRailPosition, type ScrollSpyLineWidth, type ScrollSpyColor, } from './context'
Raw manifest:https://uipkge.dev/r/svelte/scroll-spy.json