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

Curtain Transition

Переход между страницами шторкой: полосы наезжают снизу stagger-ом, контент меняется под ними, полосы уходят вверх.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
stripes?number4Число полос (формат «4»): больше полос — дробнее и быстрее «закрывается» кадр.
duration?number0.45Длительность наезда, сек (формат «0.45»).
stagger?number0.07Задержка между полосами, сек (формат «0.07»).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/curtain-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 { useEffect, 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" },
];

export interface CurtainTransitionProps {
  /** Число полос (формат «4»): больше полос — дробнее и быстрее «закрывается» кадр. */
  stripes?: number;
  /** Длительность наезда, сек (формат «0.45»). */
  duration?: number;
  /** Задержка между полосами, сек (формат «0.07»). */
  stagger?: number;
}

/**
 * Переход между «страницами» шторкой: полосы наезжают снизу stagger-ом, контент меняется
 * под шторкой, полосы уходят вверх. Reduced-motion/data-shot — мгновенная смена без шторки.
 */
export function CurtainTransition({
  stripes = 4,
  duration = 0.45,
  stagger = 0.07,
}: CurtainTransitionProps = {}) {
  const COLS = Array.from({ length: Math.max(2, Math.round(stripes)) }, (_, i) => i);
  const [page, setPage] = useState(0);
  const busy = useRef(false);
  const tl = useRef<gsap.core.Timeline | null>(null);
  const stripesRef = useRef<HTMLDivElement>(null);

  // размонтирование посреди перехода: без этого твин доигрывает вхолостую и дёргает setState
  useEffect(() => () => void tl.current?.kill(), []);

  const frozen = () =>
    document.documentElement.hasAttribute("data-shot") ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  const go = (next: number) => {
    if (next === page || busy.current) return;
    if (frozen() || !stripesRef.current) {
      setPage(next);
      return;
    }
    busy.current = true;
    const cols = stripesRef.current.children;
    tl.current = gsap
      .timeline({
        onComplete: () => {
          busy.current = false;
        },
      })
      .set(cols, { transformOrigin: "50% 100%" })
      .to(cols, { scaleY: 1, duration, ease: "power3.inOut", stagger })
      .add(() => setPage(next))
      .set(cols, { transformOrigin: "50% 0%" })
      .to(cols, { scaleY: 0, duration: duration + 0.05, ease: "power3.inOut", stagger }, "+=0.1");
  };

  const cur = PAGES[page] ?? PAGES[0]!;

  return (
    <div style={{ position: "absolute", inset: 0, overflow: "hidden", color: "oklch(0.92 0.01 265)" }}>
      <div
        style={{
          position: "absolute",
          inset: 0,
          display: "grid",
          placeItems: "center",
          background: `radial-gradient(70% 80% at 50% 30%, ${cur.accent.replace(")", " / 0.25)")}, transparent 70%), oklch(0.15 0.02 270)`,
        }}
      >
        <div style={{ textAlign: "center" }}>
          <div style={{ fontSize: "2.2rem", fontWeight: 750, letterSpacing: "-0.03em" }}>{cur.title}</div>
          <div style={{ marginTop: 6, fontFamily: "ui-monospace, monospace", fontSize: "0.75rem", opacity: 0.55 }}>
            {cur.note} — жми пункты меню ↓
          </div>
        </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) => (
          <button
            key={p.title}
            onClick={() => go(i)}
            aria-current={i === page}
            style={{
              border: "none",
              borderRadius: 999,
              padding: "7px 14px",
              fontSize: "0.8rem",
              cursor: "pointer",
              background: i === page ? p.accent : "transparent",
              color: i === page ? "oklch(0.15 0.02 270)" : "oklch(0.85 0.01 265)",
              fontWeight: 600,
            }}
          >
            {p.title}
          </button>
        ))}
      </nav>

      <div
        ref={stripesRef}
        aria-hidden
        style={{
          position: "absolute",
          inset: 0,
          display: "grid",
          gridTemplateColumns: `repeat(${COLS.length}, 1fr)`,
          zIndex: 2,
          pointerEvents: "none",
        }}
      >
        {COLS.map((i) => (
          <div
            key={i}
            style={{
              background: "oklch(0.22 0.03 285)",
              transform: "scaleY(0)",
              transformOrigin: "50% 100%",
              marginInline: -0.5,
            }}
          />
        ))}
      </div>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0