← каталог
Tilt on Scroll
3D-наклон по прогрессу скролла: панель входит завалённой в перспективу, в центре вьюпорта выпрямляется и уходит в обратный наклон.
gsaplenisscrollparallaxintermediate~30 КБreduced-motion ✓
↕ демо длиннее кадра — прокрутите его
Установка
npx shadcn@latest add https://motiva.pages.dev/r/tilt-on-scroll.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";
const CARDS = [
{ n: "01", title: "Наклонена в глубину", hue: 280 },
{ n: "02", title: "Выпрямляется в центре", hue: 210 },
{ n: "03", title: "И снова уходит", hue: 150 },
];
/**
* 3D-наклон по прогрессу скролла: панель входит завалённой в перспективу (rotateX),
* в центре вьюпорта выпрямляется, к выходу заваливается в другую сторону (scrub).
* Уважает reduced-motion / data-shot (панели плоские).
*/
export function TiltOnScroll() {
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;
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 ctx = gsap.context(() => {
el.querySelectorAll<HTMLElement>(".tos-card").forEach((card) => {
// keyframes: завалена → плоско в центре вьюпорта → завалена в другую сторону
gsap.fromTo(
card,
{ rotateX: 32, scale: 0.92, opacity: 0.55 },
{
keyframes: [
{ rotateX: 0, scale: 1, opacity: 1 },
{ rotateX: -32, scale: 0.92, opacity: 0.55 },
],
ease: "none",
scrollTrigger: { trigger: card, start: "top bottom", end: "bottom top", scrub: true },
},
);
});
}, el);
return () => {
ctx.revert();
gsap.ticker.remove(rafFn);
lenis.destroy();
};
}, []);
return (
<div ref={root} style={{ width: "100%", color: "oklch(0.92 0.01 265)" }}>
<section style={{ height: "40vh", display: "grid", placeItems: "center", opacity: 0.55 }}>
<span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}>крути — панели в перспективе ↓</span>
</section>
<div style={{ perspective: 900, display: "grid", gap: "22vh", paddingInline: "10vw", paddingBottom: "20vh" }}>
{CARDS.map((c) => (
<section
key={c.n}
className="tos-card"
style={{
height: "52vh",
borderRadius: 20,
border: "1px solid oklch(0.3 0.02 270)",
background: `linear-gradient(165deg, oklch(0.3 0.06 ${c.hue} / 0.85), oklch(0.16 0.02 270))`,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: "1.8rem",
transformOrigin: "50% 50%",
willChange: "transform, opacity",
}}
>
<span style={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem", opacity: 0.6 }}>{c.n}</span>
<h3 style={{ margin: 0, fontSize: "1.6rem", fontWeight: 750, letterSpacing: "-0.02em" }}>{c.title}</h3>
</section>
))}
</div>
</div>
);
}
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
/**
* Tilt on Scroll — vanilla JS/GSAP+Lenis (без React). Панели входят завалёнными в
* перспективу (rotateX), выпрямляются в центре вьюпорта и уходят в обратный наклон.
*
* <div style="perspective:900px">
* <section data-tos-card>…</section>
* <section data-tos-card>…</section>
* </div>
* <script type="module">
* import { initTiltOnScroll } from './tilt-on-scroll.vanilla.js';
* const destroy = initTiltOnScroll(document.body); // или контейнер панелей
* </script>
*
* @param {HTMLElement} root — контейнер с [data-tos-card] (родителю нужен perspective)
* @param {{ angle?: number }} [options] — входной угол, град (формат «32»)
* @returns {() => void} destroy
*/
export function initTiltOnScroll(root, { angle = 32 } = {}) {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (document.documentElement.hasAttribute("data-shot") || reduce) return () => {};
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 ctx = gsap.context(() => {
root.querySelectorAll("[data-tos-card]").forEach((card) => {
card.style.willChange = "transform, opacity";
// keyframes: завалена → плоско в центре вьюпорта → завалена в другую сторону
gsap.fromTo(
card,
{ rotateX: angle, scale: 0.92, opacity: 0.55 },
{
keyframes: [
{ rotateX: 0, scale: 1, opacity: 1 },
{ rotateX: -angle, scale: 0.92, opacity: 0.55 },
],
ease: "none",
scrollTrigger: { trigger: card, start: "top bottom", end: "bottom top", scrub: true },
},
);
});
}, root);
return () => {
ctx.revert();
gsap.ticker.remove(rafFn);
lenis.destroy();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (порт из fb2025/astro-boilerplate юзера, pm-3d)v1.0.0