Componentes
[animación] · spm / motion

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.

spmmotion
@/components/spm/scroll-progress
sección 01

Intro

El componente mide el progreso del contenedor y lo suaviza con spring.

sección 02

Instalación

Cada sección con id se vuelve un marcador sobre el riel.

sección 03

Uso

Click en un marcador (o en el track) salta directo a esa sección.

sección 04

API

El label flotante sigue al thumb y muestra el título activo.

sección 05

Ejemplo

Al llegar al final, el thumb y los marcadores hacen un pulso.

Intro
1'use client';
2 
3/**
4 * ScrollProgress
5 * ─────────────────────────────────────────────────────────────
6 * Barra de progreso de scroll animada con marcadores de sección
7 * (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 la
9 * sección activa, con thumb arrastrable, click en el track,
10 * hover/click sobre los marcadores y una etiqueta flotante que
11 * sigue al thumb con spring.
12 *
13 * Componible estilo Radix/shadcn (Root + subcomponentes por
14 * punto), igual que RadialChart:
15 *
16 * <ScrollProgress
17 * items={[{ id: 'intro', label: 'Intro' }, ...]}
18 * scrollRef={contenedorRef} // opcional: default window
19 * 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áticos
23 * <ScrollProgress.Thumb /> // handle arrastrable
24 * <ScrollProgress.Label /> // card flotante con el título activo
25 * </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ás
29 * la barra donde quieras con `className` (fixed/absolute). El
30 * consumidor elige los marcadores (items) y cada uno debe tener
31 * 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> — Root
56// ═════════════════════════════════════════════════════════════
57// Hace el trabajo pesado: `useScroll` sobre el contenedor (o la
58// ventana), suavizado con spring, medición de las posiciones de
59// los marcadores (ResizeObserver) y detección de sección activa
60// 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 ...props
81 },
82 ref
83) {
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: los
111 // callbacks leen `containerRef.current` sin que el React Compiler
112 // 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 scroll
119 // (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 = sc
147 ? horizontal
148 ? sc.scrollWidth - sc.clientWidth
149 : sc.scrollHeight - sc.clientHeight
150 : horizontal
151 ? document.documentElement.scrollWidth - window.innerWidth
152 : 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 = horizontal
164 ? er.left - cr.left + sc.scrollLeft
165 : er.top - cr.top + sc.scrollTop;
166 } else {
167 const er = el.getBoundingClientRect();
168 start = horizontal
169 ? er.left + window.scrollX
170 : 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 el
178 // 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 = horizontal
216 ? er.left - cr.left + sc.scrollLeft - offset
217 : 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 = horizontal
222 ? er.left + window.scrollX - offset
223 : 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 = horizontal
238 ? sc.scrollWidth - sc.clientWidth
239 : 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 = horizontal
246 ? document.documentElement.scrollWidth - window.innerWidth
247 : 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 <div
339 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 + marcadores
351// automáticos (uno por `item`). Click en el track salta al
352// 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 = horizontal
369 ? e.clientX - rect.left
370 : e.clientY - rect.top;
371 const idx = nearestIndex(point, len);
372 scrollToIndex(idx);
373 };
374 
375 return (
376 <motion.div
377 ref={trackRef}
378 data-slot="scroll-progress-track"
379 onPointerDown={handlePointerDown}
380 className={cn(
381 'absolute rounded-full bg-foreground/10',
382 horizontal
383 ? '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 className
386 )}
387 {...props}
388 >
389 <ScrollProgressBar />
390 {markers &&
391 items.map((item, i) => (
392 <ScrollProgressMarker
393 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.div
410 data-slot="scroll-progress-bar"
411 aria-hidden
412 className={cn(
413 'absolute rounded-full bg-foreground',
414 horizontal
415 ? 'left-0 right-0 top-0 h-full origin-left'
416 : 'bottom-0 left-0 top-0 w-full origin-top',
417 className
418 )}
419 style={
420 horizontal
421 ? { scaleX: smoothProgress }
422 : { scaleY: smoothProgress }
423 }
424 {...props}
425 />
426 );
427}
428 
429// ═════════════════════════════════════════════════════════════
430// <ScrollProgress.Thumb> — handle arrastrable. Mientras se
431// 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.div
455 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 horizontal
460 ? 'top-1/2 h-[14px]'
461 : 'left-1/2 w-[14px]',
462 className
463 )}
464 style={
465 horizontal
466 ? { 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 enciende
490// al acercarse (useTransform), el activo se agranda, y al hacer
491// hover muestra el label (tooltip). Click → scroll a la sección.
492// ═════════════════════════════════════════════════════════════
493function ScrollProgressMarker({
494 value,
495 index,
496 className,
497 ...props
498}) {
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 <button
532 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 horizontal
543 ? '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 className
546 )}
547 style={
548 horizontal
549 ? { left: `${pct * 100}%` }
550 : { top: `${pct * 100}%` }
551 }
552 {...props}
553 >
554 <motion.span
555 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.span
560 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 <span
569 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 horizontal
572 ? '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.span
580 aria-hidden
581 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 la
594// sección activa. Sigue al thumb con spring y cambia el texto con
595// 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 <div
618 data-slot="scroll-progress-label"
619 className={cn(
620 'pointer-events-none absolute z-30',
621 horizontal
622 ? 'left-0 right-0 top-1/2'
623 : 'bottom-0 left-1/2 top-0',
624 className
625 )}
626 {...props}
627 >
628 <motion.div
629 style={
630 horizontal
631 ? { 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.span
638 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

PropTipoDefaultDescripción
itemsArray<{id,label}>—Marcadores: cada id debe existir como elemento.
scrollRefRefObject<HTMLElement>windowContenedor a trackear. Sin él usa el scroll de la ventana.
orientation'vertical' | 'horizontal'verticalEje del riel.
position'left'|'right'|'top'|'bottom'según ejeLado de la etiqueta flotante.
offsetnumber0Margen (px) al hacer scroll a una sección.
animatedbooleantrueSuavizado del progreso con spring.
markersbooleantrueMuestra los marcadores de sección.
showLabelsbooleantrueEtiqueta flotante con el título activo.
showTooltipsbooleantrueTooltip del label al hacer hover.
onActiveChange(index) => void—Callback con el índice de la sección activa.

Acciones del componente

Search for a command to run...

Potenciá tu negocio con SFESS

Software a medida, IA integrada y ciberseguridad avanzada para empresas.

0años de ingeniería
0proyectos entregados
0% uptime
Servicios destacados
  • Consultoría SEO Avanzado & Analítica

    Auditoría técnica profunda para posicionar tu negocio en los primeros resultados de Google.

    SEO TécnicoGA4
  • Páginas Web Corporativas Complejas

    Plataformas empresariales masivas con múltiples secciones y animaciones fluidas.

    Framer MotionMulti-page
  • Integración de Inteligencia Artificial en Negocios

    Automatizamos la atención, las reservas y las ventas de tu negocio con agentes de IA.

    AI AgentsChatbots
Nuestro ecosistema
WORKENTA_OSSFESS_SOLUTIONSPRANALUZ_YOGAREMY_AI
Ver todos los servicios