← каталог
Scroll Vars Loop
Прокрутка как два числа на элементе: положение и скорость. Положение отдаёт браузер прокруточным таймлайном, скорость — производная, которой в CSS взять неоткуда; дальше вся анимация читает переменные и не знает про скрипт. Лента замкнута: с нижней кромки прокрутка возвращается в начало, и стык не виден, потому что картинка периодична.
cssscrollparallaxadvanced~1 КБreduced-motion ✓
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| frames? | string[] | ["01", "02", "03", "04", "05", "06"] | Подписи кадров ленты (формат «["01","02"]»). |
| length? | number | 3 | Сколько экранов прокрутки занимает запись (формат «3»). |
| speed? | number | 2.2 | При какой скорости прокрутки `--svl-velocity` доходит до 1, px/мс (формат «2.2»). |
| blur? | number | 7 | Насколько скорость размывает ленту, px на единицу скорости (формат «7»). |
| skew? | number | 9 | Насколько скорость кренит ленту, градусы на единицу скорости (формат «9»). |
| loop? | boolean | true | Замыкать прокрутку: докрутил до конца — вернуло в начало, и так без конца. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/scroll-vars-loop.jsonЧто нужно в проекте до установки
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.
Экспорт
Код
"use client";
import { useEffect, useId, useRef } from "react";
export interface ScrollVarsLoopProps {
/** Подписи кадров ленты (формат «["01","02"]»). */
frames?: string[];
/** Сколько экранов прокрутки занимает запись (формат «3»). */
length?: number;
/** При какой скорости прокрутки `--svl-velocity` доходит до 1, px/мс (формат «2.2»). */
speed?: number;
/** Насколько скорость размывает ленту, px на единицу скорости (формат «7»). */
blur?: number;
/** Насколько скорость кренит ленту, градусы на единицу скорости (формат «9»). */
skew?: number;
/** Замыкать прокрутку: докрутил до конца — вернуло в начало, и так без конца. */
loop?: boolean;
}
/**
* Прокрутка как ДВА числа на элементе: положение и скорость. Дальше вся анимация — чистый
* CSS, который эти числа читает; ни один твин здесь ничего не двигает.
*
* Смысл записи в том, что числа эти разной природы, и берутся они по-разному:
*
* - `--svl-progress` — доля пройденного, 0…1. Её отдаёт сам браузер: свойство объявлено
* через `@property` (без объявления оно строка, а строки не интерполируются) и
* анимируется на `animation-timeline: view()` с диапазоном `contain` — ровно на то время,
* пока запись держит экран. Скрипт к нему не прикасается: 0 КБ, отматывается назад
* вместе с прокруткой, в покое не работает ничего.
* - `--svl-velocity` — модуль скорости, 0…1. А вот это ПРОИЗВОДНАЯ, и её в CSS взять
* неоткуда: прокруточный таймлайн знает, ГДЕ страница, но не знает, как быстро она туда
* ехала. Отсюда единственный скрипт записи — десяток строк, считающих Δy/Δt.
*
* Почему `view(contain)`, а не `scroll()`: `scroll()` дал бы прогресс ВСЕГО документа, и
* запись работала бы правильно только на пустой странице. `contain` у элемента выше экрана
* идёт ровно от «накрыл экран» до «перестал накрывать» — то есть совпадает с липким
* удержанием кадра, и запись остаётся местной, куда её ни поставь.
*
* Скорость нельзя просто записать в переменную и забыть: значение залипнет на последнем
* рывке, и лента останется навсегда перекошенной. Поэтому цель затухает сама, к ней
* подтягивается текущее значение, а цикл кадров ЗАСЫПАЕТ, когда оба ушли в ноль
* (замер: 0 вызовов rAF за секунду покоя). Тот же результат на ScrollTrigger стоил бы
* постоянно живого цикла — он не даёт странице заснуть даже неподвижной.
*
* Замыкание. Докрутили до низа — прокрутка возвращается в начало, и лента бесконечна.
* Стык не виден не потому, что его чем-то прикрыли, а потому что картинка ПЕРИОДИЧНА:
* дорожка склеена из двух одинаковых половин и едет ровно на половину за полный прогресс,
* кольцо доворачивается ровно на 360°. В момент возврата прогресс идёт 1 → 0, а на экране
* при этом ровно то же, что и было.
*/
export function ScrollVarsLoop({
frames = ["01", "02", "03", "04", "05", "06"],
length = 3,
speed = 2.2,
blur = 7,
skew = 9,
loop = true,
}: ScrollVarsLoopProps) {
const root = useRef<HTMLDivElement>(null);
const id = useId().replace(/[^a-zA-Z0-9]/g, "");
useEffect(() => {
const el = root.current;
if (!el) return;
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen) return; // скорость остаётся 0, прогресс держит @media внутри @supports
const scroller = document.scrollingElement ?? document.documentElement;
let last = scroller.scrollTop;
let lastT = performance.now();
let target = 0;
let cur = 0;
let raf = 0;
let wrapped = false;
const write = (v: number) => el.style.setProperty("--svl-velocity", v.toFixed(3));
const frame = () => {
cur += (target - cur) * 0.2;
target *= 0.86; // цель гаснет сама — иначе перекос залипает на последнем рывке
if (cur > 0.002 || target > 0.002) {
write(cur);
raf = requestAnimationFrame(frame);
} else {
write(0);
raf = 0; // засыпаем: в покое цикла кадров нет вовсе
}
};
const onScroll = () => {
const now = performance.now();
const y = scroller.scrollTop;
if (wrapped) {
// собственный прыжок на другую кромку — не движение пользователя:
// скорость от него расти не должна, иначе замыкание само себя разгоняет
wrapped = false;
last = y;
lastT = now;
return;
}
const dt = Math.max(1, now - lastT);
target = Math.min(1, Math.abs(y - last) / dt / speed);
last = y;
lastT = now;
if (!raf) raf = requestAnimationFrame(frame);
if (!loop) return;
const max = scroller.scrollHeight - scroller.clientHeight;
if (max < 40) return;
// на самой кромке прокрутка перебрасывается на противоположную; на экране в этот
// момент то же самое, потому что картинка периодична
if (y >= max) {
wrapped = true;
scroller.scrollTop = 1;
} else if (y <= 0) {
wrapped = true;
scroller.scrollTop = max - 1;
}
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
cancelAnimationFrame(raf);
};
}, [speed, loop]);
return (
<div ref={root} className="svl" data-svl={id} style={{ ["--svl-length" as string]: length }}>
<style>{`
/* без @property это строки, а строки браузер не интерполирует */
@property --svl-progress { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --svl-velocity { syntax: "<number>"; inherits: true; initial-value: 0; }
[data-svl="${id}"] {
--svl-progress: 0;
--svl-velocity: 0;
position: relative;
/* ширину задаём явно: иначе коробка берёт её у содержимого, а содержимое —
дорожка в max-content на 2336 px, и запись вылезает из кадра на 1366 */
width: 100%;
height: calc(var(--svl-length, 3) * 100svh);
font-family: var(--ds-font, system-ui, sans-serif);
color: var(--ds-fg, oklch(0.95 0.01 265));
}
@keyframes svl-run-${id} { to { --svl-progress: 1; } }
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
[data-svl="${id}"] {
animation: svl-run-${id} linear both;
animation-timeline: view();
animation-range: contain;
}
}
}
[data-svl="${id}"] .svl-stage {
position: sticky;
top: 0;
height: 100svh;
display: grid;
/* minmax(0, 1fr), а не 1fr: минимум колонки по умолчанию — auto, то есть
ширина самого широкого содержимого, и дорожка распирала бы сетку изнутри
ровно так же, как распирала бы её без клипа */
grid-template-columns: minmax(0, 1fr);
align-content: center;
gap: var(--ds-6, 26px);
padding: var(--ds-6, 26px) var(--ds-gutter, clamp(18px, 5vw, 54px));
overflow-x: clip; /* не hidden: сокращённое hidden клипует и по второй оси */
}
/* --- дальше ни одной строки скрипта: всё считается от двух чисел --- */
[data-svl="${id}"] .svl-strip {
position: relative;
overflow-x: clip;
border-block: 1px solid var(--ds-border, oklch(0.3 0.02 275));
padding-block: var(--ds-4, 16px);
/* крен и размытие — от СКОРОСТИ */
rotate: calc(var(--svl-velocity) * -${skew}deg);
filter: blur(calc(var(--svl-velocity) * ${blur}px));
}
[data-svl="${id}"] .svl-track {
display: flex;
width: max-content;
gap: var(--ds-6, 26px);
/* ...а ход ленты — от ПОЛОЖЕНИЯ. Дорожка из двух половин, ход ровно на половину:
в конце прогресса кадр тот же, что в начале, поэтому возврат не виден */
translate: calc(var(--svl-progress) * -50%) 0;
}
[data-svl="${id}"] .svl-frame {
flex: none;
display: grid;
place-items: center;
width: clamp(96px, 15vw, 168px);
aspect-ratio: 4 / 3;
border-radius: var(--ds-radius-lg, 18px);
border: 1px solid var(--ds-border, oklch(0.3 0.02 275));
background: var(--ds-surface, oklch(0.18 0.02 275));
font: 700 var(--ds-text-xl, 1.6rem) / 1 var(--ds-font, system-ui, sans-serif);
letter-spacing: var(--ds-tracking-display, -0.045em);
color: var(--ds-muted, oklch(0.72 0.02 265));
}
[data-svl="${id}"] .svl-frame:nth-child(odd) { color: var(--ds-accent, oklch(0.72 0.16 300)); }
[data-svl="${id}"] .svl-dial {
justify-self: center;
width: 84px;
aspect-ratio: 1;
border-radius: var(--ds-radius-pill, 999px);
border: 1px solid var(--ds-border, oklch(0.3 0.02 275));
display: grid;
place-items: center;
/* полный оборот за полный прогресс: 360° и 0° — одно и то же положение */
rotate: calc(var(--svl-progress) * 360deg);
}
[data-svl="${id}"] .svl-dial span {
display: block;
width: 3px;
height: 30px;
margin-bottom: 34px;
border-radius: 2px;
background: var(--ds-accent, oklch(0.72 0.16 300));
}
[data-svl="${id}"] .svl-meters { display: grid; gap: var(--ds-2, 8px); }
[data-svl="${id}"] .svl-meter {
display: grid;
grid-template-columns: 9ch 1fr;
align-items: center;
gap: var(--ds-3, 12px);
font: 500 var(--ds-text-xs, 0.76rem) / 1 ui-monospace, monospace;
color: var(--ds-muted, oklch(0.72 0.02 265));
}
[data-svl="${id}"] .svl-bar {
height: 6px;
border-radius: var(--ds-radius-pill, 999px);
background: var(--ds-surface, oklch(0.18 0.02 275));
overflow: clip;
}
[data-svl="${id}"] .svl-bar i {
display: block;
height: 100%;
border-radius: inherit;
background: var(--ds-accent, oklch(0.72 0.16 300));
}
[data-svl="${id}"] .svl-bar[data-of="progress"] i { width: calc(var(--svl-progress) * 100%); }
[data-svl="${id}"] .svl-bar[data-of="velocity"] i { width: calc(var(--svl-velocity) * 100%); }
[data-svl="${id}"] .svl-hint {
justify-self: center;
margin: 0;
font: 400 var(--ds-text-xs, 0.76rem) / 1.6 ui-monospace, monospace;
color: var(--ds-muted, oklch(0.72 0.02 265));
text-align: center;
}
`}</style>
<div className="svl-stage">
<div className="svl-meters">
<div className="svl-meter">
<span>progress</span>
<span className="svl-bar" data-of="progress">
<i />
</span>
</div>
<div className="svl-meter">
<span>velocity</span>
<span className="svl-bar" data-of="velocity">
<i />
</span>
</div>
</div>
<div className="svl-strip">
<div className="svl-track">
{[...frames, ...frames].map((f, i) => (
<div className="svl-frame" key={i}>
{f}
</div>
))}
</div>
</div>
<div className="svl-dial" aria-hidden="true">
<span />
</div>
<p className="svl-hint">
крутите быстрее — лента кренится и размывается
<br />
докрутите до конца — вернёт в начало
</p>
</div>
</div>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: адаптация идеи пена «Scroll progress & velocity as CSS vars» (cobra_winfrey, https://codepen.io/cobra_winfrey/pen/podWgvv, MIT); код Motivav1.0.0