Velocity Marquee
Бегущая строка, у которой скорость и наклон берутся из СКОРОСТИ прокрутки, а не из её положения. Базовый темп крутит CSS-анимация, Lenis лишь сжимает её цикл и кренит ленту. Сила связи, предел разгона и наклон вынесены в крутилки — «слишком/недостаточно» решается ползунком.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | string[] | ["MOTIVA", "GSAP", "MOTION", "THREE", "L… | |
| baseDuration? | number | 18 | Темп в покое: сколько секунд занимает один проход ленты. |
| minDuration? | number | 3 | Предел разгона — короче этого цикл не становится, как бы резко ни крутили. |
| boost? | number | 1.6 | Насколько скорость прокрутки сжимает цикл. 0 — обычная бегущая строка. |
| maxSkew? | number | 14 | Максимальный наклон ленты на резком движении, градусы. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/velocity-marquee.jsonзависимости: lenis@^1.3.25
Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.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[];
/** Темп в покое: сколько секунд занимает один проход ленты. */
baseDuration?: number;
/** Предел разгона — короче этого цикл не становится, как бы резко ни крутили. */
minDuration?: number;
/** Насколько скорость прокрутки сжимает цикл. 0 — обычная бегущая строка. */
boost?: number;
/** Максимальный наклон ленты на резком движении, градусы. */
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` не делает элемент скролл-контейнером.
<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>
);
}