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

SVG Parallax Scene

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

cssscrollparallaxadvanced0reduced-motion ✓
↕ демо длиннее кадра — прокрутите его
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
title?string"ГОРИЗОНТ"Надпись, которую закрывают облака.
subtitle?string"семь планов, одна лента"Строка под надписью.
length?number3Сколько экранов прокрутки занимает сцена (формат «3»).
depth?number1Множитель разбега планов: 0.5 — почти плоско, 1.6 — резкая глубина (формат «1»).
drift?number1Множитель хода облаков (формат «1»).
clouds?"raster" | "vector""raster"Облака: «raster» — настоящие PNG с альфой (свои снимки облаков подставляются заменой адресов), «vector» — фигуры из эллипсов, ноль ассетов.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/svg-parallax-scene.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 { 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