Filterable Grid
Choosing a filter makes hidden items shrink away and the rest glide into their new places instead of jumping.
Create a filterable grid that animates layout changes with the GSAP Flip plugin. Register Flip. Render filter buttons and a CSS grid of tiles, each tagged with a category. Before changing the filter, capture const state = Flip.getState(tiles). Update the filter so non-matching tiles get display: none (or are removed), then on the next layout call Flip.from(state, { duration: 0.5, ease: "power2.inOut", scale: true, absolute: true, onEnter: els => gsap.fromTo(els, { opacity: 0, scale: 0.6 }, { opacity: 1, scale: 1, duration: 0.4 }), onLeave: els => gsap.to(els, { opacity: 0, scale: 0.6, duration: 0.3 }) }), so remaining tiles glide to their new cells while entering and leaving tiles scale. Mark the active filter with aria-pressed. Under prefers-reduced-motion, change the filter without Flip. The filters are All, Text, Card, and Button.
Source
'use client'
import { useLayoutEffect, useRef, useState } from 'react'
import gsap from 'gsap'
import { Flip } from 'gsap/Flip'
import s from './demo.module.css'
gsap.registerPlugin(Flip)
const TILES = ['a', 'b', 'c', 'a', 'c', 'b', 'a', 'b'] as const
export default function FilterGrid({ labels }: { labels: Record<string, string> }) {
const grid = useRef<HTMLDivElement>(null)
const flipState = useRef<Flip.FlipState | null>(null)
const [filter, setFilter] = useState<'all' | 'a' | 'b' | 'c'>('all')
const choose = (f: typeof filter) => {
if (f === filter) return
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches && grid.current)
flipState.current = Flip.getState(grid.current.children)
setFilter(f)
}
useLayoutEffect(() => {
const state = flipState.current
if (!state) return
flipState.current = null
const tween = Flip.from(state, {
duration: 0.5,
ease: 'power2.inOut',
scale: true,
absolute: true,
onEnter: (els) => gsap.fromTo(els, { opacity: 0, scale: 0.6 }, { opacity: 1, scale: 1, duration: 0.4 }),
onLeave: (els) => gsap.to(els, { opacity: 0, scale: 0.6, duration: 0.3 }),
})
return () => void tween.progress(1).kill()
}, [filter])
return (
<div className={s.wrap}>
<div className={s.filters} role="group" aria-label={labels.filter}>
{(['all', 'a', 'b', 'c'] as const).map((f) => (
<button key={f} type="button" className={s.chip} aria-pressed={filter === f} onClick={() => choose(f)}>
{labels[f]}
</button>
))}
</div>
<div ref={grid} className={s.grid}>
{TILES.map((t, i) => (
<div key={i} className={s.tile} data-type={t} hidden={filter !== 'all' && filter !== t}>
{labels[t]}
</div>
))}
</div>
</div>
)
}
.wrap {
display: grid;
gap: 12px;
width: min(300px, 100%);
}
.filters { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
height: 28px;
padding: 0 12px;
border: 1px solid var(--stage-rule);
border-radius: 999px;
background: transparent;
color: var(--stage-muted);
font: 500 var(--text-xs) / 1 var(--font-body);
cursor: pointer;
transition: background-color var(--dur-short), color var(--dur-short), border-color var(--dur-short);
}
.chip[aria-pressed='true'] { background: var(--stage-accent); border-color: var(--stage-accent); color: var(--stage-accent-ink); }
.chip:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
min-height: 140px;
align-content: start;
}
.tile {
display: grid;
place-items: end start;
aspect-ratio: 1;
padding: 6px;
border-radius: 10px;
background: var(--stage-surface);
color: var(--stage-muted);
font: 500 0.625rem / 1 var(--font-mono);
text-transform: uppercase;
}
.tile[hidden] { display: none; }
.tile[data-type='b'] { background: var(--stage-ink); color: var(--stage-bg); }
.tile[data-type='c'] { background: var(--stage-accent); color: var(--stage-accent-ink); }