Ir al contenido
Evaluar mi proyecto
Componentes · ScrollDisponible

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 velocidad

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

scroll-marquee.tsx82 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/scroll-marquee

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
words?string[]Opcional.
height?numberOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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.

VariableValor por defecto
--h420px

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)