← каталог
SVG Parallax Scene
Пейзаж, у которого семь планов разъезжаются на прокрутке, целиком лежит в одном svg: планы живут в координатах viewBox, поэтому ресайз не стоит ни одного пересчёта, а рассинхрониться они не могут — лента у всех общая. Заголовок закрывают плывущие облака через маску, содержимое которой само едет: текст не замутнён туманом, а вырезан, то есть оказывается позади облаков.
cssscrollparallaxadvanced0reduced-motion ✓
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| title? | string | "ГОРИЗОНТ" | Надпись, которую закрывают облака. |
| subtitle? | string | "семь планов, одна лента" | Строка под надписью. |
| length? | number | 3 | Сколько экранов прокрутки занимает сцена (формат «3»). |
| depth? | number | 1 | Множитель разбега планов: 0.5 — почти плоско, 1.6 — резкая глубина (формат «1»). |
| drift? | number | 1 | Множитель хода облаков (формат «1»). |
| clouds? | "raster" | "vector" | "raster" | Облака: «raster» — настоящие PNG с альфой (свои снимки облаков подставляются заменой адресов), «vector» — фигуры из эллипсов, ноль ассетов. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/svg-parallax-scene.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 { useId } from "react";
export interface SvgParallaxSceneProps {
/** Надпись, которую закрывают облака. */
title?: string;
/** Строка под надписью. */
subtitle?: string;
/** Сколько экранов прокрутки занимает сцена (формат «3»). */
length?: number;
/** Множитель разбега планов: 0.5 — почти плоско, 1.6 — резкая глубина (формат «1»). */
depth?: number;
/** Множитель хода облаков (формат «1»). */
drift?: number;
/**
* Облака: «raster» — настоящие PNG с альфой (свои снимки облаков подставляются
* заменой адресов), «vector» — фигуры из эллипсов, ноль ассетов.
*/
clouds?: "raster" | "vector";
}
/**
* Пейзаж, у которого планы разъезжаются на прокрутке, — но вся сцена целиком лежит
* в ОДНОМ `<svg>`, а не собрана из десятка позиционированных `<div>` с фоновыми картинками.
*
* Из этого следует всё остальное:
*
* - планам не нужна раскладка. Они живут в системе координат `viewBox`, поэтому при любом
* размере окна остаются на своих местах друг относительно друга, и ресайз не стоит
* ни одного пересчёта. Слоёный параллакс на `<div>` за то же самое платит либо
* процентами (которые считаются от РАЗНЫХ родителей), либо обмером в скрипте;
* - планы не могут рассинхрониться. Все семь анимаций привязаны к ОДНОЙ ленте
* (`animation-timeline: view()`), и ведёт её браузер. Это сильнее, чем «запустили твины
* вместе»: у общего таймлайна нет своего хода, который мог бы разойтись с прокруткой —
* он и есть прокрутка;
* - у сцены появляется настоящая ГЛУБИНА: облака закрывают надпись не потому, что нарисованы
* поверх неё, а потому что надпись ВЫРЕЗАНА там, где они проходят.
*
* Вырезание — та самая находка, ради которой запись и заведена. Заголовок обёрнут в `<mask>`,
* содержимое которой САМО едет: в маске лежит белый прямоугольник (видно всё) и те же
* облака чёрным по размытию (не видно ничего). Одни и те же фигуры объявлены один раз
* в `<defs>` и вставлены дважды — в маску и в видимый слой, — а совпадают они не потому,
* что их синхронизируют, а потому что обе копии висят на общей ленте.
*
* Разница с «нарисовать облака поверх текста» не косметическая. Полупрозрачное облако
* поверх даёт замутнённый текст, который читается как подпись под туманом. Маска УБИРАЕТ
* текст, и он оказывается позади облаков — то есть внутри пейзажа.
*
* Про растр и вектор — оба режима легальны, но по разным правилам. Ключ в том, что
* растровый слой обязан жить В КООРДИНАТАХ viewBox (`<image>` внутри svg), а не отдельным
* `<div>` с картинкой: тогда общая система координат сохраняется, и маска продолжает
* работать. Вырезает при этом не «фотография», а её АЛЬФА-КАНАЛ, перекрашенный в чёрный
* (feColorMatrix): у PNG с честной альфой кромка выреза — это и есть край облака.
* Фотография БЕЗ альфы (прямоугольный jpeg) так не может — ей нечем резать.
*
* Скрипта нет вовсе: 0 КБ, отматывается назад вместе с прокруткой, в покое не работает
* ничего. Источник вёл ту же сцену скрабом GSAP; здесь это не нужно — ни одному плану
* не требуется состояние между кадрами, всем нужна только доля прокрутки.
*/
export function SvgParallaxScene({
title = "ГОРИЗОНТ",
subtitle = "семь планов, одна лента",
length = 3,
depth = 1,
drift = 1,
clouds = "raster",
}: SvgParallaxSceneProps) {
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const d = (v: number) => Math.round(v * depth);
const k = (v: number) => Math.round(v * drift);
const raster = clouds !== "vector";
return (
<div
className="pps"
data-pps={uid}
style={{ ["--pps-length" as string]: length }}
>
<style>{`
/* без @property это строка, а строки браузер не интерполирует */
@property --pps-p { syntax: "<number>"; inherits: true; initial-value: 0; }
[data-pps="${uid}"] {
--pps-p: 0;
position: relative;
/* ширину задаём явно: без неё коробка берёт её у содержимого, а содержимое —
svg с width: 100%, и размер сходится на собственных пропорциях viewBox.
В кадре 1078×520 сцена вставала на 780 px и висела с полями по бокам */
width: 100%;
height: calc(var(--pps-length, 3) * 100svh);
/* палитра сцены своя: это пейзаж, а не блок интерфейса */
--pps-ink: oklch(0.97 0.02 95);
--pps-cloud: oklch(0.93 0.04 70);
}
[data-pps="${uid}"] .pps-stage {
position: sticky; top: 0;
height: 100svh;
overflow: clip;
}
[data-pps="${uid}"] svg { display: block; width: 100%; height: 100%; }
[data-pps="${uid}"] .pps-title text {
font-family: var(--ds-font, system-ui, sans-serif);
fill: var(--pps-ink);
}
/* заливка облаков — правилом, а не атрибутом: var() в презентационном атрибуте
разбирается не везде одинаково, а тут от неё зависит, видно ли слой вообще */
[data-pps="${uid}"] .pps-clouds { fill: var(--pps-cloud); }
[data-pps="${uid}"] .pps-h {
font-size: 132px; font-weight: 800; letter-spacing: -6px;
}
[data-pps="${uid}"] .pps-sub {
font-size: 26px; font-weight: 500; letter-spacing: 6px;
fill: color-mix(in oklab, var(--pps-ink) 78%, transparent);
}
/* Кегль в единицах viewBox, а порог — по реальной ширине окна. Это не косметика:
при slice узкий кадр показывает только середину сцены, и надпись прежнего
кегля просто не помещается в неё — на 380 px от «ГОРИЗОНТ» оставалось «ГОР». */
@media (max-width: 760px) {
[data-pps="${uid}"] .pps-h { font-size: 96px; letter-spacing: -4px; }
[data-pps="${uid}"] .pps-sub { font-size: 20px; letter-spacing: 4px; }
}
@media (max-width: 520px) {
[data-pps="${uid}"] .pps-h { font-size: 66px; letter-spacing: -2px; }
[data-pps="${uid}"] .pps-sub { font-size: 15px; letter-spacing: 3px; }
}
/* ОДНА анимация на всю сцену: она двигает единственное число, а семь планов это
число читают. Вешать view() на сами планы нельзя — этот таймлайн меряет
прохождение ЧЕРЕЗ экран того элемента, на котором объявлен, а планы заперты
в липкой сцене и через экран не проходят вовсе: замер такой версии показал
все слои в КОНЕЧНОМ положении уже при нулевой прокрутке. Считать проход должна
внешняя коробка сцены — она через экран как раз идёт. */
@keyframes pps-run-${uid} { to { --pps-p: 1; } }
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
[data-pps="${uid}"] {
animation: pps-run-${uid} linear both;
animation-timeline: view();
animation-range: contain;
}
}
}
[data-pps="${uid}"] .pps-sky { transform: translateY(calc(var(--pps-p) * ${-d(20)}px)); }
[data-pps="${uid}"] .pps-sun { transform: translateY(calc(var(--pps-p) * ${-d(60)}px)); }
[data-pps="${uid}"] .pps-far { transform: translateY(calc(var(--pps-p) * ${-d(90)}px)); }
[data-pps="${uid}"] .pps-mid { transform: translateY(calc(var(--pps-p) * ${-d(140)}px)); }
[data-pps="${uid}"] .pps-title { transform: translateY(calc(var(--pps-p) * ${-d(190)}px)); }
[data-pps="${uid}"] .pps-near { transform: translateY(calc(var(--pps-p) * ${-d(210)}px)); }
[data-pps="${uid}"] .pps-cloud-a {
transform: translate(calc(var(--pps-p) * ${-k(560)}px), calc(var(--pps-p) * ${-d(40)}px));
}
[data-pps="${uid}"] .pps-cloud-b {
transform: translate(calc(var(--pps-p) * ${k(420)}px), calc(var(--pps-p) * ${-d(70)}px));
}
/* статичный режим для снимков: сцена собрана, а не разъехавшаяся */
[data-shot] [data-pps="${uid}"] { animation: none; --pps-p: 0; }
`}</style>
<div className="pps-stage">
<svg viewBox="0 0 1200 800" preserveAspectRatio="xMidYMid slice" role="img" aria-label={title}>
<defs>
<linearGradient id={`pps-sky-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="oklch(0.28 0.09 285)" />
<stop offset="0.55" stopColor="oklch(0.52 0.13 40)" />
<stop offset="1" stopColor="oklch(0.74 0.14 65)" />
</linearGradient>
<radialGradient id={`pps-glow-${uid}`}>
<stop offset="0" stopColor="oklch(0.95 0.12 80)" stopOpacity="0.85" />
<stop offset="1" stopColor="oklch(0.95 0.12 80)" stopOpacity="0" />
</radialGradient>
<filter id={`pps-soft-${uid}`} x="-30%" y="-60%" width="160%" height="220%">
<feGaussianBlur stdDeviation="9" />
</filter>
{/* фигуры облаков объявлены ОДИН раз: одна и та же форма поедет и в видимом
слое, и внутри маски — сверять их между собой не придётся */}
{/* Оба облака стоят близко к центру и расходятся в РАЗНЫЕ стороны — не ради
симметрии: `slice` при узком кадре показывает только середину viewBox
(на 380 px — 585 единиц из 1200), и облака с краёв сцены на телефоне
не пересекли бы надпись ни разу, то есть приём был бы не виден вовсе. */}
{raster ? (
<>
{/* растровые облака живут в ТЕХ ЖЕ координатах viewBox — общая система
сохраняется; свои снимки (PNG с альфой) подставляются заменой href */}
<g id={`pps-ca-${uid}`}>
<image href="https://motiva.pages.dev/img/clouds/cloud-a-1024x420.png" x="640" y="312" width="360" height="148" />
</g>
<g id={`pps-cb-${uid}`}>
<image href="https://motiva.pages.dev/img/clouds/cloud-b-1024x420.png" x="216" y="334" width="340" height="140" />
</g>
{/* в маске облако должно быть ЧЁРНЫМ, а резать — его альфа: RGB в ноль,
альфа как есть. Кромка выреза = край облака из PNG. */}
<filter id={`pps-ink-${uid}`}>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" />
</filter>
</>
) : (
<>
<g id={`pps-ca-${uid}`}>
<ellipse cx="820" cy="388" rx="122" ry="30" />
<ellipse cx="782" cy="364" rx="70" ry="32" />
<ellipse cx="856" cy="356" rx="56" ry="28" />
<ellipse cx="906" cy="374" rx="60" ry="22" />
<ellipse cx="726" cy="382" rx="48" ry="18" />
</g>
<g id={`pps-cb-${uid}`}>
<ellipse cx="380" cy="404" rx="130" ry="26" />
<ellipse cx="344" cy="382" rx="76" ry="30" />
<ellipse cx="420" cy="376" rx="58" ry="26" />
<ellipse cx="480" cy="396" rx="58" ry="20" />
</g>
</>
)}
{/* Маска, содержимое которой само едет. Белое — видно, чёрное/альфа — вырезано;
облака идут по тем же кадрам, что и видимые, потому что лента общая. */}
<mask id={`pps-mask-${uid}`} maskUnits="userSpaceOnUse" x="0" y="0" width="1200" height="800">
<rect x="0" y="0" width="1200" height="800" fill="#fff" />
<g fill="#000" filter={raster ? `url(#pps-ink-${uid})` : `url(#pps-soft-${uid})`}>
<use href={`#pps-ca-${uid}`} className="pps-layer pps-cloud-a" />
<use href={`#pps-cb-${uid}`} className="pps-layer pps-cloud-b" />
</g>
</mask>
</defs>
{/* 1. небо.
Все планы намеренно уходят далеко ВНИЗ за пределы viewBox: план, поднятый
на свои 380 единиц, обязан всё ещё закрывать нижнюю кромку кадра. Первая
версия кончалась у самого низа сцены, и к концу прокрутки под передним планом
открывалась пустота — видно это было только на дальних положениях. */}
<g className="pps-layer pps-sky">
<rect x="-100" y="-120" width="1400" height="1800" fill={`url(#pps-sky-${uid})`} />
</g>
{/* 2. солнце */}
<g className="pps-layer pps-sun">
<circle cx="860" cy="430" r="240" fill={`url(#pps-glow-${uid})`} />
<circle cx="860" cy="430" r="86" fill="oklch(0.93 0.13 80)" />
</g>
{/* 3. дальний хребет */}
<g className="pps-layer pps-far">
<path
d="M-100 560 L120 452 L260 516 L430 404 L610 512 L780 430 L940 508 L1120 446 L1300 540 L1300 1700 L-100 1700 Z"
fill="oklch(0.42 0.06 300)"
opacity="0.75"
/>
</g>
{/* 4. надпись — ПОД облаками и вырезана ими.
Маска висит на ВНЕШНЕЙ группе, а едет внутренняя. Если повесить её на ту же
группу, что и движется, маска поедет вместе с надписью: при
`maskUnits="userSpaceOnUse"` система координат маски — это система координат
элемента, который её вызвал, вместе с его собственным трансформом. Облака
тогда прилипли бы к тексту и не закрывали бы его никогда. */}
<g mask={`url(#pps-mask-${uid})`}>
<g className="pps-layer pps-title">
<text className="pps-h" x="600" y="436" textAnchor="middle">
{title}
</text>
<text className="pps-sub" x="600" y="484" textAnchor="middle">
{subtitle}
</text>
</g>
</g>
{/* 5. облака — те же фигуры (или те же PNG), что и в маске */}
{raster ? (
<g opacity="0.96">
<use href={`#pps-ca-${uid}`} className="pps-layer pps-cloud-a" />
<use href={`#pps-cb-${uid}`} className="pps-layer pps-cloud-b" />
</g>
) : (
<g className="pps-clouds" opacity="0.9" filter={`url(#pps-soft-${uid})`}>
<use href={`#pps-ca-${uid}`} className="pps-layer pps-cloud-a" />
<use href={`#pps-cb-${uid}`} className="pps-layer pps-cloud-b" />
</g>
)}
{/* 6. средний хребет */}
<g className="pps-layer pps-mid">
<path
d="M-100 660 L180 536 L360 620 L560 500 L760 616 L980 528 L1180 632 L1300 566 L1300 1700 L-100 1700 Z"
fill="oklch(0.3 0.05 300)"
/>
</g>
{/* 7. передний план */}
<g className="pps-layer pps-near">
<path
d="M-100 776 L160 690 L380 744 L620 664 L880 748 L1120 686 L1300 760 L1300 1700 L-100 1700 Z"
fill="oklch(0.18 0.03 295)"
/>
<path
d="M-100 900 L1300 900 L1300 1700 L-100 1700 Z"
fill="oklch(0.13 0.02 295)"
/>
</g>
</svg>
</div>
</div>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: адаптация идеи пена «SVG parallax scene» (Creative Ocean, https://codepen.io/creativeocean/pen/qBbBLyB, MIT); код Motivav1.0.0