
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
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.
Also available for React ->$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.jsonnpx shadcn-vue@latest add @uipkge/scroll-spyInstalls to:app/components/ui/scroll-spy/| 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 |
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[]
}<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>
<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>
<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>
<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>
<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>
<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>
<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-all 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,transform] 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-all 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-all 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-all 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-all 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>
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')
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