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

Sticky Image Reveal

Скролл-история: главы текста слева, липкое изображение справа меняется кинематографичным кроссфейдом (blur+scale) на смене главы.

gsaplenisscrollrevealmorphintermediate~30 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его

Пропсы

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

ПропТипПо умолчаниюЧто это
chapters?Chapter[]CHAPTERS
Chapter — что внутри
  • label: string
  • title: string
  • text: string
  • img: string— URL изображения главы (демо-снимки каталога — замени на свои).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/sticky-image-reveal.json

зависимости: gsap@^3.15.0 · lenis@^1.3.25

Что нужно в проекте до установки
  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, useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";

export interface Chapter {
  label: string;
  title: string;
  text: string;
  /** URL изображения главы (демо-снимки каталога — замени на свои). */
  img: string;
}

const CHAPTERS: Chapter[] = [
  {
    label: "Глава 01",
    title: "Замысел",
    text: "Каждый проект начинается с вопроса «зачем». Исследование, референсы, настроение — фундамент, на котором держится всё остальное.",
    img: "https://motiva.pages.dev/img/48-700x900.jpg",
  },
  {
    label: "Глава 02",
    title: "Форма",
    text: "Сетка, типографика, ритм. Форма не украшает содержание — она и есть способ его прочитать.",
    img: "https://motiva.pages.dev/img/476-700x900.jpg",
  },
  {
    label: "Глава 03",
    title: "Движение",
    text: "Анимация отвечает на действие человека, а не живёт сама по себе. Так интерфейс становится диалогом.",
    img: "https://motiva.pages.dev/img/683-700x900.jpg",
  },
];

/**
 * Скролл-история: главы текста слева, липкое «изображение» справа. На смене главы картинка
 * меняется кинематографичным кроссфейдом (blur + scale + saturate). Уважает reduced-motion /
 * data-shot (первая глава активна статично).
 */
export function StickyImageReveal({ chapters = CHAPTERS }: { chapters?: Chapter[] }) {
  const root = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = root.current;
    if (!el) return;

    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const frozen = document.documentElement.hasAttribute("data-shot") || reduce;

    const blocks = el.querySelectorAll<HTMLElement>(".sir-block");
    const imgs = el.querySelectorAll<HTMLElement>(".sir-img");
    const activate = (idx: number) => {
      blocks.forEach((b, i) => b.classList.toggle("is-active", i === idx));
      imgs.forEach((im, i) => im.classList.toggle("is-active", i === idx));
    };
    activate(0);

    if (frozen) return;

    gsap.registerPlugin(ScrollTrigger);
    const lenis = new Lenis({ duration: 1.1 });
    lenis.on("scroll", ScrollTrigger.update);
    const rafFn = (time: number) => lenis.raf(time * 1000);
    gsap.ticker.add(rafFn);
    gsap.ticker.lagSmoothing(0);

    const triggers = [...blocks].map((block, idx) =>
      ScrollTrigger.create({
        trigger: block,
        start: "top 60%",
        end: "bottom 40%",
        onEnter: () => activate(idx),
        onEnterBack: () => activate(idx),
      }),
    );

    return () => {
      triggers.forEach((t) => t.kill());
      gsap.ticker.remove(rafFn);
      lenis.destroy();
    };
  }, [chapters]);

  return (
    <div ref={root} style={{ width: "100%", color: "oklch(0.92 0.01 265)" }}>
      <style>{`
        .sir-block { opacity: 0.25; transition: opacity 0.5s; }
        .sir-block.is-active { opacity: 1; }
        .sir-img {
          position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
          opacity: 0; transform: scale(1.2);
          filter: blur(12px) saturate(1.3); will-change: transform, opacity, filter;
          transition: opacity 0.3s ease-out, transform 0.3s ease-out, filter 0.3s ease-out;
        }
        .sir-img.is-active {
          opacity: 1; transform: scale(1); filter: blur(0) saturate(1);
          transition: opacity 0.6s cubic-bezier(0.16,1,0.3,1), transform 1s cubic-bezier(0.16,1,0.3,1),
            filter 0.8s cubic-bezier(0.16,1,0.3,1);
        }
      `}</style>
      <div
        style={{
          display: "grid",
          gridTemplateColumns: "1fr 1.05fr",
          gap: "5vw",
          paddingInline: "5vw",
        }}
      >
        <div>
          {chapters.map((c) => (
            <div
              key={c.label}
              className="sir-block"
              style={{
                minHeight: "85vh",
                display: "flex",
                flexDirection: "column",
                justifyContent: "center",
                gap: "0.8rem",
              }}
            >
              <span
                style={{
                  fontFamily: "ui-monospace, monospace",
                  fontSize: "0.7rem",
                  letterSpacing: "0.2em",
                  textTransform: "uppercase",
                  color: "oklch(0.72 0.15 300)",
                }}
              >
                {c.label}
              </span>
              <h3 style={{ margin: 0, fontSize: "1.7rem", fontWeight: 700, letterSpacing: "-0.02em" }}>{c.title}</h3>
              <p style={{ margin: 0, fontSize: "0.95rem", lineHeight: 1.6, color: "oklch(0.7 0.02 265)" }}>{c.text}</p>
            </div>
          ))}
        </div>
        <div>
          <div
            style={{
              position: "sticky",
              top: "12vh",
              height: "76vh",
              borderRadius: 18,
              overflow: "hidden",
              border: "1px solid oklch(0.3 0.02 270)",
            }}
          >
            {chapters.map((c, i) => (
              <img key={c.label} className="sir-img" src={c.img} alt={c.title} loading={i === 0 ? "eager" : "lazy"} />
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (порт из fb2025/astro-boilerplate юзера, pm-reveal)v1.0.0