Scroll Indicator
nexvyn/scroll-indicatorFreeComponent
A vertical scroll progress indicator with tick marks, section labels, and a floating position readout.
Live preview
live · rendered by the registry
Rendered by nexvyn on their own page — this is the component running, not a screenshot of it.Install it
npx shadcn@latest add https://ui.nexvyn.dev/r/scroll-indicator.jsonSource
1'use client'23import { useEffect, useState, useRef, useCallback, type ReactNode } from 'react'4import { motion, useSpring, useMotionValue, useReducedMotion } from 'motion/react'5import { cn } from '@/lib/utils'6import { playHoverSound, playClickSound } from '@/lib/sound'78interface Section {9 id: string10 title: ReactNode11 level: number12}1314interface ScrollIndicatorProps {15 sections: Section[]16 activeIndex?: number17 onIndexChange?: (index: number) => void18 scrollRef?: React.RefObject<HTMLDivElement | null>19 className?: string20}2122export function ScrollIndicator({23 sections,24 activeIndex: controlledIndex,25 onIndexChange,26 scrollRef: externalScrollRef,27 className,28}: ScrollIndicatorProps) {29 const [internalIndex, setInternalIndex] = useState(0)30 const [isHovered, setIsHovered] = useState(false)31 const [trackHeight, setTrackHeight] = useState(0)32 const trackRef = useRef<HTMLDivElement>(null)33 const reduceMotion = useReducedMotion()3435 const activeIndex = controlledIndex ?? internalIndex3637 const progressY = useMotionValue(0)38 const smoothY = useSpring(progressY, {39 stiffness: reduceMotion ? 1000 : 300,40 damping: reduceMotion ? 100 : 30,41 })4243 const filteredSections = sections.filter((s) => s.level === 2 || s.level === 3)44 const totalTicks = 604546 const updateHeight = useCallback(() => {47 if (trackRef.current) {48 setTrackHeight(trackRef.current.clientHeight)49 }50 }, [])5152 useEffect(() => {53 updateHeight()54 window.addEventListener('resize', updateHeight)55 return () => window.removeEventListener('resize', updateHeight)56 }, [updateHeight])5758 useEffect(() => {59 if (trackHeight <= 0 || filteredSections.length === 0) return60 const targetY = (activeIndex / Math.max(filteredSections.length - 1, 1)) * trackHeight61 progressY.set(targetY)62 }, [activeIndex, trackHeight, filteredSections.length, progressY])6364 const handleClick = (index: number) => {65 playClickSound()66 if (onIndexChange) {67 onIndexChange(index)68 } else {69 setInternalIndex(index)70 }7172 const container = externalScrollRef?.current73 const sectionEl = container?.querySelector(`[data-section-index="${index}"]`)74 if (container && sectionEl) {75 const top = (sectionEl as HTMLElement).offsetTop - container.offsetTop + 876 container.scrollTo({ top, behavior: 'smooth' })77 }78 }7980 return (81 <div className={cn('h-full', className)}>82 <div83 ref={trackRef}84 className="h-full relative"85// … truncated
Files it writes
More from nexvyn
All 33 items| Component | Registry | Kind | Access | Installs | Command |
|---|---|---|---|---|---|
| Badgebadge | nexvyn | Components | Free | 2 deps · 2 files | |
| Bars Themebars-theme | nexvyn | Components | Free | 1 dep · 3 files | |
| Bounce Sidebarbounce-sidebar | nexvyn | Components | Free | 1 dep · 2 files | |
| Breadcrumbsbreadcrumbs | nexvyn | Components | Free | 1 dep · 2 files | |
| Checkboxcheckbox | nexvyn | Components | Free | 1 dep · 3 files | |
| Clipboard Fieldclipboard-field | nexvyn | Components | Free | no deps · 2 files | |
| Color Pickercolor-picker | nexvyn | Components | Free | no deps · 16 files | |
| Comboboxcombobox | nexvyn | Components | Free | 1 dep · 2 files |
