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

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