Tilt on Scroll
3D-наклон по прогрессу скролла: панель входит завалённой в перспективу, в центре вьюпорта выпрямляется и уходит в обратный наклон.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/tilt-on-scroll.jsonзависимости: gsap@^3.15.0 · lenis@^1.3.25
Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.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] — входной угол, град
* @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();
};
}