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.
- Layout
- gsap
- flip
- intermediate
- light perf
- mobile: full
- reduced-motion ✓
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.
### 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. Add this effect to my existing site/section: **Bento Grid with Flip Filtering**. Keep my existing grid of cards and my filter buttons; wrap them in a `.bento` container with the cards inside a `.bento__grid` (`display: grid; grid-auto-flow: dense`) and give each card a `data-cat` attribute. Load GSAP 3.13+ and Flip. On filter click: `const state = Flip.getState(allCards)`, toggle a `display: none` class on the non-matching cards, tween the grid's `height` from its old to new value (0.8s, `power3.inOut`), then `Flip.from(state, { duration: 0.8, ease: 'power3.inOut', stagger: 0.03, absolute: true, scale: true, onEnter: fade+scale in from 0.8 (0.6s power3.out, delay 0.15), onLeave: fade+scale out to 0.8 (0.45s power3.in) })`. Move the active indicator pill with its own `Flip.getState` → re-parent → `Flip.from(…, { duration: 0.5, ease: 'power3.inOut' })`. Use `#d4ff3a` as the accent colour for the counter / focus rings. Toggle `aria-pressed` on the buttons, respect `prefers-reduced-motion` by toggling instantly, wrap everything in `gsap.context()` and revert on unmount. Do not change my card markup, colours or fonts. ### EFFECT (React / Next.js): Bento Grid with Flip Filtering
Build **Bento Grid with Flip Filtering** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/BentoGridWithFlipFiltering.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { Flip } from "gsap/Flip";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, Flip);
export default function BentoGridWithFlipFiltering() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="bento">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`BentoGridWithFlipFiltering.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### 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.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```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" 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 & 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>
```
```css
.bento { min-height: 100vh; padding: clamp(2rem, 6vw, 5rem) clamp(1rem, 5vw, 4rem) clamp(3rem, 6vw, 5rem); max-width: 1400px; margin-inline: auto; }
.bento__head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 1.5rem 3rem; margin-bottom: clamp(1.5rem, 3vw, 2.5rem); }
.bento__eyebrow { flex-basis: 100%; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.bento__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.25rem, 5.5vw, 4.75rem); letter-spacing: -0.04em; line-height: .95; }
.bento__title em { font-style: italic; color: var(--muted); }
.bento__filters { display: flex; gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 999px; max-width: 100%; overflow-x: auto; scrollbar-width: none; }
.bento__filters::-webkit-scrollbar { display: none; }
.bento__btn { position: relative; isolation: isolate; padding: .55em 1.1em; border-radius: 999px; font-size: .8rem; font-weight: 500; letter-spacing: .01em; color: var(--muted); white-space: nowrap; transition: color .35s; }
.bento__btn:hover { color: var(--text); }
.bento__btn.is-active { color: var(--bg); }
.bento__btn:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 2px; }
.bento__btn-label { position: relative; z-index: 1; }
.bento__pill { position: absolute; inset: 0; border-radius: 999px; background: var(--text); z-index: 0; }
.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; }
.bento__tile--2x2 { grid-column: span 2; grid-row: span 2; }
.bento__tile--2x1 { grid-column: span 2; }
.bento__tile--1x2 { grid-row: span 2; }
.bento__tile.is-hidden { display: none; }
.bento__art {
position: absolute; inset: 0; transform-origin: center; transition: transform .9s cubic-bezier(.2,.7,.1,1);
background:
radial-gradient(60% 55% at var(--x) var(--y), var(--c1) 0%, transparent 70%),
radial-gradient(90% 70% at calc(100% - var(--x)) calc(100% - var(--y)), color-mix(in srgb, var(--c1) 35%, transparent) 0%, transparent 70%),
linear-gradient(165deg, color-mix(in srgb, var(--c2) 80%, var(--c1) 20%) 0%, var(--c2) 55%, #080808 100%);
}
.bento__art::before { content: ''; position: absolute; left: var(--rx); top: var(--ry); width: var(--rs); aspect-ratio: 1; transform: 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); }
.bento__art::after { content: ''; position: absolute; inset: 0; opacity: .5; mix-blend-mode: overlay; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .6 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
.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 { display: block; font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em; opacity: .55; margin-bottom: .4rem; }
.bento__name { font-family: var(--font-display); font-weight: 300; font-size: clamp(1.25rem, 2.2vw, 2rem); letter-spacing: -0.03em; line-height: 1; }
.bento__tile--2x2 .bento__name { font-size: clamp(1.75rem, 3.6vw, 3.25rem); }
.bento__tag { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .1em; opacity: .75; margin-top: .45rem; }
.bento__count { margin-top: 1.25rem; font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
.bento__count-num { color: var(--knob-accent, var(--accent)); }
@media (max-width: 767px) {
.bento__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 38vw; }
.bento__head { align-items: start; }
}
@media (prefers-reduced-motion: reduce) {
.bento__art, .bento__btn { transition: none; }
}
```
```js
// Bento Grid with Flip Filtering — snapshot, reflow, then glide every tile to its new slot.
gsap.registerPlugin(Flip);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { duration: 0.8, stagger: 0.03, ease: 'power3.inOut', leaveScale: 0.8, pillDuration: 0.5, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.bento');
const grid = document.querySelector('.bento__grid');
const tiles = gsap.utils.toArray('.bento__tile');
const buttons = gsap.utils.toArray('.bento__btn');
const pill = document.querySelector('.bento__pill');
const countNum = document.querySelector('.bento__count-num');
let ctx;
function build() {
ctx?.revert();
// Rebuild from a clean slate: the filter state lives in the classes, not in inline styles.
gsap.set(tiles, { clearProps: 'opacity,transform' });
gsap.set(grid, { clearProps: 'height' });
section.style.setProperty('--knob-accent', K('accent'));
const DURATION = K('duration'); // tile travel time
const STAGGER = K('stagger'); // between tiles
const EASE = K('ease');
const LEAVE_SCALE = K('leaveScale'); // enter / leave scale
const PILL = K('pillDuration');
let busy = false;
let pillTl, flipTl, heightTween; // handler-created animations (not auto-captured by the context)
ctx = gsap.context(() => {
const ac = new AbortController();
if (!reduced) {
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' });
}
function movePill(btn) {
buttons.forEach((b) => { b.classList.toggle('is-active', b === btn); b.setAttribute('aria-pressed', String(b === btn)); });
if (reduced) { btn.prepend(pill); return; }
const state = Flip.getState(pill);
btn.prepend(pill);
pillTl = Flip.from(state, { duration: PILL, ease: 'power3.inOut' });
}
function filter(cat) {
const match = (t) => cat === 'all' || t.dataset.cat === cat;
const visible = tiles.filter(match);
countNum.textContent = visible.length;
if (reduced) {
tiles.forEach((t) => t.classList.toggle('is-hidden', !match(t)));
return;
}
busy = true;
// 1. Snapshot every tile (including hidden ones so returning tiles count as "entering").
const state = Flip.getState(tiles);
const oldH = grid.offsetHeight;
// 2. Let the browser do the real layout.
tiles.forEach((t) => t.classList.toggle('is-hidden', !match(t)));
const newH = grid.offsetHeight;
// 3. Hold the container height while children go absolute, and tween it to the new height.
heightTween = gsap.fromTo(grid, { height: oldH }, { height: newH, duration: DURATION, ease: EASE, clearProps: 'height' });
// 4. Animate from the snapshot to the new truth.
flipTl = Flip.from(state, {
duration: DURATION, ease: EASE, stagger: STAGGER, absolute: true, scale: true,
onEnter: (els) => gsap.fromTo(els, { opacity: 0, scale: LEAVE_SCALE }, { opacity: 1, scale: 1, duration: 0.6, ease: 'power3.out', delay: 0.15 }),
onLeave: (els) => gsap.to(els, { opacity: 0, scale: LEAVE_SCALE, duration: 0.45, ease: 'power3.in' }),
onComplete: () => { busy = false; },
});
}
buttons.forEach((btn) => {
btn.addEventListener('click', () => {
if (busy || btn.classList.contains('is-active')) return;
movePill(btn);
filter(btn.dataset.filter);
}, { signal: ac.signal });
});
return () => {
ac.abort(); // listeners go with the context so rebuilds never stack handlers
// Finish any in-flight reflow instantly so Flip restores layout/visibility, then drop the handler tweens.
[pillTl, flipTl, heightTween].forEach((a) => a?.isActive() && a.progress(1));
gsap.killTweensOf([grid, pill, ...tiles]);
};
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (single HTML file): Bento Grid with Flip Filtering
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Bento Grid with Flip Filtering** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bento Grid with Flip Filtering</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<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>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### 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.
---
**Reference implementation (use these exact values and class names):**
```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" 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 & 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>
```
```css
.bento { min-height: 100vh; padding: clamp(2rem, 6vw, 5rem) clamp(1rem, 5vw, 4rem) clamp(3rem, 6vw, 5rem); max-width: 1400px; margin-inline: auto; }
.bento__head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 1.5rem 3rem; margin-bottom: clamp(1.5rem, 3vw, 2.5rem); }
.bento__eyebrow { flex-basis: 100%; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.bento__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.25rem, 5.5vw, 4.75rem); letter-spacing: -0.04em; line-height: .95; }
.bento__title em { font-style: italic; color: var(--muted); }
.bento__filters { display: flex; gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 999px; max-width: 100%; overflow-x: auto; scrollbar-width: none; }
.bento__filters::-webkit-scrollbar { display: none; }
.bento__btn { position: relative; isolation: isolate; padding: .55em 1.1em; border-radius: 999px; font-size: .8rem; font-weight: 500; letter-spacing: .01em; color: var(--muted); white-space: nowrap; transition: color .35s; }
.bento__btn:hover { color: var(--text); }
.bento__btn.is-active { color: var(--bg); }
.bento__btn:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 2px; }
.bento__btn-label { position: relative; z-index: 1; }
.bento__pill { position: absolute; inset: 0; border-radius: 999px; background: var(--text); z-index: 0; }
.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; }
.bento__tile--2x2 { grid-column: span 2; grid-row: span 2; }
.bento__tile--2x1 { grid-column: span 2; }
.bento__tile--1x2 { grid-row: span 2; }
.bento__tile.is-hidden { display: none; }
.bento__art {
position: absolute; inset: 0; transform-origin: center; transition: transform .9s cubic-bezier(.2,.7,.1,1);
background:
radial-gradient(60% 55% at var(--x) var(--y), var(--c1) 0%, transparent 70%),
radial-gradient(90% 70% at calc(100% - var(--x)) calc(100% - var(--y)), color-mix(in srgb, var(--c1) 35%, transparent) 0%, transparent 70%),
linear-gradient(165deg, color-mix(in srgb, var(--c2) 80%, var(--c1) 20%) 0%, var(--c2) 55%, #080808 100%);
}
.bento__art::before { content: ''; position: absolute; left: var(--rx); top: var(--ry); width: var(--rs); aspect-ratio: 1; transform: 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); }
.bento__art::after { content: ''; position: absolute; inset: 0; opacity: .5; mix-blend-mode: overlay; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .6 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
.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 { display: block; font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em; opacity: .55; margin-bottom: .4rem; }
.bento__name { font-family: var(--font-display); font-weight: 300; font-size: clamp(1.25rem, 2.2vw, 2rem); letter-spacing: -0.03em; line-height: 1; }
.bento__tile--2x2 .bento__name { font-size: clamp(1.75rem, 3.6vw, 3.25rem); }
.bento__tag { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .1em; opacity: .75; margin-top: .45rem; }
.bento__count { margin-top: 1.25rem; font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
.bento__count-num { color: var(--knob-accent, var(--accent)); }
@media (max-width: 767px) {
.bento__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 38vw; }
.bento__head { align-items: start; }
}
@media (prefers-reduced-motion: reduce) {
.bento__art, .bento__btn { transition: none; }
}
```
```js
// Bento Grid with Flip Filtering — snapshot, reflow, then glide every tile to its new slot.
gsap.registerPlugin(Flip);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { duration: 0.8, stagger: 0.03, ease: 'power3.inOut', leaveScale: 0.8, pillDuration: 0.5, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.bento');
const grid = document.querySelector('.bento__grid');
const tiles = gsap.utils.toArray('.bento__tile');
const buttons = gsap.utils.toArray('.bento__btn');
const pill = document.querySelector('.bento__pill');
const countNum = document.querySelector('.bento__count-num');
let ctx;
function build() {
ctx?.revert();
// Rebuild from a clean slate: the filter state lives in the classes, not in inline styles.
gsap.set(tiles, { clearProps: 'opacity,transform' });
gsap.set(grid, { clearProps: 'height' });
section.style.setProperty('--knob-accent', K('accent'));
const DURATION = K('duration'); // tile travel time
const STAGGER = K('stagger'); // between tiles
const EASE = K('ease');
const LEAVE_SCALE = K('leaveScale'); // enter / leave scale
const PILL = K('pillDuration');
let busy = false;
let pillTl, flipTl, heightTween; // handler-created animations (not auto-captured by the context)
ctx = gsap.context(() => {
const ac = new AbortController();
if (!reduced) {
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' });
}
function movePill(btn) {
buttons.forEach((b) => { b.classList.toggle('is-active', b === btn); b.setAttribute('aria-pressed', String(b === btn)); });
if (reduced) { btn.prepend(pill); return; }
const state = Flip.getState(pill);
btn.prepend(pill);
pillTl = Flip.from(state, { duration: PILL, ease: 'power3.inOut' });
}
function filter(cat) {
const match = (t) => cat === 'all' || t.dataset.cat === cat;
const visible = tiles.filter(match);
countNum.textContent = visible.length;
if (reduced) {
tiles.forEach((t) => t.classList.toggle('is-hidden', !match(t)));
return;
}
busy = true;
// 1. Snapshot every tile (including hidden ones so returning tiles count as "entering").
const state = Flip.getState(tiles);
const oldH = grid.offsetHeight;
// 2. Let the browser do the real layout.
tiles.forEach((t) => t.classList.toggle('is-hidden', !match(t)));
const newH = grid.offsetHeight;
// 3. Hold the container height while children go absolute, and tween it to the new height.
heightTween = gsap.fromTo(grid, { height: oldH }, { height: newH, duration: DURATION, ease: EASE, clearProps: 'height' });
// 4. Animate from the snapshot to the new truth.
flipTl = Flip.from(state, {
duration: DURATION, ease: EASE, stagger: STAGGER, absolute: true, scale: true,
onEnter: (els) => gsap.fromTo(els, { opacity: 0, scale: LEAVE_SCALE }, { opacity: 1, scale: 1, duration: 0.6, ease: 'power3.out', delay: 0.15 }),
onLeave: (els) => gsap.to(els, { opacity: 0, scale: LEAVE_SCALE, duration: 0.45, ease: 'power3.in' }),
onComplete: () => { busy = false; },
});
}
buttons.forEach((btn) => {
btn.addEventListener('click', () => {
if (busy || btn.classList.contains('is-active')) return;
movePill(btn);
filter(btn.dataset.filter);
}, { signal: ac.signal });
});
return () => {
ac.abort(); // listeners go with the context so rebuilds never stack handlers
// Finish any in-flight reflow instantly so Flip restores layout/visibility, then drop the handler tweens.
[pillTl, flipTl, heightTween].forEach((a) => a?.isActive() && a.progress(1));
gsap.killTweensOf([grid, pill, ...tiles]);
};
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (Framer & Webflow notes): Bento Grid with Flip Filtering
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withBentoGridwithFlipFiltering(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `bento`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<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>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### 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 & 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>
.bento { min-height: 100vh; padding: clamp(2rem, 6vw, 5rem) clamp(1rem, 5vw, 4rem) clamp(3rem, 6vw, 5rem); max-width: 1400px; margin-inline: auto; }
.bento__head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 1.5rem 3rem; margin-bottom: clamp(1.5rem, 3vw, 2.5rem); }
.bento__eyebrow { flex-basis: 100%; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.bento__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.25rem, 5.5vw, 4.75rem); letter-spacing: -0.04em; line-height: .95; }
.bento__title em { font-style: italic; color: var(--muted); }
.bento__filters { display: flex; gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 999px; max-width: 100%; overflow-x: auto; scrollbar-width: none; }
.bento__filters::-webkit-scrollbar { display: none; }
.bento__btn { position: relative; isolation: isolate; padding: .55em 1.1em; border-radius: 999px; font-size: .8rem; font-weight: 500; letter-spacing: .01em; color: var(--muted); white-space: nowrap; transition: color .35s; }
.bento__btn:hover { color: var(--text); }
.bento__btn.is-active { color: var(--bg); }
.bento__btn:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 2px; }
.bento__btn-label { position: relative; z-index: 1; }
.bento__pill { position: absolute; inset: 0; border-radius: 999px; background: var(--text); z-index: 0; }
.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; }
.bento__tile--2x2 { grid-column: span 2; grid-row: span 2; }
.bento__tile--2x1 { grid-column: span 2; }
.bento__tile--1x2 { grid-row: span 2; }
.bento__tile.is-hidden { display: none; }
.bento__art {
position: absolute; inset: 0; transform-origin: center; transition: transform .9s cubic-bezier(.2,.7,.1,1);
background:
radial-gradient(60% 55% at var(--x) var(--y), var(--c1) 0%, transparent 70%),
radial-gradient(90% 70% at calc(100% - var(--x)) calc(100% - var(--y)), color-mix(in srgb, var(--c1) 35%, transparent) 0%, transparent 70%),
linear-gradient(165deg, color-mix(in srgb, var(--c2) 80%, var(--c1) 20%) 0%, var(--c2) 55%, #080808 100%);
}
.bento__art::before { content: ''; position: absolute; left: var(--rx); top: var(--ry); width: var(--rs); aspect-ratio: 1; transform: 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); }
.bento__art::after { content: ''; position: absolute; inset: 0; opacity: .5; mix-blend-mode: overlay; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .6 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
.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 { display: block; font-family: var(--font-mono); font-size: 10px; letter-spacing: .12em; opacity: .55; margin-bottom: .4rem; }
.bento__name { font-family: var(--font-display); font-weight: 300; font-size: clamp(1.25rem, 2.2vw, 2rem); letter-spacing: -0.03em; line-height: 1; }
.bento__tile--2x2 .bento__name { font-size: clamp(1.75rem, 3.6vw, 3.25rem); }
.bento__tag { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .1em; opacity: .75; margin-top: .45rem; }
.bento__count { margin-top: 1.25rem; font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
.bento__count-num { color: var(--knob-accent, var(--accent)); }
@media (max-width: 767px) {
.bento__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 38vw; }
.bento__head { align-items: start; }
}
@media (prefers-reduced-motion: reduce) {
.bento__art, .bento__btn { transition: none; }
}
// Bento Grid with Flip Filtering — snapshot, reflow, then glide every tile to its new slot.
gsap.registerPlugin(Flip);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { duration: 0.8, stagger: 0.03, ease: 'power3.inOut', leaveScale: 0.8, pillDuration: 0.5, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.bento');
const grid = document.querySelector('.bento__grid');
const tiles = gsap.utils.toArray('.bento__tile');
const buttons = gsap.utils.toArray('.bento__btn');
const pill = document.querySelector('.bento__pill');
const countNum = document.querySelector('.bento__count-num');
let ctx;
function build() {
ctx?.revert();
// Rebuild from a clean slate: the filter state lives in the classes, not in inline styles.
gsap.set(tiles, { clearProps: 'opacity,transform' });
gsap.set(grid, { clearProps: 'height' });
section.style.setProperty('--knob-accent', K('accent'));
const DURATION = K('duration'); // tile travel time
const STAGGER = K('stagger'); // between tiles
const EASE = K('ease');
const LEAVE_SCALE = K('leaveScale'); // enter / leave scale
const PILL = K('pillDuration');
let busy = false;
let pillTl, flipTl, heightTween; // handler-created animations (not auto-captured by the context)
ctx = gsap.context(() => {
const ac = new AbortController();
if (!reduced) {
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' });
}
function movePill(btn) {
buttons.forEach((b) => { b.classList.toggle('is-active', b === btn); b.setAttribute('aria-pressed', String(b === btn)); });
if (reduced) { btn.prepend(pill); return; }
const state = Flip.getState(pill);
btn.prepend(pill);
pillTl = Flip.from(state, { duration: PILL, ease: 'power3.inOut' });
}
function filter(cat) {
const match = (t) => cat === 'all' || t.dataset.cat === cat;
const visible = tiles.filter(match);
countNum.textContent = visible.length;
if (reduced) {
tiles.forEach((t) => t.classList.toggle('is-hidden', !match(t)));
return;
}
busy = true;
// 1. Snapshot every tile (including hidden ones so returning tiles count as "entering").
const state = Flip.getState(tiles);
const oldH = grid.offsetHeight;
// 2. Let the browser do the real layout.
tiles.forEach((t) => t.classList.toggle('is-hidden', !match(t)));
const newH = grid.offsetHeight;
// 3. Hold the container height while children go absolute, and tween it to the new height.
heightTween = gsap.fromTo(grid, { height: oldH }, { height: newH, duration: DURATION, ease: EASE, clearProps: 'height' });
// 4. Animate from the snapshot to the new truth.
flipTl = Flip.from(state, {
duration: DURATION, ease: EASE, stagger: STAGGER, absolute: true, scale: true,
onEnter: (els) => gsap.fromTo(els, { opacity: 0, scale: LEAVE_SCALE }, { opacity: 1, scale: 1, duration: 0.6, ease: 'power3.out', delay: 0.15 }),
onLeave: (els) => gsap.to(els, { opacity: 0, scale: LEAVE_SCALE, duration: 0.45, ease: 'power3.in' }),
onComplete: () => { busy = false; },
});
}
buttons.forEach((btn) => {
btn.addEventListener('click', () => {
if (busy || btn.classList.contains('is-active')) return;
movePill(btn);
filter(btn.dataset.filter);
}, { signal: ac.signal });
});
return () => {
ac.abort(); // listeners go with the context so rebuilds never stack handlers
// Finish any in-flight reflow instantly so Flip restores layout/visibility, then drop the handler tweens.
[pillTl, flipTl, heightTween].forEach((a) => a?.isActive() && a.progress(1));
gsap.killTweensOf([grid, pill, ...tiles]);
};
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());