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

Pixel Transition

Пиксельный переход: экран рассыпается на квадраты, вспыхивающие в случайном порядке, под ними меняется страница — и так же случайно гаснут.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
cols?number14Колонок в сетке (формат «14»): крупнее пиксель — грубее и заметнее рассыпание.
duration?number0.34Длительность одной половины перехода, сек (формат «0.34»).
stagger?number0.0028Задержка между соседними клетками, сек (формат «0.0028»): весь разброс = each × число клеток.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/pixel-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 PixelTransitionProps {
  /** Колонок в сетке (формат «14»): крупнее пиксель — грубее и заметнее рассыпание. */
  cols?: number;
  /** Длительность одной половины перехода, сек (формат «0.34»). */
  duration?: number;
  /** Задержка между соседними клетками, сек (формат «0.0028»): весь разброс = each × число клеток. */
  stagger?: number;
}

/**
 * Пиксельный переход: экран «рассыпается» на квадраты, которые вспыхивают в случайном
 * порядке (stagger from:"random"), под ними меняется страница, затем квадраты так же
 * случайно гаснут. Никаких масок и фильтров — только scale, поэтому дёшево на GPU.
 *
 * На клетках НЕТ постоянного `will-change`: 112 промотированных слоёв висели бы в памяти
 * всё время жизни страницы ради анимации на полторы секунды. GSAP поднимает элемент в
 * композитор на время твина сам (`force3D: "auto"`) и опускает обратно после.
 *
 * Reduced-motion/data-shot — мгновенная смена без рассыпания.
 */
export function PixelTransition({
  cols = 14,
  duration = 0.34,
  stagger = 0.0028,
}: PixelTransitionProps = {}) {
  // строк — из пропорции кадра, чтобы клетка оставалась близкой к квадрату
  const COLS = Math.max(4, Math.round(cols));
  const ROWS = Math.max(3, Math.round(COLS * 0.58));
  const CELLS = Array.from({ length: COLS * ROWS }, (_, i) => i);

  const [page, setPage] = useState(0);
  const busy = useRef(false);
  const tl = useRef<gsap.core.Timeline | null>(null);
  const gridRef = 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;
    const grid = gridRef.current;
    if (frozen() || !grid) {
      setPage(next);
      return;
    }
    busy.current = true;
    const cells = grid.children;
    tl.current = gsap
      .timeline({
        onComplete: () => {
          busy.current = false;
        },
      })
      .to(cells, {
        scale: 1,
        duration,
        ease: "power2.out",
        stagger: { each: stagger, from: "random" },
      })
      .add(() => setPage(next))
      .to(
        cells,
        { scale: 0, duration, ease: "power2.in", stagger: { each: stagger, from: "random" } },
        "+=0.08",
      );
  };

  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% 32%, ${cur.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" }}>{cur.title}</div>
          <div style={{ marginTop: 6, fontFamily: "ui-monospace, monospace", fontSize: "0.75rem", opacity: 0.55 }}>
            {cur.note} — экран рассыпается на пиксели
          </div>
        </div>
      </div>

      <div
        ref={gridRef}
        aria-hidden
        style={{
          position: "absolute",
          inset: 0,
          display: "grid",
          gridTemplateColumns: `repeat(${COLS}, 1fr)`,
          gridTemplateRows: `repeat(${ROWS}, 1fr)`,
          zIndex: 2,
          pointerEvents: "none",
        }}
      >
        {CELLS.map((i) => (
          <div
            key={i}
            style={{
              background: "oklch(0.24 0.035 285)",
              transform: "scale(0)",
              // клетки чуть перекрываются — иначе между ними просвечивают щели
              outline: "1px solid oklch(0.24 0.035 285)",
            }}
          />
        ))}
      </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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0