Bento Grid with Flip Filtering

A bento-style grid of nine mixed-span tiles is filtered by a pill bar; GSAP Flip records the layout, toggles visibility, then glides every surviving tile into its new slot while leaving tiles shrink away.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1527 words of exact values — rewritten live as you tune the Playground.

prompt.md · Bento Grid with Flip Filtering
### EFFECT: Bento Grid with Flip Filtering
**What it does (visual):** A dark page shows a heading ("Selected work, rearranged"), a pill-shaped filter bar (All / Brand / Product / Motion / Web — "All" is active with a solid light pill behind it) and a bento grid: nine rounded tiles on a 4-column grid with mixed spans (one 2×2, three 2×1, one 1×2, four 1×1) that tile perfectly into four rows. Each tile is a soft procedural image — a coloured radial glow over a near-black gradient, a thin circular ring, fine film grain — with a small index number, a light serif project name and a mono category tag in the bottom-left corner. On load the heading drifts up, then the tiles fade in one after another (50ms apart) rising 24px and scaling from 96%. When the user clicks "Motion", the light pill behind "All" slides across the bar to sit behind "Motion" (0.5s), and at the same time the five non-Motion tiles shrink to 0.8× and fade out (0.45s) while the two Motion tiles glide — not jump — from their old slots to their new slots at the top of the grid over 0.8s with a smooth `power3.inOut` ease and a 0.03s stagger, the grid's height shrinking in sync. Clicking "All" again makes the missing tiles reappear in their slots, scaling up from 0.8× as the others slide back. A mono counter (number in the #d4ff3a accent) under the grid reads "2 projects" / "9 projects". Hovering a tile zooms its artwork to 105%.

**Tech stack:**
- GSAP 3.13+ with the Flip plugin (free since 3.13).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/Flip.min.js"></script>`
- npm: `import gsap from "gsap"; import { Flip } from "gsap/Flip"; gsap.registerPlugin(Flip);`
- Pure CSS grid for layout; the tile artwork is CSS gradients + an inline SVG `feTurbulence` grain so no image assets are required.

**HTML structure:**
```html
<section class="bento">
  <header class="bento__head">
    <p class="bento__eyebrow">Layout · GSAP Flip</p>
    <h1 class="bento__title">Selected work, <em>rearranged</em></h1>
    <nav class="bento__filters" aria-label="Filter projects">
      <button class="bento__btn is-active" data-filter="all" aria-pressed="true"><span class="bento__pill" aria-hidden="true"></span><span class="bento__btn-label">All</span></button>
      <button class="bento__btn" data-filter="brand" aria-pressed="false"><span class="bento__btn-label">Brand</span></button>
      <button class="bento__btn" data-filter="product" aria-pressed="false"><span class="bento__btn-label">Product</span></button>
      <button class="bento__btn" data-filter="motion" aria-pressed="false"><span class="bento__btn-label">Motion</span></button>
      <button class="bento__btn" data-filter="web" aria-pressed="false"><span class="bento__btn-label">Web</span></button>
    </nav>
  </header>
  <div class="bento__grid">
    <article class="bento__tile bento__tile--2x2" data-cat="brand" style="--c1:#1f7a6d;--c2:#0b2a2a;--x:28%;--y:22%;--rx:72%;--ry:70%;--rs:46%">
      <div class="bento__art" aria-hidden="true"></div>
      <div class="bento__meta"><span class="bento__num">01</span><h2 class="bento__name">Halcyon</h2><p class="bento__tag">Brand · Identity system</p></div>
    </article>
    <!-- 8 more tiles: spans 1x1 (web), 1x2 (product), 2x1 (motion), 1x1 (brand), 1x1 (product), 1x1 (motion), 2x1 (web), 2x1 (brand) = 16 grid cells -->
  </div>
  <p class="bento__count" aria-live="polite"><span class="bento__count-num">9</span> projects</p>
</section>
```
Each tile carries its own colours/positions as inline CSS variables: `--c1` glow colour, `--c2` base colour, `--x/--y` glow centre, `--rx/--ry/--rs` ring centre and diameter.

**CSS requirements:**
- `.bento`: `min-height: 100vh; max-width: 1400px; margin-inline: auto; padding: clamp(2rem, 6vw, 5rem) clamp(1rem, 5vw, 4rem);`
- `.bento__head`: `display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 1.5rem 3rem;` — the eyebrow takes `flex-basis: 100%`.
- `.bento__title`: display serif, `font-weight: 300; font-size: clamp(2.25rem, 5.5vw, 4.75rem); letter-spacing: -0.04em; line-height: .95;` — the `<em>` is italic in `var(--muted)`.
- `.bento__filters`: `display: flex; gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 999px; overflow-x: auto;` (scrolls horizontally on very narrow screens).
- `.bento__btn`: `position: relative; isolation: isolate; padding: .55em 1.1em; border-radius: 999px; font-size: .8rem; font-weight: 500; color: var(--muted); transition: color .35s;` — `.is-active { color: var(--bg) }`. Label span `position: relative; z-index: 1`.
- `.bento__pill`: `position: absolute; inset: 0; border-radius: 999px; background: var(--text); z-index: 0;` — it lives *inside* the active button and is re-parented by JS.
- `.bento__grid`: `display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: clamp(150px, 15vw, 210px); grid-auto-flow: dense; gap: clamp(8px, 1vw, 14px); position: relative;`
- `.bento__tile`: `position: relative; overflow: hidden; border-radius: 14px; background: var(--surface); border: 1px solid var(--line); will-change: transform;` Spans: `--2x2 { grid-column: span 2; grid-row: span 2 }`, `--2x1 { grid-column: span 2 }`, `--1x2 { grid-row: span 2 }`. `.is-hidden { display: none }`.
- `.bento__art`: `position: absolute; inset: 0; transition: transform .9s cubic-bezier(.2,.7,.1,1);` background = `radial-gradient(60% 55% at var(--x) var(--y), var(--c1) 0%, transparent 70%)`, a second fainter radial at the mirrored position (`--c1` at 35%), and `linear-gradient(165deg, color-mix(in srgb, var(--c2) 80%, var(--c1) 20%), var(--c2) 55%, #080808 100%)`. `::before` = ring: `left: var(--rx); top: var(--ry); width: var(--rs); aspect-ratio: 1; translate(-50%,-50%); border-radius: 50%; border: 1px solid rgba(255,255,255,.22); box-shadow: inset 0 0 80px rgba(255,255,255,.06)`. `::after` = grain: an inline SVG data-URI with `<feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="2">`, `opacity: .5; mix-blend-mode: overlay`. `.bento__tile:hover .bento__art { transform: scale(1.05) }`.
- `.bento__meta`: `position: absolute; inset: auto 0 0 0; padding: clamp(.8rem, 1.4vw, 1.25rem); color: #fff; background: linear-gradient(to top, rgba(0,0,0,.55), transparent);`
- `.bento__num`: mono 10px, `letter-spacing: .12em; opacity: .55`. `.bento__name`: display serif 300, `font-size: clamp(1.25rem, 2.2vw, 2rem)` (the 2×2 tile uses `clamp(1.75rem, 3.6vw, 3.25rem)`), `letter-spacing: -0.03em; line-height: 1`. `.bento__tag`: mono 10.5px uppercase, `letter-spacing: .1em; opacity: .75`.
- `.bento__count`: mono 11px uppercase muted; the number is `var(--accent)`.
- Accent colour (counter number, focus rings): `--accent: #d4ff3a`.

**Animation spec (exact values):**
1. Intro (skipped under reduced motion):
   - `gsap.from('.bento__title', { y: 30, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.05 })`
   - `gsap.from('.bento__filters', { y: 16, opacity: 0, duration: 0.8, ease: 'power3.out', delay: 0.2 })`
   - `gsap.from(tiles, { opacity: 0, y: 24, scale: 0.96, duration: 0.9, ease: 'power3.out', stagger: 0.05, delay: 0.15, clearProps: 'transform' })`
2. Pill indicator on click (`movePill(btn)`): toggle `.is-active` / `aria-pressed` on the buttons, then
   ```js
   const state = Flip.getState(pill);
   btn.prepend(pill);                       // re-parent into the clicked button
   Flip.from(state, { duration: 0.5, ease: 'power3.inOut' });
   ```
3. Filtering (`filter(cat)`), with `match = (t) => cat === 'all' || t.dataset.cat === cat`:
   ```js
   const state = Flip.getState(tiles);          // ALL tiles, hidden ones included
   const oldH = grid.offsetHeight;
   tiles.forEach((t) => t.classList.toggle('is-hidden', !match(t)));   // real layout happens here
   const newH = grid.offsetHeight;
   gsap.fromTo(grid, { height: oldH }, { height: newH, duration: 0.8, ease: 'power3.inOut', clearProps: 'height' });
   Flip.from(state, {
     duration: 0.8, ease: 'power3.inOut', stagger: 0.03, absolute: true, scale: true,
     onEnter: (els) => gsap.fromTo(els, { opacity: 0, scale: 0.8 }, { opacity: 1, scale: 1, duration: 0.6, ease: 'power3.out', delay: 0.15 }),
     onLeave: (els) => gsap.to(els, { opacity: 0, scale: 0.8, duration: 0.45, ease: 'power3.in' }),
     onComplete: () => { busy = false; },
   });
   ```
   - Surviving tiles: position/size from old slot → new slot, 0.8s, `power3.inOut`, stagger 0.03 (in DOM order).
   - Leaving tiles: `opacity 1 → 0`, `scale 1 → 0.8`, 0.45s, `power3.in` (Flip temporarily un-hides them for this tween, then re-applies `display: none`).
   - Entering tiles: `opacity 0 → 1`, `scale 0.8 → 1`, 0.6s, `power3.out`, delayed 0.15s so the reflow leads.
   - Container height: `oldH → newH`, 0.8s, same ease, inline height cleared on complete.
4. Update the counter text to `visible.length` immediately on click. Ignore clicks while `busy` is true or on the already-active button.

**Interaction:** Click a filter button → pill slides (0.5s) + grid reflows (0.8s + 0.03s × tiles). Hover a tile → artwork scales to 1.05 over 0.9s with `cubic-bezier(.2,.7,.1,1)`. Buttons expose `aria-pressed`; the counter is `aria-live="polite"`.

**Responsive behavior:** Under 768px (`@media (max-width: 767px)`) the grid becomes `grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 38vw;` — the 2×2 and 2×1 tiles span the full width, the 1×2 tile stays a tall single column; the header switches to `align-items: start` so the filter bar wraps under the title. All type uses `clamp()` so nothing overflows at 375px. The Flip logic is unchanged on mobile and touch (no `gsap.matchMedia()` branch is needed because the animation reads the real layout).

**Accessibility:** With `prefers-reduced-motion: reduce`: no intro tweens, the pill is simply re-parented, and filtering toggles `.is-hidden` synchronously with no Flip. CSS transitions on `.bento__art` and `.bento__btn` are disabled in the same media query. Filter buttons are real `<button>`s with `aria-pressed` and a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 2px`); tiles are `<article>`s with `<h2>` names so the grid reads as a list of titled projects.

**Performance rules:** Flip animates `transform` (and width/height only when `scale: false`; here `scale: true` keeps it transform-only). `absolute: true` removes the tiles from flow for the duration so no layout thrash occurs mid-tween — the one layout read happens in `Flip.getState()` plus two `offsetHeight` reads. `will-change: transform` is on `.bento__tile`. Everything runs inside `gsap.context()` and `ctx.revert()` on unmount.

**Customization knobs:**
- `DURATION` (0.8) and `STAGGER` (0.03) — reflow speed and rhythm.
- `EASE` (`power3.inOut`) — try `expo.inOut` for a snappier glide.
- Enter/leave scale (0.8) — 0.9 for subtler, 0.6 for more dramatic.
- Pill duration (0.5).
- Accent colour `--accent` (#d4ff3a) — counter number and focus rings.
- `grid-auto-rows` and the span modifier classes control the bento proportions; per-tile `--c1/--c2/--x/--y/--rx/--ry/--rs` control the artwork.

**Acceptance criteria:**
- [ ] Nine tiles fill a 4×4-cell grid with no holes before any interaction; "All" is active with the pill behind it.
- [ ] Clicking a filter: the pill slides (does not jump), surviving tiles glide to their new slots over ~0.8s, leaving tiles shrink/fade, the grid height animates with them.
- [ ] Clicking "All" brings the hidden tiles back scaling up from 0.8×.
- [ ] Rapid clicks during a transition are ignored; the counter always matches the visible tile count.
- [ ] With reduced motion enabled, filtering is instant and nothing animates.
- [ ] Works at 375px (2-column grid) with no horizontal page scroll; no console errors.

Source

Full source code

<section class="bento">
  <header class="bento__head">
    <p class="bento__eyebrow">Layout · GSAP Flip</p>
    <h1 class="bento__title">Selected work, <em>rearranged</em></h1>
    <nav class="bento__filters" aria-label="Filter projects">
      <button class="bento__btn is-active" type="button" data-filter="all" aria-pressed="true"><span class="bento__pill" aria-hidden="true"></span><span class="bento__btn-label">All</span></button>
      <button class="bento__btn" type="button" data-filter="brand" aria-pressed="false"><span class="bento__btn-label">Brand</span></button>
      <button class="bento__btn" type="button" data-filter="product" aria-pressed="false"><span class="bento__btn-label">Product</span></button>
      <button class="bento__btn" type="button" data-filter="motion" aria-pressed="false"><span class="bento__btn-label">Motion</span></button>
      <button class="bento__btn" type="button" data-filter="web" aria-pressed="false"><span class="bento__btn-label">Web</span></button>
    </nav>
  </header>

  <div class="bento__grid">
    <article class="bento__tile bento__tile--2x2" data-cat="brand" style="--c1:#1f7a6d;--c2:#0b2a2a;--x:28%;--y:22%;--rx:72%;--ry:70%;--rs:46%">
      <div class="bento__art" aria-hidden="true"></div>
      <div class="bento__meta"><span class="bento__num">01</span><h2 class="bento__name">Halcyon</h2><p class="bento__tag">Brand · Identity system</p></div>
    </article>
    <article class="bento__tile" data-cat="web" style="--c1:#5b6cff;--c2:#121433;--x:70%;--y:30%;--rx:20%;--ry:78%;--rs:70%">
      <div class="bento__art" aria-hidden="true"></div>
      <div class="bento__meta"><span class="bento__num">02</span><h2 class="bento__name">Northline</h2><p class="bento__tag">Web · Marketing site</p></div>
    </article>
    <article class="bento__tile bento__tile--1x2" data-cat="product" style="--c1:#c0584a;--c2:#2a1410;--x:50%;--y:18%;--rx:50%;--ry:72%;--rs:80%">
      <div class="bento__art" aria-hidden="true"></div>
      <div class="bento__meta"><span class="bento__num">03</span><h2 class="bento__name">Ferrule</h2><p class="bento__tag">Product · Hardware launch</p></div>
    </article>
    <article class="bento__tile bento__tile--2x1" data-cat="motion" style="--c1:#8a7cff;--c2:#15112b;--x:18%;--y:50%;--rx:84%;--ry:40%;--rs:90%">
      <div class="bento__art" aria-hidden="true"></div>
      <div class="bento__meta"><span class="bento__num">04</span><h2 class="bento__name">Still Water</h2><p class="bento__tag">Motion · Title sequence</p></div>
    </article>
    <article class="bento__tile" data-cat="brand" style="--c1:#d9a441;--c2:#2b1d08;--x:60%;--y:70%;--rx:30%;--ry:28%;--rs:60%">
      <div class="bento__art" aria-hidden="true"></div>
      <div class="bento__meta"><span class="bento__num">05</span><h2 class="bento__name">Oro Studio</h2><p class="bento__tag">Brand · Naming &amp; mark</p></div>
    </article>
    <article class="bento__tile" data-cat="product" style="--c1:#3fb7a7;--c2:#0e2524;--x:30%;--y:75%;--rx:76%;--ry:26%;--rs:66%">
      <div class="bento__art" aria-hidden="true"></div>
      <div class="bento__meta"><span class="bento__num">06</span><h2 class="bento__name">Pulse Kit</h2><p class="bento__tag">Product · Design system</p></div>
    </article>
    <article class="bento__tile" data-cat="motion" style="--c1:#9aa3b2;--c2:#141618;--x:50%;--y:40%;--rx:50%;--ry:50%;--rs:96%">
      <div class="bento__art" aria-hidden="true"></div>
      <div class="bento__meta"><span class="bento__num">07</span><h2 class="bento__name">Vantablack II</h2><p class="bento__tag">Motion · Exhibition film</p></div>
    </article>
    <article class="bento__tile bento__tile--2x1" data-cat="web" style="--c1:#2f7fd6;--c2:#0a1a2e;--x:80%;--y:60%;--rx:16%;--ry:50%;--rs:84%">
      <div class="bento__art" aria-hidden="true"></div>
      <div class="bento__meta"><span class="bento__num">08</span><h2 class="bento__name">Meridian</h2><p class="bento__tag">Web · Editorial platform</p></div>
    </article>
    <article class="bento__tile bento__tile--2x1" data-cat="brand" style="--c1:#e0633b;--c2:#2a1008;--x:36%;--y:60%;--rx:80%;--ry:30%;--rs:64%">
      <div class="bento__art" aria-hidden="true"></div>
      <div class="bento__meta"><span class="bento__num">09</span><h2 class="bento__name">Cinder</h2><p class="bento__tag">Brand · Packaging</p></div>
    </article>
  </div>
  <p class="bento__count" aria-live="polite"><span class="bento__count-num">9</span> projects</p>
</section>