Hecho a manoSin plantillasListo para lanzar
Hecho a manoSin plantillasListo para lanzar
Hecho a manoSin plantillasListo para lanzar
Hecho a manoSin plantillasListo para lanzar
React + MotionTexto y scroll

Cinta de titulares con inercia

Cuatro filas de texto gigante corren en sentidos opuestos, aceleran e inclinan con tu scroll y cambian de sentido al subir.

Ajustes

3%/s

Código

import { Fragment, useRef } from "react";
import {
  motion,
  useAnimationFrame,
  useMotionValue,
  useReducedMotion,
  useScroll,
  useSpring,
  useTransform,
  useVelocity,
  type MotionValue,
} from "framer-motion";

interface ScrollVelocityMarqueeProps {
  /** Frases separadas por "·". */
  text?: string;
  accent?: string;
  /** Velocidad base, en % de la cinta por segundo. */
  speed?: number;
  /** true: caja con scroll propio. false: reacciona al scroll de la página. */
  contained?: boolean;
}

type RowStyle = "solid" | "band" | "ghost";
const ROWS: { look: RowStyle; direction: 1 | -1; start: number }[] = [
  { look: "ghost", direction: 1, start: -9 },
  { look: "solid", direction: -1, start: -6 },
  { look: "band", direction: 1, start: -14 },
  { look: "ghost", direction: -1, start: -5 },
];

const wrap = (min: number, max: number, v: number) => {
  const r = max - min;
  return ((((v - min) % r) + r) % r) + min;
};

function Row({
  items,
  speed,
  direction,
  factor,
  skew,
  look,
  start,
  accent,
}: {
  items: string[];
  speed: number;
  direction: 1 | -1;
  start: number;
  factor: MotionValue<number>;
  skew: MotionValue<number>;
  look: RowStyle;
  accent: string;
}) {
  const baseX = useMotionValue(start);
  const dir = useRef<number>(direction);
  const reduce = useReducedMotion();
  const x = useTransform(baseX, (v) => wrap(-25, 0, v) + "%");

  useAnimationFrame((_, delta) => {
    if (reduce) return;
    const f = factor.get();
    if (f < 0) dir.current = -direction;
    else if (f > 0) dir.current = direction;
    baseX.set(baseX.get() + dir.current * speed * (delta / 1000) * (1 + Math.abs(f)));
  });

  const text =
    look === "band"
      ? "text-[#0b0b0c]"
      : look === "ghost"
        ? "text-[#1d1d1f]/[0.13] dark:text-[#f5f5f7]/[0.16]"
        : "text-[#1d1d1f] dark:text-[#f5f5f7]";
  const dot = look === "band" ? "bg-[#0b0b0c]" : "bg-current opacity-30";

  return (
    <div
      className={"overflow-hidden whitespace-nowrap " + (look === "band" ? "py-[0.08em]" : "")}
      style={look === "band" ? { background: accent } : undefined}
    >
      <motion.div className="flex w-max" style={{ x, skewX: skew }}>
        {Array.from({ length: 4 }, (_, copy) => (
          <span
            key={copy}
            aria-hidden={copy > 0 ? true : undefined}
            className={
              "flex shrink-0 items-center text-[clamp(44px,17cqi,140px)] font-semibold leading-[1.02] tracking-[-0.045em] " +
              text
            }
          >
            {items.map((item, i) => (
              <Fragment key={i}>
                <span className="px-[0.18em]">{item}</span>
                <span className={"mx-[0.12em] h-[0.14em] w-[0.14em] shrink-0 rounded-full " + dot} />
              </Fragment>
            ))}
          </span>
        ))}
      </motion.div>
    </div>
  );
}

export default function ScrollVelocityMarquee({
  text = "Hecho a mano · Sin plantillas · Listo para lanzar",
  accent = "#d4ff3a",
  speed = 3,
  contained = true,
}: ScrollVelocityMarqueeProps) {
  const scroller = useRef<HTMLDivElement>(null);
  const { scrollY } = useScroll(contained ? { container: scroller } : {});
  const velocity = useVelocity(scrollY);
  const smooth = useSpring(velocity, { damping: 50, stiffness: 400 });
  const factor = useTransform(smooth, [0, 1000], [0, 5], { clamp: false });
  const skew = useTransform(smooth, [-2000, 2000], [-12, 12]);
  const items = text
    .split("·")
    .map((s) => s.trim())
    .filter(Boolean);

  const rows = (
    <div className="flex w-full flex-col gap-[1.5cqi] [container-type:inline-size]">
      {ROWS.map((r, i) => (
        <Row
          key={i}
          items={items}
          speed={speed}
          direction={r.direction}
          start={r.start}
          factor={factor}
          skew={skew}
          look={r.look}
          accent={accent}
        />
      ))}
    </div>
  );

  if (!contained) return <div className="w-full overflow-hidden py-10">{rows}</div>;
  return (
    <div
      ref={scroller}
      className="relative h-[28rem] w-full overflow-y-auto overflow-x-hidden rounded-3xl bg-[#fafafa] [scrollbar-width:none] dark:bg-[#0a0a0a]"
    >
      <div className="h-[500%]">
        <div className="sticky top-0 flex h-[20%] items-center overflow-hidden">{rows}</div>
      </div>
    </div>
  );
}

Prompt para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Más en Texto y scroll

Dónde usarlo

Una cinta de texto que reacciona al scroll le da pulso a una página entre sección y sección: una agencia enseñando lo que hace, un festival con fechas y ciudades, una marca de moda gritando su lema. Filas macizas, filas en sombra y una banda de color corren a la contra, y cuanto más rápido bajas, más se inclinan.

Dos a cuatro frases cortas separadas por «·» se leen mejor que frases largas. El color de la banda es lo que le da el golpe, así que elígelo primero. Si alguien tiene activado reducir movimiento en su sistema, las filas se quedan quietas y el texto se lee bien.