Filterable Grid

Choosing a filter makes hidden items shrink away and the rest glide into their new places instead of jumping.

  • GSAP
  • Layout
  • Click
  • Added
Text
Card
Button
Text
Button
Card
Text
Card

AI prompt

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

Demo.tsx
'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>
  )
}
demo.module.css
.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); }

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.