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-vue@latest add https://uipkge.dev/r/vue/scroll-spy.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/scroll-spy.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/scroll-spy.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/scroll-spy.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/scroll-spyInstalls to:app/components/ui/scroll-spy/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
modelValue | string | '' | optional |
items | ScrollSpyItem[] | () => [], title: undefined, offsetTop: 0, bounds: 5, scro… | optional |
title | string | — | optional |
offsetTop | number | — | optional |
bounds | number | — | optional |
scrollContainer | HTMLElement | string | null | — | optional |
affix | boolean | — | optional |
variant | ScrollSpyVariant | — | optional |
turn | ScrollSpyTurn | — | optional |
indicator | ScrollSpyIndicatorMode | — | optional |
keepScrolled | boolean | — | optional |
highlightParent | boolean | — | optional |
lineWidth | ScrollSpyLineWidth | — | optional |
color | ScrollSpyColor | — | optional |
position | ScrollSpyPosition | — | optional |
railPosition | ScrollSpyRailPosition | — | optional |
class | HTMLAttributes['class'] | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
Markerinterface Marker {
value: string
depth: number
x: number
y: number
top: number
bottom: number
}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 {
activeValue: Ref<string>
setActiveValue: (value: string) => void
scrollProgress: Ref<number>
registerItem: (item: RegisteredItem) => void
unregisterItem: (value: string) => void
variant: Ref<ScrollSpyVariant>
turn: Ref<ScrollSpyTurn>
indicator: Ref<ScrollSpyIndicatorMode>
keepScrolled: Ref<boolean>
highlightParent: Ref<boolean>
lineWidth: Ref<ScrollSpyLineWidth>
resolvedLineWidth: Ref<number>
color: Ref<ScrollSpyColor>
position: Ref<ScrollSpyPosition>
railPosition: Ref<ScrollSpyRailPosition>
scrollProgressSmooth: Ref<boolean>
scrollContainer: Ref<HTMLElement | Window | null>
offsetTop: Ref<number>
items: Ref<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
}ScrollSpyIteminterface ScrollSpyItem {
href: string
title: string
depth?: number
children?: ScrollSpyItem[]
}npm dependencies
Files installed (9)
app/components/ui/scroll-spy/ScrollSpy.vue15.9 kB
<script setup lang="ts"> import { computed, onBeforeUnmount, onMounted, provide, ref, toRef, useTemplateRef, watch } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import type { ScrollSpyItem } from '.' import { SCROLL_SPY_CONTEXT_KEY, type ScrollSpyTurn, type ScrollSpyVariant, type ScrollSpyIndicatorMode, type ScrollSpyPosition, type ScrollSpyRailPosition, type ScrollSpyLineWidth, type ScrollSpyColor, type RegisteredItem, } from './context' import ScrollSpyTitle from './ScrollSpyTitle.vue' import ScrollSpyList from './ScrollSpyList.vue' import ScrollSpyIndicator from './ScrollSpyIndicator.vue' import ScrollSpyItemComp from './ScrollSpyItem.vue' import ScrollSpyLink from './ScrollSpyLink.vue' import ScrollSpyStepper from './ScrollSpyStepper.vue' const props = withDefaults( defineProps<{ modelValue?: string items?: ScrollSpyItem[] 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 class?: HTMLAttributes['class'] }>(), { modelValue: '', items: () => [], title: undefined, offsetTop: 0, bounds: 5, scrollContainer: null, affix: false, variant: undefined, turn: undefined, indicator: 'line', keepScrolled: false, highlightParent: true, lineWidth: 'default', color: 'primary', position: 'right', railPosition: undefined, }, ) const emits = defineEmits<{ (e: 'update:modelValue', value: string): void (e: 'change', value: string): void (e: 'progress', value: number): void }>() const resolvedPosition = computed<ScrollSpyPosition>(() => props.position ?? 'right') const resolvedRailPosition = computed<ScrollSpyRailPosition>(() => { if (props.railPosition) return props.railPosition return props.position === 'left' ? 'right' : 'left' }) const resolvedTurn = computed<ScrollSpyTurn>(() => { if (props.turn) return props.turn if (props.variant === 'angle' || props.variant === 'rounded') return 'rounded' if (props.variant === 'sharp') return 'sharp' if (props.variant === 'line' || props.variant === 'default') return 'straight' return 'straight' }) const resolvedVariant = computed<ScrollSpyVariant>(() => { if (props.variant) return props.variant if (props.turn === 'sharp') return 'angle' if (props.turn === 'rounded') return 'rounded' if (props.position === 'top' || props.position === 'bottom') return 'stepper' return 'line' }) const resolvedIndicator = computed<ScrollSpyIndicatorMode>(() => props.indicator ?? 'line') const internalActive = ref(props.modelValue || (props.items[0]?.href ?? '')) const activeValue = computed({ get: () => props.modelValue || internalActive.value, set: (val: string) => { if (internalActive.value !== val) { internalActive.value = val emits('update:modelValue', val) emits('change', val) } }, }) function flattenItems(rawItems: ScrollSpyItem[]): RegisteredItem[] { const result: RegisteredItem[] = [] function walk(list: ScrollSpyItem[], 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 } const registeredItems = ref<RegisteredItem[]>(props.items && props.items.length > 0 ? flattenItems(props.items) : []) const listEl = ref<HTMLElement | null>(null) const resolvedContainer = ref<HTMLElement | Window | null>(null) const readingProgress = ref(0) const scrollProgressSmooth = ref(true) function resolveContainer(): HTMLElement | Window | null { if (typeof window === 'undefined') return null const sc = props.scrollContainer if (!sc) return window if (typeof sc === 'function') { return (sc as () => HTMLElement | Window | null)() ?? window } if (typeof sc === 'string') { return (document.querySelector(sc) as HTMLElement) ?? window } return sc as HTMLElement | Window } function registerItem(item: RegisteredItem) { const existingIdx = registeredItems.value.findIndex((i) => i.value === item.value) if (existingIdx >= 0) { registeredItems.value[existingIdx] = { ...registeredItems.value[existingIdx]!, ...item, title: item.title ?? registeredItems.value[existingIdx]!.title, } } else { registeredItems.value.push(item) } if (!activeValue.value && registeredItems.value.length > 0) { activeValue.value = registeredItems.value[0]!.value } } watch( () => props.items, (newItems) => { if (newItems && newItems.length > 0) { const flattened = flattenItems(newItems) const existingMap = new Map(registeredItems.value.map((i) => [i.value, i])) registeredItems.value = flattened.map((item) => { const existing = existingMap.get(item.value) return { ...item, el: existing?.el ?? null, title: item.title ?? existing?.title, } }) if (!activeValue.value && registeredItems.value.length > 0) { activeValue.value = registeredItems.value[0]!.value } } }, { deep: true }, ) function unregisterItem(value: string) { registeredItems.value = registeredItems.value.filter((i) => i.value !== value) } function setActiveValue(value: string) { activeValue.value = value } function scrollToHref(href: string) { setActiveValue(href) const container = resolvedContainer.value || resolveContainer() const offset = props.offsetTop 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) - offset cEl.scrollTo({ top, behavior }) } } else { const target = document.querySelector(href) as HTMLElement | null if (target) { const top = window.scrollY + target.getBoundingClientRect().top - offset window.scrollTo({ top, behavior }) } } if (typeof history !== 'undefined') { history.replaceState(null, '', href) } } function goToPrev() { const list = registeredItems.value const active = activeValue.value 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.value const active = activeValue.value 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 = computed(() => { const current = activeValue.value if (!current) return -1 const clean = current.replace(/^#/, '') return registeredItems.value.findIndex((i) => i.value === current || i.value.replace(/^#/, '') === clean) }) function isItemActive(value: string): boolean { if (!value || !activeValue.value) return false const cleanVal = value.replace(/^#/, '') const cleanActive = activeValue.value.replace(/^#/, '') return cleanVal === cleanActive } function isItemParentActive(value: string): boolean { if (!props.highlightParent || !value || !activeValue.value) return false const cleanVal = value.replace(/^#/, '') const cleanActive = activeValue.value.replace(/^#/, '') if (cleanVal === cleanActive) return false const activeItem = registeredItems.value.find( (i) => i.value === activeValue.value || i.value.replace(/^#/, '') === cleanActive, ) let parent = activeItem?.parentValue while (parent) { if (parent === value || parent.replace(/^#/, '') === cleanVal) return true const pItem = registeredItems.value.find( (i) => i.value === parent || i.value.replace(/^#/, '') === parent!.replace(/^#/, ''), ) parent = pItem?.parentValue } return false } function isItemScrolled(value: string): boolean { if (!props.keepScrolled || activeIndex.value < 0) return false const cleanVal = value.replace(/^#/, '') const idx = registeredItems.value.findIndex((i) => i.value === value || i.value.replace(/^#/, '') === cleanVal) return idx >= 0 && idx <= activeIndex.value } const resolvedLineWidth = computed<number>(() => { const lw = props.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 }) provide(SCROLL_SPY_CONTEXT_KEY, { activeValue, setActiveValue, scrollProgress: readingProgress, registerItem, unregisterItem, variant: resolvedVariant, turn: resolvedTurn, indicator: resolvedIndicator, keepScrolled: toRef(props, 'keepScrolled'), highlightParent: toRef(props, 'highlightParent'), lineWidth: toRef(props, 'lineWidth'), resolvedLineWidth, color: toRef(props, 'color'), position: resolvedPosition, railPosition: resolvedRailPosition, scrollProgressSmooth, scrollContainer: resolvedContainer, offsetTop: toRef(props, 'offsetTop'), items: registeredItems, getListEl: () => listEl.value, setListEl: (el) => { listEl.value = el }, scrollToHref, goToPrev, goToNext, isItemActive, isItemParentActive, isItemScrolled, }) // Scroll Spy tracking for both local container and window, plus reading progress function recomputeActive() { const container = resolvedContainer.value if (!container || registeredItems.value.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.value = prog emits('progress', prog) const containerTop = isWin ? 0 : (container as HTMLElement).getBoundingClientRect().top const triggerThreshold = containerTop + props.offsetTop + props.bounds + 40 let currentTarget = '' for (const item of registeredItems.value) { 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.value.length > 0) { currentTarget = registeredItems.value[registeredItems.value.length - 1]!.value } if (!currentTarget && registeredItems.value.length > 0) { currentTarget = registeredItems.value[0]!.value } if (currentTarget && currentTarget !== activeValue.value) { activeValue.value = 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.value = resolveContainer() const c = resolvedContainer.value 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.value if (c && typeof (c as EventTarget).removeEventListener === 'function') { ;(c as EventTarget).removeEventListener('scroll', onScroll) } } let mountTimer: ReturnType<typeof setTimeout> | null = null onMounted(() => { bindScrollListener() recomputeActive() mountTimer = setTimeout(() => { bindScrollListener() recomputeActive() }, 100) }) onBeforeUnmount(() => { if (mountTimer) { clearTimeout(mountTimer) mountTimer = null } unbindScrollListener() }) watch( () => props.scrollContainer, () => { bindScrollListener() recomputeActive() }, ) const navRef = useTemplateRef<HTMLElement>('navRef') </script> <template> <nav ref="navRef" 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'), props.class, ) " :style="affix && resolvedPosition !== 'bottom' ? { top: `${offsetTop}px` } : undefined" > <template v-if="items && items.length > 0"> <!-- Top Sticky Stepper Mode --> <ScrollSpyStepper v-if="resolvedPosition === 'top'" /> <!-- Standard Left or Right Vertical Rail --> <template v-else-if="resolvedPosition !== 'bottom'"> <ScrollSpyTitle v-if="title">{{ title }}</ScrollSpyTitle> <ScrollSpyList> <ScrollSpyIndicator /> <template v-for="(item, itemIdx) in items" :key="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> <template v-for="(child, childIdx) in item.children ?? []" :key="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> <template v-for="(grandchild, gIdx) in child.children ?? []" :key="grandchild.href"> <ScrollSpyItemComp :value="grandchild.href" :depth="grandchild.depth ?? 3" :class="gIdx === 0 ? 'mt-2' : undefined" > <ScrollSpyLink :href="grandchild.href" :title="grandchild.title" /> </ScrollSpyItemComp> </template> </template> </template> </ScrollSpyList> </template> <!-- Bottom Floating Stepper Capsule Mode --> <ScrollSpyStepper v-if="resolvedPosition === 'bottom'" /> </template> <slot v-else /> </nav> </template>app/components/ui/scroll-spy/ScrollSpyIndicator.vue11.2 kB
<script setup lang="ts"> import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { SCROLL_SPY_CONTEXT_KEY, resolveScrollSpyColor, type ScrollSpyColor } from './context' const props = defineProps<{ class?: HTMLAttributes['class'] color?: ScrollSpyColor }>() const ctx = inject(SCROLL_SPY_CONTEXT_KEY, null) if (!ctx) throw new Error('ScrollSpyIndicator must be used inside <ScrollSpy>.') const resolvedColor = computed(() => props.color ?? ctx.color.value ?? 'primary') const handleColor = computed(() => resolveScrollSpyColor(resolvedColor.value)) interface Marker { value: string depth: number x: number y: number top: number bottom: number } const measurePathRef = useTemplateRef<SVGPathElement>('measurePathRef') const trackPath = ref('') const pathLength = ref(0) const activeStart = ref(0) const activeEnd = ref(0) const straightHighlight = ref({ top: 0, height: 0, visible: false }) const canAnimate = ref(false) const activeMarker = ref<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 || !ctx) return [] const listRect = list.getBoundingClientRect() const validItems = ctx.items.value.filter((i) => i.el && i.el.isConnected) if (validItems.length === 0) return [] const isRightRail = ctx.position.value === 'left' && ctx.railPosition.value === '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 = measurePathRef.value 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() { if (!ctx) return const markers = collectMarkers() const activeValue = ctx.activeValue.value const activeIndex = markers.findIndex( (m) => m.value === activeValue || m.value.replace(/^#/, '') === activeValue.replace(/^#/, ''), ) const turn = ctx.turn.value const indicator = ctx.indicator.value const isRightRail = ctx.position.value === 'left' && ctx.railPosition.value === 'right' if (markers.length === 0) { trackPath.value = '' pathLength.value = 0 activeStart.value = 0 activeEnd.value = 0 activeMarker.value = null straightHighlight.value = { 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.value = `M ${railX} ${first.top} L ${railX} ${last.bottom}` pathLength.value = 0 activeStart.value = 0 activeEnd.value = 0 if (indicator === 'progress') { const list = ctx.getListEl() const totalHeight = list ? list.clientHeight : last.bottom - first.top straightHighlight.value = { top: first.top, height: Math.max(0, totalHeight * ctx.scrollProgress.value), visible: true, } activeMarker.value = null enableAnimation() return } if (activeIndex < 0) { straightHighlight.value = { top: 0, height: 0, visible: false } activeMarker.value = null return } const active = markers[activeIndex]! activeMarker.value = active const isFillMode = indicator === 'fill' || (ctx?.keepScrolled.value ?? false) if (isFillMode) { straightHighlight.value = { top: first.top, height: Math.max(active.bottom - first.top, 14), visible: true, } } else { straightHighlight.value = { 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.value = fullPath straightHighlight.value = { top: 0, height: 0, visible: false } await nextTick() const total = measurePathLength(fullPath) pathLength.value = total if (total === 0) { activeStart.value = 0 activeEnd.value = 0 activeMarker.value = null return } if (indicator === 'progress') { activeStart.value = 0 activeEnd.value = total * ctx.scrollProgress.value activeMarker.value = null enableAnimation() return } if (activeIndex < 0) { activeStart.value = 0 activeEnd.value = 0 activeMarker.value = null return } const active = markers[activeIndex]! activeMarker.value = active const isFillMode = indicator === 'fill' || (ctx?.keepScrolled.value ?? false) const startLength = isFillMode ? 0 : measurePathLength(buildCircuitPath(markers, activeIndex, rounded, 'top')) const endLength = measurePathLength(buildCircuitPath(markers, activeIndex, rounded, 'bottom')) activeStart.value = startLength activeEnd.value = endLength enableAnimation() } function enableAnimation() { if (!canAnimate.value) { if (typeof requestAnimationFrame !== 'undefined') { requestAnimationFrame(() => { canAnimate.value = true }) } else { canAnimate.value = 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() } } onMounted(() => { scheduleUpdate() window.addEventListener('resize', scheduleUpdate) }) onBeforeUnmount(() => { if (typeof cancelAnimationFrame !== 'undefined') { cancelAnimationFrame(rafId) } if (typeof window !== 'undefined') { window.removeEventListener('resize', scheduleUpdate) } }) watch( () => [ ctx?.activeValue.value, ctx?.items.value, ctx?.turn.value, ctx?.indicator.value, ctx?.keepScrolled.value, ctx?.resolvedLineWidth.value, ctx?.position.value, ctx?.railPosition.value, ctx?.scrollProgress.value, ], () => { scheduleUpdate() }, { deep: true }, ) </script> <template> <div data-slot="scroll-spy-indicator" :class="cn('pointer-events-none absolute inset-0 overflow-visible', props.class)" aria-hidden="true" > <svg v-if="ctx?.turn.value !== 'straight'" class="absolute inset-0 size-full overflow-visible" fill="none"> <path ref="measurePathRef" class="invisible" fill="none" /> <path :d="trackPath" class="stroke-border" :stroke-width="ctx?.resolvedLineWidth.value ?? 2.5" stroke-linecap="round" stroke-linejoin="round" /> <path v-if="trackPath && activeEnd > 0" :d="trackPath" :class="handleColor.strokeClass" :stroke-width="ctx?.resolvedLineWidth.value ?? 2.5" stroke-linecap="butt" stroke-linejoin="round" :style="{ stroke: handleColor.customColor, strokeDasharray: `${Math.max(activeEnd - activeStart, 0)} ${Math.max(pathLength, 1)}`, strokeDashoffset: -activeStart, transition: canAnimate && ctx?.indicator.value !== 'progress' ? 'stroke-dashoffset 260ms cubic-bezier(0.16, 1, 0.3, 1), stroke-dasharray 260ms cubic-bezier(0.16, 1, 0.3, 1)' : 'none', }" /> </svg> <div v-if=" ctx?.turn.value === 'straight' && straightHighlight.visible && (ctx?.indicator.value !== 'segment' || ctx?.keepScrolled.value) " :class="cn('absolute z-10 rounded-none', handleColor.bgClass)" :style="{ top: `${straightHighlight.top}px`, height: `${straightHighlight.height}px`, width: `${ctx?.resolvedLineWidth.value ?? 2.5}px`, backgroundColor: handleColor.customColor, left: ctx?.position.value === 'left' && ctx?.railPosition.value === 'right' ? undefined : `-${ctx?.resolvedLineWidth.value ?? 2.5}px`, right: ctx?.position.value === 'left' && ctx?.railPosition.value === 'right' ? `-${ctx?.resolvedLineWidth.value ?? 2.5}px` : undefined, transition: canAnimate && ctx?.indicator.value !== '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> </template>app/components/ui/scroll-spy/ScrollSpyItem.vue1.4 kB
<script setup lang="ts"> import { computed, inject, onBeforeUnmount, onMounted, provide, ref, useTemplateRef, watch } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { SCROLL_SPY_CONTEXT_KEY, SCROLL_SPY_ITEM_DEPTH_KEY } from './context' const props = withDefaults( defineProps<{ value: string title?: string depth?: number class?: HTMLAttributes['class'] }>(), { title: undefined, depth: undefined, }, ) const ctx = inject(SCROLL_SPY_CONTEXT_KEY, null) const parentDepth = inject(SCROLL_SPY_ITEM_DEPTH_KEY, ref(0)) const computedDepth = computed(() => props.depth ?? parentDepth.value + 1) provide(SCROLL_SPY_ITEM_DEPTH_KEY, computedDepth) const itemRef = useTemplateRef<HTMLLIElement>('itemRef') function register() { if (!ctx) return ctx.registerItem({ value: props.value, title: props.title, depth: computedDepth.value, el: itemRef.value, }) } onMounted(() => { register() }) onBeforeUnmount(() => { ctx?.unregisterItem(props.value) }) watch( () => [props.value, computedDepth.value] as const, ([newVal], [oldVal]) => { if (oldVal && oldVal !== newVal) { ctx?.unregisterItem(oldVal) } register() }, ) </script> <template> <li ref="itemRef" data-slot="scroll-spy-item" :data-depth="computedDepth" :class="cn('relative flex flex-col', props.class)" > <slot /> </li> </template>app/components/ui/scroll-spy/ScrollSpyLink.vue4.9 kB
<script setup lang="ts"> import { computed, inject, ref } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { SCROLL_SPY_CONTEXT_KEY, SCROLL_SPY_ITEM_DEPTH_KEY, resolveScrollSpyColor } from './context' const props = defineProps<{ href: string title?: string depth?: number class?: HTMLAttributes['class'] }>() const ctx = inject(SCROLL_SPY_CONTEXT_KEY, null) const itemDepth = inject(SCROLL_SPY_ITEM_DEPTH_KEY, ref(1)) const selfDepth = computed(() => props.depth ?? itemDepth.value) const isActive = computed(() => { if (!ctx) return false return ctx.isItemActive(props.href) }) const isParentActive = computed(() => { if (!ctx) return false return ctx.isItemParentActive(props.href) }) const isScrolled = computed(() => { if (!ctx) return false return ctx.isItemScrolled(props.href) }) const isCircuit = computed(() => ctx?.turn.value !== 'straight') const isLeftWithRightRail = computed(() => ctx?.position.value === 'left' && ctx?.railPosition.value === 'right') const hasIndicatorBar = computed( () => ctx?.turn.value === 'straight' && (ctx?.indicator.value !== 'segment' || ctx?.keepScrolled.value), ) const handleColor = computed(() => resolveScrollSpyColor(ctx?.color.value ?? 'primary')) const borderActiveClass = computed(() => { if (hasIndicatorBar.value) { if (isActive.value || isParentActive.value) return 'text-foreground font-medium' if (isScrolled.value) return 'text-foreground/85' return 'text-muted-foreground hover:text-foreground' } if (isActive.value) return cn(handleColor.value.borderClass, 'text-foreground font-medium') if (isParentActive.value) return 'border-border/50 text-foreground font-medium' if (isScrolled.value) return 'border-border/70 text-foreground/85' return 'text-muted-foreground hover:border-foreground/40 hover:text-foreground' }) function onClick(e: MouseEvent) { e.preventDefault() if (!ctx) return ctx.scrollToHref(props.href) } </script> <template> <a :href="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=" !isCircuit && !hasIndicatorBar && (isActive || isParentActive || isScrolled) ? { borderLeftWidth: isLeftWithRightRail ? undefined : `${ctx?.resolvedLineWidth.value ?? 2.5}px`, borderRightWidth: isLeftWithRightRail ? `${ctx?.resolvedLineWidth.value ?? 2.5}px` : undefined, marginLeft: isLeftWithRightRail ? undefined : `-${ctx?.resolvedLineWidth.value ?? 2.5}px`, marginRight: isLeftWithRightRail ? `-${ctx?.resolvedLineWidth.value ?? 2.5}px` : undefined, borderColor: isActive ? handleColor.customColor : undefined, } : undefined " :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, ], props.class, ) " @click="onClick" > <slot>{{ title }}</slot> </a> </template>app/components/ui/scroll-spy/ScrollSpyList.vue1.2 kB
<script setup lang="ts"> import { computed, inject, onMounted, useTemplateRef } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { SCROLL_SPY_CONTEXT_KEY } from './context' const props = defineProps<{ class?: HTMLAttributes['class'] }>() const ctx = inject(SCROLL_SPY_CONTEXT_KEY, null) const listRef = useTemplateRef<HTMLUListElement>('listRef') const isStraight = computed(() => ctx?.turn.value === 'straight') const isRightRail = computed(() => ctx?.position.value === 'left' && ctx?.railPosition.value === 'right') const lineWidthPx = computed(() => `${ctx?.resolvedLineWidth.value ?? 2.5}px`) onMounted(() => { if (ctx && listRef.value) { ctx.setListEl(listRef.value) } }) </script> <template> <ul ref="listRef" data-slot="scroll-spy-list" :style="{ borderLeftWidth: isStraight && !isRightRail ? lineWidthPx : undefined, borderRightWidth: isStraight && isRightRail ? lineWidthPx : undefined, }" :class=" cn( 'relative flex flex-col space-y-1 text-sm', isStraight && (isRightRail ? 'border-border border-r' : 'border-border border-l'), props.class, ) " > <slot /> </ul> </template>app/components/ui/scroll-spy/ScrollSpyTitle.vue0.6 kB
<script setup lang="ts"> import { computed, inject } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { SCROLL_SPY_CONTEXT_KEY } from './context' const props = defineProps<{ class?: HTMLAttributes['class'] }>() const ctx = inject(SCROLL_SPY_CONTEXT_KEY, null) const isRightRail = computed(() => ctx?.position.value === 'left' && ctx?.railPosition.value === 'right') </script> <template> <p data-slot="scroll-spy-title" :class=" cn('text-foreground mb-3 text-sm font-semibold tracking-tight', isRightRail && 'pr-3 text-right', props.class) " > <slot /> </p> </template>app/components/ui/scroll-spy/ScrollSpyStepper.vue8.8 kB
<script setup lang="ts"> import { computed, inject } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { SCROLL_SPY_CONTEXT_KEY } from './context' const props = defineProps<{ class?: HTMLAttributes['class'] }>() const ctx = inject(SCROLL_SPY_CONTEXT_KEY, null) if (!ctx) throw new Error('ScrollSpyStepper must be used inside <ScrollSpy>.') const items = computed(() => ctx.items.value) const activeValue = computed(() => ctx.activeValue.value) const scrollProgress = computed(() => ctx.scrollProgress.value) const position = computed(() => ctx.position.value) const activeIndex = computed(() => { const list = items.value const active = activeValue.value const idx = list.findIndex((i) => i.value === active || i.value.replace(/^#/, '') === active.replace(/^#/, '')) return idx >= 0 ? idx : 0 }) const activeItem = computed(() => items.value[activeIndex.value]) const activeTitle = computed(() => { if (!activeItem.value) return '' return ( activeItem.value.title || activeItem.value.value .replace(/^#/, '') .replace(/[-_]/g, ' ') .replace(/\b\w/g, (c) => c.toUpperCase()) ) }) const isTop = computed(() => position.value === 'top') const isBottom = computed(() => position.value === 'bottom') const variant = computed(() => ctx.variant.value) const indicator = computed(() => ctx.indicator.value) const isScrollSpy = computed( () => variant.value === 'scrollspy' || variant.value === 'tabs' || variant.value === 'pills' || indicator.value === 'pill' || indicator.value === 'dot', ) </script> <template> <!-- Top Sticky Scroll Spy Bar (Highlights ONLY the active section) --> <div v-if="isTop && isScrollSpy" 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', props.class, ) " > <button v-for="(item, idx) in items" :key="item.value" 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', ) " @click="ctx.scrollToHref(item.value)" > <!-- Active Dot Indicator --> <span v-if="idx === activeIndex" class="bg-primary size-1.5 shrink-0 rounded-full" /> <!-- Section Title --> <span class="truncate"> {{ item.title || item.value .replace(/^#/, '') .replace(/[-_]/g, ' ') .replace(/\b\w/g, (c) => c.toUpperCase()) }} </span> </button> <!-- 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> <!-- Top Sticky Stepper Bar (Cumulative Step Wizard with Step Numbers & Connecting Lines) --> <div v-else-if="isTop" 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', props.class, ) " > <template v-for="(item, idx) in items" :key="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', ) " @click="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', ) " > <svg v-if="idx < activeIndex" 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> <span v-else>{{ idx + 1 }}</span> </span> <!-- Step Title --> <span class="max-w-[120px] truncate"> {{ item.title || item.value .replace(/^#/, '') .replace(/[-_]/g, ' ') .replace(/\b\w/g, (c) => c.toUpperCase()) }} </span> </button> <!-- Connecting Progress Line --> <div v-if="idx < items.length - 1" class="bg-border/70 h-0.5 max-w-10 min-w-4 flex-1 rounded-full transition-colors duration-200" :class="idx < activeIndex && 'bg-primary'" /> </template> </div> <!-- Bottom Floating Stepper Capsule --> <div v-else-if="isBottom" 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', props.class, ) " > <!-- 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" @click="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"> <button v-for="(item, idx) in items" :key="item.value" 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', ) " @click="ctx.scrollToHref(item.value)" /> </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" @click="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> </template>app/components/ui/scroll-spy/context.ts3.5 kB
import type { InjectionKey, Ref } from 'vue' 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 } export interface ScrollSpyContext { activeValue: Ref<string> setActiveValue: (value: string) => void scrollProgress: Ref<number> registerItem: (item: RegisteredItem) => void unregisterItem: (value: string) => void variant: Ref<ScrollSpyVariant> turn: Ref<ScrollSpyTurn> indicator: Ref<ScrollSpyIndicatorMode> keepScrolled: Ref<boolean> highlightParent: Ref<boolean> lineWidth: Ref<ScrollSpyLineWidth> resolvedLineWidth: Ref<number> color: Ref<ScrollSpyColor> position: Ref<ScrollSpyPosition> railPosition: Ref<ScrollSpyRailPosition> scrollProgressSmooth: Ref<boolean> scrollContainer: Ref<HTMLElement | Window | null> offsetTop: Ref<number> items: Ref<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: InjectionKey<ScrollSpyContext> = Symbol('uipkge-scroll-spy-context') export const SCROLL_SPY_ITEM_DEPTH_KEY: InjectionKey<Ref<number>> = Symbol('uipkge-scroll-spy-depth')app/components/ui/scroll-spy/index.ts1.3 kB
import ScrollSpyComp from './ScrollSpy.vue' import ScrollSpyIndicatorComp from './ScrollSpyIndicator.vue' import ScrollSpyItemComp from './ScrollSpyItem.vue' import ScrollSpyLinkComp from './ScrollSpyLink.vue' import ScrollSpyListComp from './ScrollSpyList.vue' import ScrollSpyTitleComp from './ScrollSpyTitle.vue' import ScrollSpyStepperComp from './ScrollSpyStepper.vue' export interface ScrollSpyItem { href: string title: string depth?: number children?: ScrollSpyItem[] } export type { ScrollSpyVariant, ScrollSpyTurn, ScrollSpyIndicatorMode, ScrollSpyPosition, ScrollSpyRailPosition, ScrollSpyLineWidth, ScrollSpyColor, } from './context' export const ScrollSpy = Object.assign(ScrollSpyComp, { Root: ScrollSpyComp, Title: ScrollSpyTitleComp, List: ScrollSpyListComp, Indicator: ScrollSpyIndicatorComp, Item: ScrollSpyItemComp, Link: ScrollSpyLinkComp, Stepper: ScrollSpyStepperComp, }) export const ScrollSpyRoot = ScrollSpyComp export const ScrollSpyTitle = ScrollSpyTitleComp export const ScrollSpyList = ScrollSpyListComp export const ScrollSpyIndicator = ScrollSpyIndicatorComp export const ScrollSpyItem = ScrollSpyItemComp export const ScrollSpyLink = ScrollSpyLinkComp export const ScrollSpyStepper = ScrollSpyStepperComp export default ScrollSpy
Raw manifest:https://uipkge.dev/r/vue/scroll-spy.json