← каталог
Isotope Filter
Фильтруемая сетка в духе Isotope: плитки переезжают на новые места, уходящие тают, приходящие всплывают. Раскладку делает CSS Grid, разницу между состояниями — GSAP Flip.
gsapclickhovermorphstaggerintermediate~4 КБreduced-motion ✓
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| items? | IsotopeItem[] | ITEMS | |
| allLabel? | string | "все" | Подпись кнопки «показать всё». |
IsotopeItem — что внутри
- src: string
- title: string
- cat: string
- wide?: boolean— Плитка на две колонки.
- tall?: boolean— Плитка на две строки.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/isotope-filter.jsonЧто нужно в проекте до установки
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";
import { Flip } from "gsap/Flip";
export interface IsotopeItem {
src: string;
title: string;
cat: string;
/** Плитка на две колонки. */
wide?: boolean;
/** Плитка на две строки. */
tall?: boolean;
}
export interface IsotopeFilterProps {
items?: IsotopeItem[];
/** Подпись кнопки «показать всё». */
allLabel?: string;
}
const p = (id: number, w: number, h: number) => `https://motiva.pages.dev/img/${id}-${w}x${h}.jpg`;
const ITEMS: IsotopeItem[] = [
{ src: p(1015, 800, 600), title: "Fjord", cat: "природа", wide: true },
{ src: p(1050, 600, 800), title: "Skyline", cat: "город", tall: true },
{ src: p(1080, 600, 600), title: "Berries", cat: "макро" },
{ src: p(1018, 600, 600), title: "Ridge", cat: "природа" },
{ src: p(1069, 800, 600), title: "Rooftop", cat: "город", wide: true },
{ src: p(1039, 600, 600), title: "Falls", cat: "природа" },
{ src: p(1060, 600, 600), title: "Cup", cat: "макро" },
{ src: p(1076, 600, 800), title: "Bridge", cat: "город", tall: true },
{ src: p(1043, 600, 600), title: "Valley", cat: "природа" },
{ src: p(1084, 600, 600), title: "Petals", cat: "макро" },
];
type Sort = "по умолчанию" | "по названию";
/**
* Фильтруемая сетка в духе Isotope: плитки не пропадают и не появляются рывком, а
* ПЕРЕЕЗЖАЮТ на новые места — уходящие тают, приходящие всплывают.
*
* Считать координаты руками не нужно: раскладку делает CSS Grid, а задачу «из состояния A
* в состояние B» решает плагин GSAP Flip. Мы меняем список — он сравнивает снимок до
* и после и анимирует разницу.
*
* Три вещи, каждая из которых была здесь сделана НЕПРАВИЛЬНО и каждая давала свой дефект.
*
* 1. ОТФИЛЬТРОВАННЫЕ ПЛИТКИ НЕ РАЗМОНТИРУЮТСЯ, а прячутся классом (`display: none`).
* React удалил бы их из DOM ДО того, как Flip успеет что-то анимировать, и никакого
* «уходящие тают» не получилось бы — узла уже нет. По той же причине Flip не увидел бы
* и приходящие. Живут все, меняется только видимость — это и есть канонический
* рецепт фильтра на Flip.
*
* 2. `absoluteOnLeave`, А НЕ `absolute`. При `absolute: true` из потока вынимаются ВСЕ
* плитки, сетка схлопывается в нулевую высоту — и всё, что стоит рядом, прыгает
* (в центрированном контейнере панель фильтров уезжала вниз на середине анимации).
* Из потока нужно вынимать только уходящих: остальные остаются в сетке, её высота
* не меняется, а движение всё равно рисует Flip трансформами.
*
* 3. КЛИК ПОВЕРХ ИДУЩЕЙ АНИМАЦИИ НЕ ГЛОТАЕМ. Раньше стояла защёлка `busy`, и нажатие
* в течение ~0.8 с после предыдущего просто пропадало — со стороны это выглядит как
* «работает через раз». Вместо этого `Flip.killFlipsOf` убивает текущий полёт, а
* снимок снимается прямо сейчас: плитки поедут с того места, где их застали.
*/
export function IsotopeFilter({ items = ITEMS, allLabel = "все" }: IsotopeFilterProps) {
const cats = [allLabel, ...Array.from(new Set(items.map((i) => i.cat)))];
const [cat, setCat] = useState(allLabel);
const [sort, setSort] = useState<Sort>("по умолчанию");
const root = useRef<HTMLDivElement>(null);
const snapshot = useRef<Flip.FlipState | null>(null);
/** высота сетки ДО перестройки — иначе контейнер схлопнется мгновенно, пока плитки летят */
const heightBefore = useRef(0);
const frozen = () =>
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const tiles = () => root.current?.querySelectorAll<HTMLElement>(".iso-tile") ?? [];
const change = (fn: () => void) => {
if (frozen()) {
fn();
return;
}
gsap.registerPlugin(Flip);
// текущий полёт убиваем и снимаем состояние прямо сейчас — клик не теряется,
// а плитки поедут с того места, где их застали
Flip.killFlipsOf(tiles());
const grid = root.current?.querySelector<HTMLElement>(".iso-grid");
if (grid) {
gsap.killTweensOf(grid);
// берём ТЕКУЩУЮ высоту (возможно, посреди прошлого твина) и тут же отпускаем
// inline-значение: иначе убитый твин оставляет сетке зафиксированную высоту
// навсегда — она перестаёт реагировать и на смену контента, и на ресайз
heightBefore.current = grid.getBoundingClientRect().height;
grid.style.height = "";
}
snapshot.current = Flip.getState(tiles());
fn();
};
useLayoutEffect(() => {
if (!snapshot.current) return;
const tween = Flip.from(snapshot.current, {
// без targets Flip знает только про плитки из старого снимка и не заметит,
// что часть из них снова показалась
targets: tiles(),
duration: 0.55,
ease: "power3.inOut",
absoluteOnLeave: true,
stagger: 0.02,
onEnter: (els) =>
gsap.fromTo(
els,
{ opacity: 0, scale: 0.86 },
{ opacity: 1, scale: 1, duration: 0.4, ease: "power2.out" },
),
onLeave: (els) => gsap.to(els, { opacity: 0, scale: 0.86, duration: 0.28, ease: "power2.in" }),
});
snapshot.current = null;
// Высота сетки — единственное, что здесь МЕНЯЕТ РАСКЛАДКУ, и меняет мгновенно:
// React перестроил сетку в том же кадре, а плитки ещё летят полсекунды. Всё, что
// стоит рядом, дёргается на месте. Поэтому высоту доводим твином — одна величина
// на одном элементе, зато соседний контент едет вместе с плитками, а не до них.
const grid = root.current?.querySelector<HTMLElement>(".iso-grid");
let height: gsap.core.Tween | undefined;
if (grid && heightBefore.current) {
const to = grid.getBoundingClientRect().height;
if (Math.abs(to - heightBefore.current) > 1) {
height = gsap.fromTo(
grid,
{ height: heightBefore.current },
{ height: to, duration: 0.55, ease: "power3.inOut", clearProps: "height" },
);
}
}
return () => {
tween.kill();
height?.kill();
};
}, [cat, sort]);
// порядок и видимость — по данным; из DOM ничего не выкидываем (см. п.1 в шапке)
const ordered =
sort === "по названию" ? items.slice().sort((a, b) => a.title.localeCompare(b.title)) : items;
const visible = (it: IsotopeItem) => cat === allLabel || it.cat === cat;
const shownCount = ordered.filter(visible).length;
return (
<div ref={root} className="iso">
<style>{`
.iso { width: 100%; font-family: var(--ds-font, system-ui, sans-serif); }
.iso-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--ds-2, 8px);
margin-bottom: var(--ds-4, 16px); }
.iso-chip { border: 1px solid var(--ds-border, oklch(0.3 0.02 275)); cursor: pointer;
border-radius: var(--ds-radius-pill, 999px); padding: 7px 14px; background: transparent;
font: 600 var(--ds-text-sm, 0.85rem) / 1 var(--ds-font, system-ui, sans-serif);
color: var(--ds-muted, oklch(0.72 0.02 265));
transition: color var(--ds-fast, 0.18s), border-color var(--ds-fast, 0.18s),
background var(--ds-fast, 0.18s); }
.iso-chip:hover { color: var(--ds-fg, oklch(0.95 0.01 265)); }
.iso-chip[data-on="1"] { color: var(--ds-accent-fg, oklch(0.14 0.02 275));
background: var(--ds-accent, oklch(0.72 0.16 300));
border-color: var(--ds-accent, oklch(0.72 0.16 300)); }
.iso-chip:focus-visible { outline: 2px solid var(--ds-accent-ring, oklch(0.72 0.16 300 / 0.35));
outline-offset: 3px; }
.iso-sort { margin-left: auto; display: flex; gap: var(--ds-1, 4px); align-items: center;
font-family: var(--ds-font-mono, ui-monospace, monospace);
font-size: var(--ds-text-xs, 0.72rem); color: var(--ds-muted, oklch(0.72 0.02 265)); }
.iso-sort button { border: 0; background: none; cursor: pointer; padding: 4px 8px;
border-radius: var(--ds-radius-sm, 8px); font: inherit;
color: var(--ds-muted, oklch(0.72 0.02 265));
transition: color var(--ds-fast, 0.18s), background var(--ds-fast, 0.18s); }
.iso-sort button[data-on="1"] { color: var(--ds-fg, oklch(0.95 0.01 265));
background: var(--ds-surface-2, oklch(0.22 0.025 275)); }
/* dense — иначе авто-раскладка кладёт плитки строго по порядку и НЕ возвращается
заполнять дыру, которую оставила широкая: при переносе появляются пустые клетки.
Расплата честная: визуальный порядок может разойтись с порядком в разметке,
поэтому приём годится для галереи и не годится там, где порядок несёт смысл. */
.iso-grid { display: grid; gap: var(--ds-3, 12px);
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-auto-rows: 130px; grid-auto-flow: row dense; }
.iso-tile { position: relative; overflow: hidden; margin: 0;
border-radius: var(--ds-radius, 14px);
background: var(--ds-surface, oklch(0.18 0.02 275)); }
/* отфильтрованные не удаляются из DOM, а выпадают из раскладки: Flip должен
видеть их в обоих снимках, иначе анимировать нечего */
.iso-tile.is-off { display: none; }
.iso-tile[data-wide="1"] { grid-column: span 2; }
.iso-tile[data-tall="1"] { grid-row: span 2; }
.iso-tile img { display: block; width: 100%; height: 100%; object-fit: cover;
transition: transform var(--ds-slow, 0.6s) var(--ds-ease-out, cubic-bezier(0.16, 1, 0.3, 1)); }
.iso-tile:hover img { transform: scale(1.06); }
.iso-cap { position: absolute; inset: auto 0 0 0; padding: 26px 10px 8px;
color: oklch(0.98 0 0);
background: linear-gradient(to top, oklch(0 0 0 / 0.74), oklch(0 0 0 / 0));
font-size: var(--ds-text-xs, 0.75rem); font-weight: 650; }
.iso-cap i { font-style: normal; font-weight: 400; opacity: 0.75;
font-family: var(--ds-font-mono, ui-monospace, monospace); }
.iso-empty { padding: var(--ds-7, 54px) 0; text-align: center;
color: var(--ds-muted, oklch(0.72 0.02 265)); font-size: var(--ds-text-sm, 0.9rem); }
@media (prefers-reduced-motion: reduce) { .iso-tile img, .iso-chip { transition: none; } }
`}</style>
<div className="iso-bar">
{cats.map((c) => (
<button
key={c}
className="iso-chip"
data-on={c === cat ? "1" : "0"}
aria-pressed={c === cat}
onClick={() => c !== cat && change(() => setCat(c))}
>
{c}
</button>
))}
<div className="iso-sort">
<span>сортировка:</span>
{(["по умолчанию", "по названию"] as Sort[]).map((s) => (
<button
key={s}
data-on={s === sort ? "1" : "0"}
aria-pressed={s === sort}
onClick={() => s !== sort && change(() => setSort(s))}
>
{s}
</button>
))}
</div>
</div>
<div className="iso-grid">
{ordered.map((it) => (
<figure
key={it.src}
className={visible(it) ? "iso-tile" : "iso-tile is-off"}
data-wide={it.wide ? "1" : "0"}
data-tall={it.tall ? "1" : "0"}
>
<img src={it.src} alt={it.title} loading="lazy" decoding="async" />
<figcaption className="iso-cap">
{it.title} <i>· {it.cat}</i>
</figcaption>
</figure>
))}
</div>
{shownCount === 0 && <p className="iso-empty">В этой категории пока пусто</p>}
</div>
);
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0