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

Iris Transition

Переход-диафрагма: новая страница раскрывается кругом из точки клика (clip-path circle), радиус считается до дальнего угла.

gsapclickrevealintermediate~70 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

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

ПропТипПо умолчаниюЧто это
duration?number0.6Длительность раскрытия, сек (формат «0.6»).
ease?string"power3.out"Кривая GSAP (формат «power3.out»).
seed?number0.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

Что нужно в проекте до установки
  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 { 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