← каталог
Iris Transition
Переход-диафрагма: новая страница раскрывается кругом из точки клика (clip-path circle), радиус считается до дальнего угла.
gsapclickrevealintermediate~70 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| duration? | number | 0.6 | Длительность раскрытия, сек (формат «0.6»). |
| ease? | string | "power3.out" | Кривая GSAP (формат «power3.out»). |
| seed? | number | 0.32 | Стартовый размер круга долей от конечного (формат «0.32»). Это ГЛАВНАЯ ручка характера: 0.02 — круг вылетает из точки и раздувается в полсотни раз, 0.32 (по умолчанию) — раскрывается спокойно, как диафрагма. Ноль ставить нельзя: контр-масштаб контента считается как 1/seed и уходит в бесконечность. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/iris-transition.jsonзависимости: gsap@^3.15.0
Что нужно в проекте до установки
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 { useLayoutEffect, useRef, useState } from "react";
import gsap from "gsap";
const PAGES = [
{ title: "Главная", accent: "oklch(0.62 0.19 280)", note: "страница A" },
{ title: "Проекты", accent: "oklch(0.66 0.16 230)", note: "страница B" },
{ title: "Контакты", accent: "oklch(0.7 0.16 60)", note: "страница C" },
];
function PageView({ page }: { page: (typeof PAGES)[number] }) {
return (
<div
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
background: `radial-gradient(70% 80% at 50% 32%, ${page.accent.replace(")", " / 0.28)")}, transparent 70%), oklch(0.15 0.02 270)`,
}}
>
<div style={{ textAlign: "center" }}>
<div style={{ fontSize: "2.2rem", fontWeight: 750, letterSpacing: "-0.03em" }}>{page.title}</div>
<div style={{ marginTop: 6, fontFamily: "ui-monospace, monospace", fontSize: "0.75rem", opacity: 0.55 }}>
{page.note} — раскрывается из точки клика
</div>
</div>
</div>
);
}
export interface IrisTransitionProps {
/** Длительность раскрытия, сек (формат «0.6»). */
duration?: number;
/** Кривая GSAP (формат «power3.out»). */
ease?: string;
/**
* Стартовый размер круга долей от конечного (формат «0.32»).
* Это ГЛАВНАЯ ручка характера: 0.02 — круг вылетает из точки и раздувается в полсотни раз,
* 0.32 (по умолчанию) — раскрывается спокойно, как диафрагма. Ноль ставить нельзя:
* контр-масштаб контента считается как 1/seed и уходит в бесконечность.
*/
seed?: number;
}
/**
* Переход-диафрагма, честно ускоренный композитором.
*
* Наивная реализация анимирует `clip-path: circle()` — и она ТОРМОЗИТ: clip-path живёт на
* стадии отрисовки, поэтому каждый кадр браузер заново растрирует полноэкранный слой вместе
* с его градиентом. Здесь вместо этого круглый контейнер масштабируется трансформом
* (чистый композитор, ноль перерисовки), а вложенная страница получает ОБРАТНЫЙ масштаб
* с точкой отсчёта в месте клика — поэтому контент внутри круга стоит неподвижно и не
* «раздувается» вместе с ним.
*
* Про умолчания. Раньше здесь стояли `seed: 0.02` и `expo.out`: круг вылетал из точки,
* раздувался в пятьдесят раз, и «экспоненциальная» кривая проходила почти весь путь
* за первую четверть времени — два множителя эффектности складывались, и переход читался
* как трюк, а не как навигация. Сейчас круг стартует уже заметным (треть конечного размера),
* поэтому раскрытие идёт примерно втрое, а не в полсотни раз. Характер настраивается тремя
* пропсами, крутилки на странице записи меняют ровно их — прежнее поведение возвращается
* одним движением ползунка.
*/
export function IrisTransition({
duration = 0.6,
ease = "power3.out",
seed = 0.32,
}: IrisTransitionProps = {}) {
// ноль сломал бы контр-масштаб (1/seed), поэтому зажимаем снизу
const SEED = Math.min(0.6, Math.max(0.01, seed));
const [page, setPage] = useState(0);
const [incoming, setIncoming] = useState<number | null>(null);
const geom = useRef({ x: 0, y: 0, r: 0, w: 0, h: 0 });
const circle = useRef<HTMLDivElement>(null);
const inner = useRef<HTMLDivElement>(null);
const root = useRef<HTMLDivElement>(null);
const frozen = () =>
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const go = (next: number, e: React.MouseEvent) => {
if (next === page || incoming !== null) return; // защита от клика поверх идущего перехода
if (frozen()) {
setPage(next);
return;
}
const b = root.current?.getBoundingClientRect();
if (!b) return;
const x = e.clientX - b.left;
const y = e.clientY - b.top;
// радиус до самого дальнего угла — круг гарантированно накроет кадр из любой точки
const r = Math.max(
Math.hypot(x, y),
Math.hypot(b.width - x, y),
Math.hypot(x, b.height - y),
Math.hypot(b.width - x, b.height - y),
);
geom.current = { x, y, r, w: b.width, h: b.height };
setIncoming(next);
};
useLayoutEffect(() => {
const c = circle.current;
const i = inner.current;
if (incoming === null || !c || !i) return;
const tl = gsap.timeline({
onComplete: () => {
setPage(incoming);
setIncoming(null);
},
});
// круг растёт, контент контр-масштабируется — обе анимации только transform
tl.fromTo(c, { scale: SEED }, { scale: 1, duration, ease }, 0).fromTo(
i,
{ scale: 1 / SEED },
{ scale: 1, duration, ease },
0,
);
return () => {
tl.kill();
};
}, [incoming, duration, ease, SEED]);
const cur = PAGES[page] ?? PAGES[0]!;
const next = incoming !== null ? PAGES[incoming] : null;
const { x, y, r, w, h } = geom.current;
return (
<div ref={root} style={{ position: "absolute", inset: 0, overflow: "hidden", color: "oklch(0.92 0.01 265)" }}>
<PageView page={cur} />
{next && (
<div
ref={circle}
aria-hidden
style={{
position: "absolute",
left: x - r,
top: y - r,
width: r * 2,
height: r * 2,
borderRadius: "50%",
overflow: "hidden",
zIndex: 2,
willChange: "transform",
}}
>
<div
ref={inner}
style={{
position: "absolute",
left: r - x,
top: r - y,
width: w,
height: h,
// точка отсчёта = центр круга в координатах контента, иначе страницу уведёт вбок
transformOrigin: `${x}px ${y}px`,
willChange: "transform",
}}
>
<PageView page={next} />
</div>
</div>
)}
<nav
style={{
position: "absolute",
bottom: 18,
left: "50%",
transform: "translateX(-50%)",
display: "flex",
gap: 8,
padding: "8px 10px",
borderRadius: 999,
border: "1px solid oklch(0.32 0.02 270)",
background: "oklch(0.2 0.02 270 / 0.8)",
backdropFilter: "blur(10px)",
zIndex: 3,
}}
>
{PAGES.map((p, i) => {
// подсветку ведём по ЦЕЛИ перехода, а не по доехавшему состоянию: иначе кнопка
// остаётся неактивной всё время раскрытия и клик кажется непринятым
const on = i === (incoming ?? page);
return (
<button
key={p.title}
onClick={(e) => go(i, e)}
aria-current={on}
style={{
border: "none",
borderRadius: 999,
padding: "7px 14px",
fontSize: "0.8rem",
cursor: "pointer",
background: on ? p.accent : "transparent",
color: on ? "oklch(0.15 0.02 270)" : "oklch(0.85 0.01 265)",
fontWeight: 600,
transition: "background 0.3s ease, color 0.3s ease",
}}
>
{p.title}
</button>
);
})}
</nav>
</div>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0