Fait mainSans modèlesPrêt à lancer
Fait mainSans modèlesPrêt à lancer
Fait mainSans modèlesPrêt à lancer
Fait mainSans modèlesPrêt à lancer
React + MotionTexte et défilement

Bandeau défilant réactif

Quatre lignes de texte géant défilent en sens opposés, accélèrent et penchent avec le scroll, puis s’inversent quand vous remontez.

Réglages

3%/s

Code

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 = "Fait main · Sans modèles · Prêt à lancer",
  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 pour l’IA

Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.

Plus dans Texte et défilement

Où l’utiliser

Un bandeau qui réagit à la vitesse du scroll donne du rythme entre deux sections : une agence qui montre ses métiers, un festival avec ses dates et ses villes, une marque de mode qui affiche son slogan. Lignes pleines, lignes fantômes et bande de couleur défilent à contresens, et plus on descend vite, plus elles penchent.

Deux à quatre phrases courtes séparées par « · » se lisent mieux que de longues phrases. C’est la couleur de la bande qui donne du punch, choisissez-la en premier. Si le visiteur a demandé moins d’animations à son système, les lignes restent immobiles et le texte reste lisible.