← каталог
Velocity Marquee
Бегущая строка, у которой скорость и наклон берутся из СКОРОСТИ прокрутки, а не из её положения. Базовый темп крутит CSS-анимация, Lenis лишь сжимает её цикл и кренит ленту. Сила связи, предел разгона и наклон вынесены в крутилки — «слишком/недостаточно» решается ползунком.
csslenisscrollautoparallaxintermediate~30 КБreduced-motion ✓
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | string[] | ["MOTIVA", "GSAP", "MOTION", "THREE", "L… | |
| baseDuration? | number | 18 | Темп в покое: сколько секунд занимает один проход ленты (формат «18»). |
| minDuration? | number | 3 | Предел разгона — короче этого цикл не становится, как бы резко ни крутили (формат «3»). |
| boost? | number | 1.6 | Насколько скорость прокрутки сжимает цикл. 0 — обычная бегущая строка (формат «1.6»). |
| maxSkew? | number | 14 | Максимальный наклон ленты на резком движении, градусы (формат «14»). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/velocity-marquee.jsonзависимости: lenis@^1.3.25
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
.vm-viewport {
overflow: hidden;
width: 100%;
max-width: 100%;
padding: 1.6rem 0;
border-block: 1px solid oklch(0.28 0.02 270);
}
/* Наклон ленты — задаётся скоростью скролла через переменную */
.vm-skew {
transform: skewX(var(--vm-skew, 0deg));
transition: transform 0.25s ease-out;
}
/* Базовое движение — CSS-анимация (надёжно, всегда крутится). Контент задублирован → -50% бесшовно. */
.vm-track {
display: inline-flex;
gap: 2.5rem;
white-space: nowrap;
will-change: transform;
animation: vm-scroll var(--vm-dur, 18s) linear infinite;
}
.vm-track span {
font-size: 2.2rem;
font-weight: 800;
color: oklch(0.9 0.01 265);
}
.vm-track .vm-star {
color: oklch(0.72 0.15 220);
}
@keyframes vm-scroll {
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.vm-track {
animation: none;
}
.vm-skew {
transform: none;
}
}
"use client";
import { useEffect, useRef } from "react";
import Lenis from "lenis";
import "./velocity-marquee.css";
const clamp = (min: number, max: number, v: number) => Math.max(min, Math.min(max, v));
/**
* ВАЖНО: сама запись — только лента. Полосы, которые можно прокрутить, живут в `preview.tsx`:
* это демо-обвязка, и в компоненте ей не место — тот, кто поставит запись себе, получил бы
* вместе с ней две пустые секции на пол-экрана.
*
* Бегущая строка: базовый темп крутит CSS-анимация (надёжно), а скорость скролла (Lenis)
* задаёт её длительность (разгон) и наклон ленты через CSS-переменные. Разгон заметный
* намеренно: при слабой связи запись неотличима от обычной бегущей строки — именно на это
* и жаловались, пока полосы прокрутки были срезаны клипом. Замирает при
* reduced-motion / data-shot. Контент задублирован для бесшовной петли.
*/
export interface VelocityMarqueeProps {
items?: string[];
/** Темп в покое: сколько секунд занимает один проход ленты (формат «18»). */
baseDuration?: number;
/** Предел разгона — короче этого цикл не становится, как бы резко ни крутили (формат «3»). */
minDuration?: number;
/** Насколько скорость прокрутки сжимает цикл. 0 — обычная бегущая строка (формат «1.6»). */
boost?: number;
/** Максимальный наклон ленты на резком движении, градусы (формат «14»). */
maxSkew?: number;
}
export function VelocityMarquee({
items = ["MOTIVA", "GSAP", "MOTION", "THREE", "LENIS", "WEBGL"],
baseDuration = 18,
minDuration = 3,
boost = 1.6,
maxSkew = 14,
}: VelocityMarqueeProps) {
const skew = useRef<HTMLDivElement>(null);
const track = useRef<HTMLDivElement>(null);
useEffect(() => {
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) return; // статику держат CSS-reset / media-query
const lenis = new Lenis({ duration: 1.1 });
let rafId = requestAnimationFrame(function raf(t) {
lenis.raf(t);
rafId = requestAnimationFrame(raf);
});
let idle = 0;
lenis.on("scroll", (l: { velocity: number }) => {
const v = l.velocity;
// разгон нелинейный: слабое движение почти не трогает темп, резкое — сжимает цикл
// с 18 с до 3 с. На линейной связи разница читалась как дрожание, а не как разгон.
track.current?.style.setProperty(
"--vm-dur",
`${clamp(minDuration, baseDuration, baseDuration - Math.abs(v) * boost)}s`,
);
skew.current?.style.setProperty("--vm-skew", `${clamp(-maxSkew, maxSkew, v * -0.5)}deg`);
clearTimeout(idle);
idle = window.setTimeout(() => {
track.current?.style.setProperty("--vm-dur", `${baseDuration}s`);
skew.current?.style.setProperty("--vm-skew", "0deg");
}, 150);
});
return () => {
cancelAnimationFrame(rafId);
clearTimeout(idle);
lenis.destroy();
};
}, [baseDuration, minDuration, boost, maxSkew]);
return (
// overflow-x: clip, а НЕ overflow: hidden. Сокращённое `hidden` клипует по обеим осям
// и срезает собственные полосы прокрутки записи — а без них скорость прокрутки, которой
// эта лента и живёт, взять неоткуда: в кадре остаётся обычная равномерная бегущая строка.
// Плюс `clip` не делает элемент скролл-контейнером (см. грабли превью-сцены в AGENTS).
<div style={{ width: "100%", maxWidth: "100%", overflowX: "clip" }}>
<div ref={skew} className="vm-skew">
<div className="vm-viewport">
<div
ref={track}
className="vm-track"
style={{ ["--vm-dur" as string]: `${baseDuration}s` }}
>
{[...items, ...items].map((t, i) => (
<span key={i}>
{t} <span className="vm-star">✳</span>
</span>
))}
</div>
</div>
</div>
</div>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0