ScrollProgress
Barra de progreso de scroll animada con marcadores por sección: thumb arrastrable, click en el track, tooltips y una etiqueta flotante que muestra el título de la sección activa.
@/components/spm/scroll-progressIntro
El componente mide el progreso del contenedor y lo suaviza con spring.
Instalación
Cada sección con id se vuelve un marcador sobre el riel.
Uso
Click en un marcador (o en el track) salta directo a esa sección.
API
El label flotante sigue al thumb y muestra el título activo.
Ejemplo
Al llegar al final, el thumb y los marcadores hacen un pulso.
Intro
El componente mide el progreso del contenedor y lo suaviza con spring.
Instalación
Cada sección con id se vuelve un marcador sobre el riel.
Uso
Click en un marcador (o en el track) salta directo a esa sección.
API
El label flotante sigue al thumb y muestra el título activo.
Ejemplo
Al llegar al final, el thumb y los marcadores hacen un pulso.
1'use client';2 3/**4 * ScrollProgress5 * ─────────────────────────────────────────────────────────────6 * Barra de progreso de scroll animada con marcadores de sección7 * (estilo skiper-ui "Anime js scrollbar"). Reacciona al scroll,8 * muestra en qué parte de la página estás y el título de la9 * sección activa, con thumb arrastrable, click en el track,10 * hover/click sobre los marcadores y una etiqueta flotante que11 * sigue al thumb con spring.12 *13 * Componible estilo Radix/shadcn (Root + subcomponentes por14 * punto), igual que RadialChart:15 *16 * <ScrollProgress17 * items={[{ id: 'intro', label: 'Intro' }, ...]}18 * scrollRef={contenedorRef} // opcional: default window19 * orientation="vertical" // 'vertical' | 'horizontal'20 * className="fixed right-3 top-1/2 -translate-y-1/2 h-[55vh]"21 * >22 * <ScrollProgress.Track /> // riel + marcadores automáticos23 * <ScrollProgress.Thumb /> // handle arrastrable24 * <ScrollProgress.Label /> // card flotante con el título activo25 * </ScrollProgress>26 *27 * El Root es un contenedor `relative`: TODA la lógica (medición,28 * scroll-spy, drag) vive acá y baja por contexto. Vos posicionás29 * la barra donde quieras con `className` (fixed/absolute). El30 * consumidor elige los marcadores (items) y cada uno debe tener31 * un `id` que exista como elemento en la página/contenedor.32 */33 34import * as React from 'react';35import {36 motion,37 AnimatePresence,38 useScroll,39 useSpring,40 useMotionValue,41 useTransform,42 useMotionValueEvent,43} from 'motion/react';44import { cn } from '@/lib/utils';45import { getStrictContext } from '@/lib/get-strict-context';46 47const [ScrollProgressProvider, useScrollProgress] =48 getStrictContext('ScrollProgress');49 50const SPRING = { type: 'spring', stiffness: 320, damping: 24, mass: 0.8 };51const SPRING_SOFT = { type: 'spring', stiffness: 180, damping: 24, mass: 0.9 };52const THUMB_SIZE = 20;53 54// ═════════════════════════════════════════════════════════════55// <ScrollProgress> — Root56// ═════════════════════════════════════════════════════════════57// Hace el trabajo pesado: `useScroll` sobre el contenedor (o la58// ventana), suavizado con spring, medición de las posiciones de59// los marcadores (ResizeObserver) y detección de sección activa60// por progreso. Expone un ref imperativo:61//62// const ref = useRef(null);63// <ScrollProgress ref={ref} items={...}>...</ScrollProgress>64// ref.current.scrollTo('instalacion');65// ref.current.refresh();66const ScrollProgressRoot = React.forwardRef(function ScrollProgressRoot(67 {68 items = [],69 scrollRef = null,70 orientation = 'vertical',71 position = null,72 offset = 0,73 animated = true,74 markers = true,75 showLabels = true,76 showTooltips = true,77 onActiveChange,78 className,79 children,80 ...props81 },82 ref83) {84 const horizontal = orientation === 'horizontal';85 const resolvedPosition =86 position ?? (horizontal ? 'bottom' : 'right');87 88 // Progreso real del contenedor (o ventana) + suavizado.89 const scroll = React.useMemo(90 () => (scrollRef ? { container: scrollRef } : undefined),91 [scrollRef]92 );93 const { scrollYProgress, scrollXProgress } = useScroll(scroll);94 const progress = horizontal ? scrollXProgress : scrollYProgress;95 const springProgress = useSpring(progress, SPRING);96 const smoothProgress = animated ? springProgress : progress;97 98 const [activeIndex, setActiveIndex] = React.useState(0);99 const [positions, setPositions] = React.useState(100 () => items.map(() => 0)101 );102 const [reachedEnd, setReachedEnd] = React.useState(false);103 const [trackLen, setTrackLen] = React.useState(0);104 105 const trackRef = React.useRef(null);106 const isDraggingRef = React.useRef(false);107 const activeRef = React.useRef(0);108 const thumbPos = useMotionValue(0);109 110 // Guardamos el elemento del contenedor en un ref propio: los111 // callbacks leen `containerRef.current` sin que el React Compiler112 // se pierda con `scrollRef?.current` (ref que viene por prop).113 const containerRef = React.useRef(null);114 React.useEffect(() => {115 containerRef.current = scrollRef?.current ?? null;116 }, [scrollRef]);117 118 // Scroll-spy por progreso + mover el thumb con el scroll119 // (pero no mientras el usuario está arrastrando el handle).120 useMotionValueEvent(progress, 'change', (v) => {121 if (!isDraggingRef.current) {122 thumbPos.set(v * Math.max(trackLen - THUMB_SIZE, 0));123 }124 let idx = 0;125 if (positions.length) {126 for (let i = positions.length - 1; i >= 0; i--) {127 if (v >= positions[i]) {128 idx = i;129 break;130 }131 }132 if (v >= 0.999) idx = positions.length - 1;133 }134 if (activeRef.current !== idx) {135 activeRef.current = idx;136 setActiveIndex(idx);137 onActiveChange?.(idx);138 }139 setReachedEnd(v >= 0.999);140 });141 142 // Posición (0..1) de cada marcador dentro del scroll total.143 const measurePositions = React.useCallback(() => {144 if (typeof window === 'undefined') return;145 const sc = containerRef.current ?? null;146 const max = sc147 ? horizontal148 ? sc.scrollWidth - sc.clientWidth149 : sc.scrollHeight - sc.clientHeight150 : horizontal151 ? document.documentElement.scrollWidth - window.innerWidth152 : document.documentElement.scrollHeight - window.innerHeight;153 const denom = Math.max(max, 1);154 155 const next = items.map((item) => {156 if (!item || !item.id) return 0;157 const el = document.getElementById(item.id);158 if (!el) return 0;159 let start;160 if (sc) {161 const cr = sc.getBoundingClientRect();162 const er = el.getBoundingClientRect();163 start = horizontal164 ? er.left - cr.left + sc.scrollLeft165 : er.top - cr.top + sc.scrollTop;166 } else {167 const er = el.getBoundingClientRect();168 start = horizontal169 ? er.left + window.scrollX170 : er.top + window.scrollY;171 }172 return Math.min(Math.max(start / denom, 0), 1);173 });174 setPositions(next);175 }, [items, horizontal]);176 177 // Medición inicial y ante resize/cambio de contenido. Todo el178 // setState ocurre dentro de callbacks del ResizeObserver (async),179 // así que no hay re-renders en cascada.180 React.useEffect(() => {181 const sc = containerRef.current ?? null;182 const ro = new ResizeObserver(() => {183 const t = trackRef.current;184 if (t) {185 const rect = t.getBoundingClientRect();186 setTrackLen(horizontal ? rect.width : rect.height);187 }188 measurePositions();189 });190 if (sc) ro.observe(sc);191 if (sc?.firstElementChild) ro.observe(sc.firstElementChild);192 if (trackRef.current) ro.observe(trackRef.current);193 if (!sc && typeof document !== 'undefined') {194 ro.observe(document.body);195 }196 return () => ro.disconnect();197 }, [measurePositions, horizontal]);198 199 // Si thumbPos depende del trackLen recién medido, re-sincronizo.200 React.useEffect(() => {201 thumbPos.set(progress.get() * Math.max(trackLen - THUMB_SIZE, 0));202 }, [trackLen, progress, thumbPos]);203 204 // Llevar el scroll a una sección (smooth).205 const scrollToIndex = React.useCallback(206 (index) => {207 const item = items[index];208 if (!item || !item.id) return;209 const sc = containerRef.current ?? null;210 const el = document.getElementById(item.id);211 if (!el) return;212 if (sc) {213 const cr = sc.getBoundingClientRect();214 const er = el.getBoundingClientRect();215 const target = horizontal216 ? er.left - cr.left + sc.scrollLeft - offset217 : er.top - cr.top + sc.scrollTop - offset;218 sc.scrollTo({ [horizontal ? 'left' : 'top']: target, behavior: 'smooth' });219 } else if (typeof window !== 'undefined') {220 const er = el.getBoundingClientRect();221 const target = horizontal222 ? er.left + window.scrollX - offset223 : er.top + window.scrollY - offset;224 window.scrollTo({ [horizontal ? 'left' : 'top']: target, behavior: 'smooth' });225 }226 },227 [items, horizontal, offset]228 );229 230 // Scroll directo a un porcentaje (drag del thumb / click track).231 const scrollToProgress = React.useCallback(232 (pct) => {233 if (typeof window === 'undefined') return;234 const sc = containerRef.current ?? null;235 const value = Math.min(Math.max(pct, 0), 1);236 if (sc) {237 const max = horizontal238 ? sc.scrollWidth - sc.clientWidth239 : sc.scrollHeight - sc.clientHeight;240 sc.scrollTo({241 [horizontal ? 'left' : 'top']: value * Math.max(max, 0),242 behavior: 'auto',243 });244 } else {245 const max = horizontal246 ? document.documentElement.scrollWidth - window.innerWidth247 : document.documentElement.scrollHeight - window.innerHeight;248 window.scrollTo({249 [horizontal ? 'left' : 'top']: value * Math.max(max, 0),250 behavior: 'auto',251 });252 }253 },254 [horizontal]255 );256 257 // Salto al marcador más cercano a una posición del track (px).258 const nearestIndex = React.useCallback(259 (point, len) => {260 if (!positions.length || len <= 0) return 0;261 const value = Math.min(Math.max(point / len, 0), 1);262 let best = 0;263 let bestDist = Infinity;264 positions.forEach((p, i) => {265 const dist = Math.abs(p - value);266 if (dist < bestDist) {267 bestDist = dist;268 best = i;269 }270 });271 return best;272 },273 [positions]274 );275 276 React.useImperativeHandle(277 ref,278 () => ({279 scrollTo: (id) => {280 const i = items.findIndex((it) => it && it.id === id);281 if (i >= 0) scrollToIndex(i);282 },283 scrollToIndex,284 scrollToProgress,285 refresh: measurePositions,286 getActiveIndex: () => activeRef.current,287 getProgress: () => progress.get(),288 }),289 [items, scrollToIndex, scrollToProgress, measurePositions, progress]290 );291 292 const ctx = React.useMemo(293 () => ({294 items,295 positions,296 activeIndex,297 reachedEnd,298 orientation,299 horizontal,300 position: resolvedPosition,301 smoothProgress,302 progress,303 thumbPos,304 trackRef,305 trackLen,306 isDraggingRef,307 THUMB_SIZE,308 markers,309 showLabels,310 showTooltips,311 scrollToIndex,312 scrollToProgress,313 nearestIndex,314 }),315 [316 items,317 positions,318 activeIndex,319 reachedEnd,320 orientation,321 horizontal,322 resolvedPosition,323 smoothProgress,324 progress,325 thumbPos,326 trackLen,327 markers,328 showLabels,329 showTooltips,330 scrollToIndex,331 scrollToProgress,332 nearestIndex,333 ]334 );335 336 return (337 <ScrollProgressProvider value={ctx}>338 <div339 data-slot="scroll-progress"340 className={cn('relative', className)}341 {...props}342 >343 {children}344 </div>345 </ScrollProgressProvider>346 );347});348 349// ═════════════════════════════════════════════════════════════350// <ScrollProgress.Track> — el riel + relleno + marcadores351// automáticos (uno por `item`). Click en el track salta al352// marcador más cercano.353// ═════════════════════════════════════════════════════════════354function ScrollProgressTrack({ className, ...props }) {355 const {356 items,357 markers,358 horizontal,359 trackRef,360 scrollToIndex,361 nearestIndex,362 } = useScrollProgress();363 364 const handlePointerDown = (e) => {365 const rect = e.currentTarget.getBoundingClientRect();366 const len = horizontal ? rect.width : rect.height;367 if (len <= 0) return;368 const point = horizontal369 ? e.clientX - rect.left370 : e.clientY - rect.top;371 const idx = nearestIndex(point, len);372 scrollToIndex(idx);373 };374 375 return (376 <motion.div377 ref={trackRef}378 data-slot="scroll-progress-track"379 onPointerDown={handlePointerDown}380 className={cn(381 'absolute rounded-full bg-foreground/10',382 horizontal383 ? 'left-0 right-0 top-1/2 h-[3px] -translate-y-1/2'384 : 'bottom-0 left-1/2 top-0 w-[3px] -translate-x-1/2',385 className386 )}387 {...props}388 >389 <ScrollProgressBar />390 {markers &&391 items.map((item, i) => (392 <ScrollProgressMarker393 key={item?.id ?? i}394 value={item?.id}395 index={i}396 />397 ))}398 </motion.div>399 );400}401 402// ═════════════════════════════════════════════════════════════403// <ScrollProgress.Bar> — relleno del progreso (scaleY/scaleX).404// ═════════════════════════════════════════════════════════════405function ScrollProgressBar({ className, ...props }) {406 const { smoothProgress, horizontal } = useScrollProgress();407 408 return (409 <motion.div410 data-slot="scroll-progress-bar"411 aria-hidden412 className={cn(413 'absolute rounded-full bg-foreground',414 horizontal415 ? 'left-0 right-0 top-0 h-full origin-left'416 : 'bottom-0 left-0 top-0 w-full origin-top',417 className418 )}419 style={420 horizontal421 ? { scaleX: smoothProgress }422 : { scaleY: smoothProgress }423 }424 {...props}425 />426 );427}428 429// ═════════════════════════════════════════════════════════════430// <ScrollProgress.Thumb> — handle arrastrable. Mientras se431// arrastra escribe el scroll real; al soltar, el scroll manda.432// ═════════════════════════════════════════════════════════════433function ScrollProgressThumb({ className, ...props }) {434 const {435 horizontal,436 thumbPos,437 trackRef,438 trackLen,439 reachedEnd,440 isDraggingRef,441 progress,442 THUMB_SIZE,443 scrollToProgress,444 } = useScrollProgress();445 446 const handleDrag = () => {447 const max = Math.max(trackLen - THUMB_SIZE, 0);448 if (max <= 0) return;449 const pct = Math.min(Math.max(thumbPos.get() / max, 0), 1);450 scrollToProgress(pct);451 };452 453 return (454 <motion.div455 data-slot="scroll-progress-thumb"456 onPointerDownCapture={(e) => e.stopPropagation()}457 className={cn(458 'absolute z-20 cursor-grab rounded-full bg-foreground shadow-lg active:cursor-grabbing',459 horizontal460 ? 'top-1/2 h-[14px]'461 : 'left-1/2 w-[14px]',462 className463 )}464 style={465 horizontal466 ? { width: THUMB_SIZE, x: thumbPos, y: -7 }467 : { height: THUMB_SIZE, y: thumbPos, x: -7 }468 }469 drag={horizontal ? 'x' : 'y'}470 dragConstraints={trackRef}471 dragElastic={0}472 dragMomentum={false}473 animate={{ scale: reachedEnd ? 1.25 : 1 }}474 transition={SPRING}475 onDragStart={() => {476 isDraggingRef.current = true;477 }}478 onDrag={handleDrag}479 onDragEnd={() => {480 isDraggingRef.current = false;481 thumbPos.set(progress.get() * Math.max(trackLen - THUMB_SIZE, 0));482 }}483 {...props}484 />485 );486}487 488// ═════════════════════════════════════════════════════════════489// <ScrollProgress.Marker> — punto de sección. Crece y se enciende490// al acercarse (useTransform), el activo se agranda, y al hacer491// hover muestra el label (tooltip). Click → scroll a la sección.492// ═════════════════════════════════════════════════════════════493function ScrollProgressMarker({494 value,495 index,496 className,497 ...props498}) {499 const {500 items,501 positions,502 activeIndex,503 reachedEnd,504 horizontal,505 smoothProgress,506 showTooltips,507 scrollToIndex,508 } = useScrollProgress();509 510 const item = items[index];511 const pct = positions[index] ?? 0;512 const isActive = activeIndex === index;513 514 const scale = useTransform(515 smoothProgress,516 [Math.max(pct - 0.08, 0), pct],517 [0.5, 1]518 );519 const opacity = useTransform(520 smoothProgress,521 [Math.max(pct - 0.15, 0), pct],522 [0.3, 1]523 );524 const fillScale = useTransform(525 smoothProgress,526 [Math.max(pct - 0.02, 0), pct],527 [0, 1]528 );529 530 return (531 <button532 type="button"533 data-slot="scroll-progress-marker"534 data-active={isActive || undefined}535 aria-label={item?.label ?? `Sección ${index + 1}`}536 onClick={(e) => {537 e.stopPropagation();538 scrollToIndex(index);539 }}540 className={cn(541 'group absolute z-10 flex items-center justify-center',542 horizontal543 ? 'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2'544 : 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2',545 className546 )}547 style={548 horizontal549 ? { left: `${pct * 100}%` }550 : { top: `${pct * 100}%` }551 }552 {...props}553 >554 <motion.span555 style={{ scale, opacity }}556 className="relative flex size-3 items-center justify-center"557 >558 <span className="absolute inset-0 rounded-full bg-foreground/25 transition-colors duration-200 group-hover:bg-foreground/40" />559 <motion.span560 style={{ scaleX: fillScale, scaleY: fillScale }}561 className={cn(562 'size-1.5 rounded-full bg-foreground',563 isActive ? 'bg-foreground' : 'bg-foreground'564 )}565 />566 </motion.span>567 {showTooltips && item && (568 <span569 className={cn(570 'pointer-events-none absolute z-20 whitespace-nowrap rounded-md border border-border bg-background px-2 py-0.5 text-[11px] font-medium text-foreground opacity-0 shadow-lg transition-opacity duration-150 group-hover:opacity-100',571 horizontal572 ? 'left-1/2 top-full mt-2 -translate-x-1/2'573 : 'left-full top-1/2 ml-2 -translate-y-1/2'574 )}575 >576 {item.label}577 </span>578 )}579 <motion.span580 aria-hidden581 className="pointer-events-none absolute inset-0 rounded-full bg-foreground/0"582 animate={{583 scale: reachedEnd ? 1.6 : isActive ? 1.35 : 1,584 opacity: isActive || reachedEnd ? 0.18 : 0,585 }}586 transition={SPRING}587 />588 </button>589 );590}591 592// ═════════════════════════════════════════════════════════════593// <ScrollProgress.Label> — card flotante con el label de la594// sección activa. Sigue al thumb con spring y cambia el texto con595// AnimatePresence. `pointer-events-none`, es decorativo.596// ═════════════════════════════════════════════════════════════597function ScrollProgressLabel({ className, ...props }) {598 const {599 items,600 activeIndex,601 horizontal,602 showLabels,603 thumbPos,604 THUMB_SIZE,605 } = useScrollProgress();606 607 const item = items[activeIndex] ?? items[0];608 609 const followY = useTransform(thumbPos, (v) => v - 10);610 const followX = useTransform(thumbPos, (v) => v - 40);611 const springY = useSpring(followY, SPRING_SOFT);612 const springX = useSpring(followX, SPRING_SOFT);613 614 if (!showLabels || !item) return null;615 616 return (617 <div618 data-slot="scroll-progress-label"619 className={cn(620 'pointer-events-none absolute z-30',621 horizontal622 ? 'left-0 right-0 top-1/2'623 : 'bottom-0 left-1/2 top-0',624 className625 )}626 {...props}627 >628 <motion.div629 style={630 horizontal631 ? { x: springX, y: -THUMB_SIZE - 8 }632 : { y: springY, x: THUMB_SIZE }633 }634 className="relative w-max"635 >636 <AnimatePresence mode="wait" initial={false}>637 <motion.span638 key={item.id}639 initial={{ opacity: 0, y: 6, filter: 'blur(4px)' }}640 animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}641 exit={{ opacity: 0, y: -6, filter: 'blur(4px)' }}642 transition={{ duration: 0.18 }}643 className="block whitespace-nowrap rounded-full border border-border bg-background/90 px-3 py-1 text-[11px] font-medium text-foreground shadow-lg backdrop-blur"644 >645 {item.label}646 </motion.span>647 </AnimatePresence>648 </motion.div>649 </div>650 );651}652 653// ─────────────────────────────────────────────────────────────654// Ensamblado final: ScrollProgress.Track, .Bar, .Thumb, .Marker,655// .Label. Mismo patrón de namespacing por punto que Radix.656// ─────────────────────────────────────────────────────────────657export const ScrollProgress = Object.assign(ScrollProgressRoot, {658 Track: ScrollProgressTrack,659 Bar: ScrollProgressBar,660 Thumb: ScrollProgressThumb,661 Marker: ScrollProgressMarker,662 Label: ScrollProgressLabel,663});664 665export { useScrollProgress };666 Uso
Props
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- ene
- feb
- mar
- abr
- may
- jun
- jul
- ago
- sep
- oct
- nov
- dic
- ene
- feb
- mar
- abr
- may
- jun
- jul
- ago
- sep
- oct
- nov
- dic
- 2025
- 2026
- 2027
- 2028
- 2029
- 2030
- 2031
- 2025
- 2026
- 2027
- 2028
- 2029
- 2030
- 2031
martes, 29 de sep de 2026
Acciones del componente
Search for a command to run...
Software a medida, IA integrada y ciberseguridad avanzada para empresas.
Consultoría SEO Avanzado & Analítica
Auditoría técnica profunda para posicionar tu negocio en los primeros resultados de Google.
SEO TécnicoGA4Páginas Web Corporativas Complejas
Plataformas empresariales masivas con múltiples secciones y animaciones fluidas.
Framer MotionMulti-pageIntegración de Inteligencia Artificial en Negocios
Automatizamos la atención, las reservas y las ventas de tu negocio con agentes de IA.
AI AgentsChatbots