← каталог
Pixel Transition
Пиксельный переход: экран рассыпается на квадраты, вспыхивающие в случайном порядке, под ними меняется страница — и так же случайно гаснут.
gsapclickstaggerintermediate~70 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| cols? | number | 14 | Колонок в сетке (формат «14»): крупнее пиксель — грубее и заметнее рассыпание. |
| duration? | number | 0.34 | Длительность одной половины перехода, сек (формат «0.34»). |
| stagger? | number | 0.0028 | Задержка между соседними клетками, сек (формат «0.0028»): весь разброс = each × число клеток. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/pixel-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 { 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