Motiva
Мои коллекции
← каталог

Scroll Vars Loop

Прокрутка как два числа на элементе: положение и скорость. Положение отдаёт браузер прокруточным таймлайном, скорость — производная, которой в CSS взять неоткуда; дальше вся анимация читает переменные и не знает про скрипт. Лента замкнута: с нижней кромки прокрутка возвращается в начало, и стык не виден, потому что картинка периодична.

cssscrollparallaxadvanced~1 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
frames?string[]["01", "02", "03", "04", "05", "06"]Подписи кадров ленты (формат «["01","02"]»).
length?number3Сколько экранов прокрутки занимает запись (формат «3»).
speed?number2.2При какой скорости прокрутки `--svl-velocity` доходит до 1, px/мс (формат «2.2»).
blur?number7Насколько скорость размывает ленту, px на единицу скорости (формат «7»).
skew?number9Насколько скорость кренит ленту, градусы на единицу скорости (формат «9»).
loop?booleantrueЗамыкать прокрутку: докрутил до конца — вернуло в начало, и так без конца.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/scroll-vars-loop.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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