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

RGB Shift Scroll

Заголовок разъезжается на цветные каналы тем сильнее, чем быстрее крутят страницу. Разъезд настоящий, а не цветные тени: надпись набрана трижды — красной, зелёной и синей копией, — и копии складываются режимом screen, поэтому совпав дают ровно белое, а разъехавшись показывают каналы по краям.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
lines?string[]["СКОРОСТЬ", "РАЗВОДИТ", "КАНАЛЫ"]Строки заголовка (формат «["ПЕРВАЯ","ВТОРАЯ"]»).
length?number3Сколько экранов прокрутки занимает запись (формат «3»).
speed?number2.2При какой скорости прокрутки разъезд максимален, px/мс (формат «2.2»).
amount?number14Максимальный разъезд каналов, px (формат «14»).
blur?number1.4Максимальное размытие крайних каналов, px (формат «1.4»).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/rgb-shift-scroll.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 RgbShiftScrollProps {
  /** Строки заголовка (формат «["ПЕРВАЯ","ВТОРАЯ"]»). */
  lines?: string[];
  /** Сколько экранов прокрутки занимает запись (формат «3»). */
  length?: number;
  /** При какой скорости прокрутки разъезд максимален, px/мс (формат «2.2»). */
  speed?: number;
  /** Максимальный разъезд каналов, px (формат «14»). */
  amount?: number;
  /** Максимальное размытие крайних каналов, px (формат «1.4»). */
  blur?: number;
}

const CHANNELS = [
  { key: "r", color: "#f00", dir: -1 },
  { key: "g", color: "#0f0", dir: 0 },
  { key: "b", color: "#00f", dir: 1 },
] as const;

/**
 * Заголовок разъезжается на цветные каналы тем сильнее, чем быстрее крутят страницу.
 *
 * Разъезд здесь НАСТОЯЩИЙ, а не нарисованный цветными тенями. Надпись набрана трижды —
 * красной, зелёной и синей копией друг поверх друга, — и копии складываются режимом
 * `screen`. Сложение аддитивное, поэтому пока копии совпадают, `screen` трёх чистых
 * каналов даёт ровно белый: на экране одна обычная белая надпись, а не три цветных.
 * Стоит копиям разъехаться — по краям проступают те самые каналы, и ровно так же,
 * как их разводит настоящая оптика.
 *
 * Отсюда следует единственное требование записи, и оно не косметическое: **под надписью
 * нужна тёмная подложка**. Аддитивное сложение считается от чёрного; над белым фоном
 * `screen` даст белое при любом сдвиге, то есть эффекта не будет вовсе. Подложка —
 * часть приёма, поэтому она внутри записи, вместе с `isolation: isolate`: без изоляции
 * копии смешивались бы не между собой, а со всей страницей под ними.
 *
 * Второй вход — СКОРОСТЬ прокрутки. Её в CSS взять неоткуда: прокруточный таймлайн знает,
 * где страница, но не знает, как быстро она туда ехала. Производная считается тем же
 * десятком строк с засыпающим циклом, что и в `scroll-vars-loop` — сама по себе она
 * здесь не приём, а вход: запись публикует `--rss-v` (0…1), а всё остальное делает CSS.
 * В покое цикла кадров нет, и надпись стоит идеально белой.
 */
export function RgbShiftScroll({
  lines = ["СКОРОСТЬ", "РАЗВОДИТ", "КАНАЛЫ"],
  length = 3,
  speed = 2.2,
  amount = 14,
  blur = 1.4,
}: RgbShiftScrollProps) {
  const root = useRef<HTMLDivElement>(null);
  const uid = 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: надпись просто белая

    const scroller = document.scrollingElement ?? document.documentElement;
    let last = scroller.scrollTop;
    let lastT = performance.now();
    let target = 0;
    let cur = 0;
    let raf = 0;

    const write = (v: number) => el.style.setProperty("--rss-v", 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;
      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);
    };

    window.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      window.removeEventListener("scroll", onScroll);
      cancelAnimationFrame(raf);
    };
  }, [speed]);

  return (
    <div ref={root} className="rss" data-rss={uid} style={{ ["--rss-length" as string]: length }}>
      <style>{`
        [data-rss="${uid}"] {
          --rss-v: 0;
          position: relative;
          width: 100%;
          height: calc(var(--rss-length, 3) * 100svh);
        }
        [data-rss="${uid}"] .rss-plate {
          position: sticky; top: 0;
          height: 100svh;
          display: grid;
          place-content: center;
          gap: 0.06em;
          padding-inline: var(--ds-gutter, clamp(18px, 5vw, 54px));
          /* подложка — часть приёма: аддитивное сложение считается от чёрного.
             isolation обязателен, иначе копии смешиваются со страницей, а не между собой */
          isolation: isolate;
          background: oklch(0.12 0.02 275);
          font-family: var(--ds-font, system-ui, sans-serif);
        }

        [data-rss="${uid}"] .rss-line {
          display: grid;
          justify-items: center;
          font-size: clamp(2.2rem, 1rem + 6.4vw, 6rem);
          font-weight: 800;
          line-height: 0.98;
          letter-spacing: var(--ds-tracking-display, -0.045em);
        }
        /* постоянный will-change тут не нужен: слоёв девять, живут они долями секунды
           за прокрутку, а держать их промотированными всё остальное время — плата
           без выигрыша (та же грабля, что снимали с клеток pixel-transition) */
        [data-rss="${uid}"] .rss-ch {
          grid-area: 1 / 1;
          mix-blend-mode: screen;
        }
        /* совпали — screen трёх чистых каналов даёт белое; разъехались — видно каналы */
        [data-rss="${uid}"] .rss-r {
          color: #f00;
          translate: calc(var(--rss-v) * ${-amount}px) calc(var(--rss-v) * -2px);
          filter: blur(calc(var(--rss-v) * ${blur}px));
        }
        [data-rss="${uid}"] .rss-g { color: #0f0; }
        [data-rss="${uid}"] .rss-b {
          color: #00f;
          translate: calc(var(--rss-v) * ${amount}px) calc(var(--rss-v) * 2px);
          filter: blur(calc(var(--rss-v) * ${blur}px));
        }

        [data-rss="${uid}"] .rss-hint {
          margin: 1.4em 0 0;
          justify-self: center;
          font: 400 var(--ds-text-xs, 0.76rem) / 1.6 ui-monospace, monospace;
          color: oklch(0.62 0.02 265);
          text-align: center;
        }

        /* Запасной вид там, где смешивания нет: показываем один канал белым, а не три
           цветных копии поверх друг друга — иначе надпись становится нечитаемой кашей. */
        @supports not (mix-blend-mode: screen) {
          [data-rss="${uid}"] .rss-ch { mix-blend-mode: normal; color: oklch(0.97 0 0); }
          [data-rss="${uid}"] .rss-r, [data-rss="${uid}"] .rss-b { visibility: hidden; }
        }
      `}</style>

      <div className="rss-plate">
        {lines.map((line) => (
          <span className="rss-line" key={line}>
            {CHANNELS.map((c) => (
              <span className={`rss-ch rss-${c.key}`} key={c.key} aria-hidden={c.dir !== 0}>
                {line}
              </span>
            ))}
          </span>
        ))}
        <p className="rss-hint">крутите резче — каналы расходятся</p>
      </div>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — https://codepen.io/fand/pen/PorYXaq (лицензия не указана, код не заимствован)v1.0.0