← каталог
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
Что нужно в проекте до установки
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, 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>
);
}
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
/**
* Sticky Image Reveal — vanilla JS/GSAP+Lenis (без React). Главы слева, липкая картинка справа
* меняется blur-кроссфейдом на смене главы (классы .is-active, стили инжектятся).
*
* <div id="story">
* <div data-sir-block>Глава 1…</div> <div data-sir-block>Глава 2…</div>
* <div style="position:sticky;top:12vh"><img data-sir-img src="1.jpg"><img data-sir-img src="2.jpg"></div>
* </div>
* <script type="module">
* import { initStickyImageReveal } from './sticky-image-reveal.vanilla.js';
* const destroy = initStickyImageReveal(document.querySelector('#story'));
* </script>
*
* @param {HTMLElement} root — контейнер с [data-sir-block] (по порядку) и [data-sir-img]
* @returns {() => void} destroy
*/
export function initStickyImageReveal(root) {
const blocks = [...root.querySelectorAll("[data-sir-block]")];
const imgs = [...root.querySelectorAll("[data-sir-img]")];
if (!blocks.length || !imgs.length) return () => {};
const style = document.createElement("style");
style.dataset.sir = "";
style.textContent = `
[data-sir-block] { opacity: 0.25; transition: opacity 0.5s; }
[data-sir-block].is-active { opacity: 1; }
[data-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; }
[data-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); }`;
document.head.appendChild(style);
const activate = (idx) => {
blocks.forEach((b, i) => b.classList.toggle("is-active", i === idx));
imgs.forEach((im, i) => im.classList.toggle("is-active", i === idx));
};
activate(0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (document.documentElement.hasAttribute("data-shot") || reduce) return () => style.remove();
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({ duration: 1.1 });
lenis.on("scroll", ScrollTrigger.update);
const rafFn = (t) => lenis.raf(t * 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();
style.remove();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (порт из fb2025/astro-boilerplate юзера, pm-reveal)v1.0.0