Marquesina sensible a la velocidad del scroll
Avanza despacio por sí sola, se acelera con la velocidad del desplazamiento y cambia de dirección si subes.
Diseño a medidaDiseño a medidaDiseño a medidaDiseño a medidaDiseño a medidaDiseño a medidaDiseño a medidaDiseño a medida
Desplázate arriba y abajo: cambia la velocidadCódigo completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
/** Marquesina cuya velocidad y sentido responden al scroll: avanza despacio por sí sola, se acelera con la velocidad del desplazamiento y cambia de dirección si subes. Autocontenida en su panel. React + TypeScript, sin dependencias. */
export type ScrollMarqueeProps = {
words?: string[];
height?: number;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcsma-root { box-sizing: border-box; position: relative; width: 100%; height: var(--h, 420px); overflow-y: auto; overflow-x: hidden; background: #121212; color: #f4f0e6; font: 400 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; scrollbar-width: thin; scrollbar-color: #333 transparent; outline: 0; }
.gcsma-root *, .gcsma-root *::before, .gcsma-root *::after { box-sizing: border-box; }
.gcsma-root:focus-visible { box-shadow: inset 0 0 0 2px #ff6a3d; }
.gcsma-track { height: calc(var(--h, 420px) * 3); }
.gcsma-stage { position: sticky; top: 0; height: var(--h, 420px); display: grid; align-content: center; gap: 12px; overflow: hidden; }
.gcsma-row { display: flex; width: max-content; will-change: transform; white-space: nowrap; font: 900 clamp(54px, 13vw, 150px)/.95 "Arial Black", "Impact", ui-sans-serif, system-ui, sans-serif; letter-spacing: -.04em; text-transform: uppercase; }
.gcsma-row:nth-child(even) { color: transparent; -webkit-text-stroke: 2px #f4f0e6; }
.gcsma-row span { padding-right: .5em; }
.gcsma-row span::after { content: "✺"; margin-left: .5em; color: #ff6a3d; -webkit-text-stroke: 0; }
.gcsma-hint { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); font-size: 12px; color: #8c8677; }
@media (prefers-reduced-motion: reduce) { .gcsma-row { transform: none !important; } }`;
export function ScrollMarquee({ words = ["Diseño a medida", "Código limpio", "Entrega continua"], height = 420, className = "", style }: ScrollMarqueeProps) {
const ref = useRef<HTMLDivElement>(null);
const rows = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) return;
let raf = 0;
let last = el.scrollTop;
let vel = 0;
let dir = 1;
const pos = rows.current.map(() => 0);
let visible = true;
const tick = () => {
const now = el.scrollTop;
const dy = now - last;
last = now;
vel += (Math.abs(dy) - vel) * 0.15;
if (Math.abs(dy) > 0.5) dir = dy > 0 ? 1 : -1;
rows.current.forEach((row, i) => {
if (!row) return;
const half = row.scrollWidth / 2;
const base = 0.6 + vel * 0.9;
pos[i] = (pos[i] + (i % 2 ? -1 : 1) * dir * base) % half;
row.style.transform = `translateX(${(pos[i] - half) % half}px)`;
});
raf = visible && !document.hidden ? requestAnimationFrame(tick) : 0;
};
raf = requestAnimationFrame(tick);
const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible && !raf) raf = requestAnimationFrame(tick); });
io.observe(el);
const vis = () => { if (!document.hidden && !raf) raf = requestAnimationFrame(tick); };
document.addEventListener("visibilitychange", vis);
return () => { cancelAnimationFrame(raf); io.disconnect(); document.removeEventListener("visibilitychange", vis); };
}, []);
return (
<div ref={ref} className={`gcsma-root ${className}`} style={{ "--h": `${height}px`, ...style } as CSSProperties} tabIndex={0} role="region" aria-label="Marquesina sensible al scroll">
<style>{CSS}</style>
<div className="gcsma-track">
<div className="gcsma-stage">
{words.map((word, i) => (
<div key={word} ref={(node) => { rows.current[i] = node; }} className="gcsma-row" aria-hidden={i > 0 || undefined}>
{Array.from({ length: 8 }, (_, n) => (
<span key={n}>{word}</span>
))}
</div>
))}
<span className="gcsma-hint">Desplázate arriba y abajo: cambia la velocidad</span>
</div>
</div>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/scroll-marqueeEjemplo de uso de Marquesina sensible a la velocidad del scroll
Después de copiar scroll-marquee.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { ScrollMarquee } from "./scroll-marquee";
export default function Ejemplo() {
return <ScrollMarquee />;
}Propiedades de ScrollMarquee
ScrollMarquee acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
words? | string[] | Opcional. |
height? | number | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Marquesina sensible a la velocidad del scroll con variables CSS
Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.
| Variable | Valor por defecto |
|---|---|
--h | 420px |
Accesibilidad y rendimiento
El archivo tiene 82 líneas (4 KB) y no necesita @keyframes. Esto es lo que hace Marquesina sensible a la velocidad del scroll por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: region.
- Se pausa cuando sale de la pantalla para no gastar batería ni CPU.
- Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
- Es un componente de cliente de Next.js: incluye la directiva "use client".