Scroll Color Shift
Фон страницы плавно перекрашивается по мере прокрутки: тон идёт по цветовому кругу, и каждая секция оказывается в своей атмосфере — ноль скриптов. Прогресс берётся из animation-timeline: scroll() (доля прокрутки страницы — правильный источник для слоя, который по экрану не едет), интерполяцию цвета делает браузер в oklch — по дуге тона, без серых провалов rgb-смешивания.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| hue? | number | 265 | Тон в начале страницы, градусы oklch. |
| turns? | number | 0.75 | Сколько цветового круга пройти за всю прокрутку (0.25–2 оборота). |
| lightness? | number | 0.16 | Светлота фона: держим тёмной, чтобы текст поверх читался всегда. |
| chroma? | number | 0.05 | Насыщенность. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/scroll-color-shift.jsonЧто нужно в проекте до установки
Файл
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.
Экспорт
Код
/* база = цвет начала страницы: без поддержки прокруточных анимаций, под
prefers-reduced-motion и data-shot фон просто стоит в первом цвете */
.scsh { background: oklch(var(--scsh-l) var(--scsh-c) var(--scsh-h0)); }
@supports (animation-timeline: scroll()) {
.scsh {
animation: scsh-drift linear both;
animation-timeline: scroll();
}
/* var() в значении кадра разрешается НА ЭЛЕМЕНТЕ: кадры общие, тона у каждого
экземпляра свои — поэтому один <style href> на страницу остаётся честным */
@keyframes scsh-drift {
0% { background: oklch(var(--scsh-l) var(--scsh-c) var(--scsh-h0)); }
25% { background: oklch(var(--scsh-l) var(--scsh-c) var(--scsh-h1)); }
50% { background: oklch(var(--scsh-l) var(--scsh-c) var(--scsh-h2)); }
75% { background: oklch(var(--scsh-l) var(--scsh-c) var(--scsh-h3)); }
100% { background: oklch(var(--scsh-l) var(--scsh-c) var(--scsh-h4)); }
}
}
@media (prefers-reduced-motion: reduce) { .scsh { animation: none; } }
html[data-shot] .scsh { animation: none; }
const css = `/* база = цвет начала страницы: без поддержки прокруточных анимаций, под
prefers-reduced-motion и data-shot фон просто стоит в первом цвете */
.scsh { background: oklch(var(--scsh-l) var(--scsh-c) var(--scsh-h0)); }
@supports (animation-timeline: scroll()) {
.scsh {
animation: scsh-drift linear both;
animation-timeline: scroll();
}
/* var() в значении кадра разрешается НА ЭЛЕМЕНТЕ: кадры общие, тона у каждого
экземпляра свои — поэтому один <style href> на страницу остаётся честным */
@keyframes scsh-drift {
0% { background: oklch(var(--scsh-l) var(--scsh-c) var(--scsh-h0)); }
25% { background: oklch(var(--scsh-l) var(--scsh-c) var(--scsh-h1)); }
50% { background: oklch(var(--scsh-l) var(--scsh-c) var(--scsh-h2)); }
75% { background: oklch(var(--scsh-l) var(--scsh-c) var(--scsh-h3)); }
100% { background: oklch(var(--scsh-l) var(--scsh-c) var(--scsh-h4)); }
}
}
@media (prefers-reduced-motion: reduce) { .scsh { animation: none; } }
html[data-shot] .scsh { animation: none; }
`;
import type { CSSProperties } from "react";
export interface ScrollColorShiftProps {
/** Тон в начале страницы, градусы oklch. */
hue?: number;
/** Сколько цветового круга пройти за всю прокрутку (0.25–2 оборота). */
turns?: number;
/** Светлота фона: держим тёмной, чтобы текст поверх читался всегда. */
lightness?: number;
/** Насыщенность. */
chroma?: number;
}
/**
* Фон страницы плавно перекрашивается по мере прокрутки: тон идёт по цветовому
* кругу, и каждая секция оказывается в своей атмосфере — без единого скрипта.
*
* Источник прогресса — `animation-timeline: scroll()`: это доля ПРОКРУТКИ СТРАНИЦЫ,
* правильный выбор для слоя, который по экрану не едет (`view()` тут не подошёл бы —
* слой растянут на весь документ и «проходит через кадр» всю дорогу). Интерполяцию
* цвета делает сам браузер в oklch — по дуге тона, без серых провалов посередине,
* которые дала бы rgb-интерполяция. Кадры анимации общие, тона каждого экземпляра
* приходят переменными — `var()` в значении кадра разрешается на элементе.
*
* База (без поддержки, под `prefers-reduced-motion` и `data-shot`) — цвет начала
* страницы: статичный, но валидный фон.
*/
export function ScrollColorShift({
hue = 265,
turns = 0.75,
lightness = 0.16,
chroma = 0.05,
}: ScrollColorShiftProps = {}) {
const vars = Object.fromEntries(
[0, 0.25, 0.5, 0.75, 1].map((t, i) => [`--scsh-h${i}`, String(Math.round(hue + turns * 360 * t))]),
);
return (
<div
className="scsh"
style={
{
position: "absolute",
inset: 0,
pointerEvents: "none",
"--scsh-l": String(lightness),
"--scsh-c": String(chroma),
...vars,
} as CSSProperties
}
>
<style href="motiva-scroll-color-shift" precedence="medium">{css}</style>
</div>
);
}