← каталог
RGB Shift Scroll
Заголовок разъезжается на цветные каналы тем сильнее, чем быстрее крутят страницу. Разъезд настоящий, а не цветные тени: надпись набрана трижды — красной, зелёной и синей копией, — и копии складываются режимом screen, поэтому совпав дают ровно белое, а разъехавшись показывают каналы по краям.
cssscrollmorphadvanced~1 КБreduced-motion ✓
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| lines? | string[] | ["СКОРОСТЬ", "РАЗВОДИТ", "КАНАЛЫ"] | Строки заголовка (формат «["ПЕРВАЯ","ВТОРАЯ"]»). |
| length? | number | 3 | Сколько экранов прокрутки занимает запись (формат «3»). |
| speed? | number | 2.2 | При какой скорости прокрутки разъезд максимален, px/мс (формат «2.2»). |
| amount? | number | 14 | Максимальный разъезд каналов, px (формат «14»). |
| blur? | number | 1.4 | Максимальное размытие крайних каналов, px (формат «1.4»). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/rgb-shift-scroll.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 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