All components

Field Day Sound — Filter Gallery

GSAP animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A two-column masonry work archive for an independent audio studio, paired with oversized discipline filters; clicking a filter splits its heading into per-character spans and tweens each span's font-size with a staggered power2.out ease while gsap fades the item grid out and back in to swap the filtered items. The giant active word multiplies over the photo grid like an ink stamp.

How it's built

Categorygallery
Techgsap
Complexitypage
Performance costmedium
Mobile-safeyes

filter gallery grid click stagger font-size typography editorial gsap

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Filter Gallery with Letter-by-Letter Swelling Headings

Goal

Build a full-screen editorial gallery on a white page: a two-column staggered (masonry-ish) image grid fills the left, and a stack of oversized category filters sits bottom-right. Clicking a filter is the star effect — the clicked category's heading is split into per-character <span>s and its font-size tweens up letter-by-letter with a stagger (a small word swelling into a giant magenta headline), the previously-active heading simultaneously shrinks back down the same way, and the item grid cross-fades out and back in to swap in only the items matching that category.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no GSAP plugins, no smooth-scroll library.

import gsap from "gsap";

The character-splitting is done by hand (not SplitText). Run everything inside a DOMContentLoaded listener.

Layout / HTML

Class names are load-bearing (JS and CSS query them). Static markup is minimal — the gallery items are injected by JS.

<div class="container">
  <div class="filters">
    <div class="filter active" data-filter="all">
      <p>(34)</p>
      <h1>All</h1>
    </div>
    <div class="filter" data-filter="mix">
      <p>(13)</p>
      <h1>Remixes</h1>
    </div>
    <div class="filter" data-filter="design">
      <p>(11)</p>
      <h1>Sound Designing</h1>
    </div>
    <div class="filter" data-filter="music">
      <p>(10)</p>
      <h1>Production</h1>
    </div>
  </div>

  <div class="items">
    <div class="items-col"></div>
    <div class="items-col"></div>
  </div>
</div>
  • Four filters. The first (data-filter="all") starts with class active. Each filter has a small count <p> (the parenthesised number) sitting above a big <h1> label.
  • Filter labels / counts / filter-keys: All (34) → all, Remixes (13) → mix, Sound Designing (11) → design, Production (10) → music. (Neutral music-studio category words — no brand.)
  • .items holds exactly two empty .items-col columns; JS fills them.
Data model (drives the grid)

Define a module-level array items of 34 objects, each { title: string, tag: [oneOf "mix" | "design" | "music"], img: string }. Titles are evocative music/art phrases; keep this exact order and tagging (it determines each filter's count and which cards appear):

| # | title | tag | |---|-------|-----| | 1 | Echoes of Silence | mix | | 2 | Midnight Canvas | design | | 3 | Vibrant Rhythms | music | | 4 | Shadow Dance | mix | | 5 | Colorful Serenity | design | | 6 | Dream Weaver | mix | | 7 | Urban Mirage | design | | 8 | Sonic Bloom | music | | 9 | Celestial Nights | mix | | 10 | Harmony Quest | music | | 11 | Abstract Harmony | design | | 12 | Rhythm and Space | mix | | 13 | Ethereal Echoes | music | | 14 | Whispers of Twilight | mix | | 15 | Mosaic of Dreams | design | | 16 | Glimpse of Eternity | mix | | 17 | Infinite Palette | mix | | 18 | Soul's Resonance | music | | 19 | Spectral Designs | design | | 20 | Temporal Visions | mix | | 21 | Luminous Journey | design | | 22 | Melodic Horizon | music | | 23 | Eclipse of the Heart | mix | | 24 | Canvas of Sound | design | | 25 | Aurora's Whisper | mix | | 26 | Visions in Bloom | design | | 27 | Harmonious Disarray | music | | 28 | Orchestral Dreams | music | | 29 | Symphony of Night | design | | 30 | Echoing Serenade | music | | 31 | Mystical Frequencies | mix | | 32 | Serenity in Chaos | mix | | 33 | Rhythmic Illusions | music | | 34 | The Color of Sound | design |

(This yields mix = 13, design = 11, music = 10, all = 34 — matching the filter counts.)

Each item's markup, generated at runtime:

<div class="item">
  <div class="item-img"><img src="{item.img}" alt=""></div>
  <div class="item-copy"><p>{item.title}</p></div>
</div>

Styling

Palette: page background is default white (#fff, no explicit background), text black (#000), and the active heading accent is magenta #fb5eff. That's the whole palette — a stark white editorial layout with one hot-pink highlight.

Fonts:

  • Headings (.filter h1): a heavy, wide display grotesque — original uses "Druk Trial". Use a very bold condensed/wide display face (e.g. "Druk Trial" with a fallback like Anton, or any ultra-bold sans). It must read as a big blocky headline.
  • Counts and item captions (.filter p, .item-copy p): a clean neutral sans — original uses "PP Neue Montreal", weight 500 (fallback: system grotesque like Helvetica, Arial).

Global: * { margin:0; padding:0; box-sizing:border-box }. body { width:100%; height:100%; font-family:"Druk Trial" }. img { width:100%; height:100%; object-fit:cover }.

Filters (fixed, bottom-right):

  • .filters { position:fixed; top:0; right:0; width:50vw; height:100vh; padding:1em; display:flex; flex-direction:column; justify-content:flex-end; align-items:flex-end } — the four filters stack at the bottom, right-aligned. (Because each label is right-aligned max-content and grows huge, the active headline overflows leftward across the page.)
  • .filter { width:max-content; height:max-content; padding:1.5em 0 0.5em 0; display:flex; align-items:flex-end; cursor:pointer }.
  • .filter.active { padding-top:2.5em } (active row gets a bit more headroom — snaps, not animated).
  • .filter p { position:relative; bottom:10px; padding:0 0.5em; font-family:"PP Neue Montreal"; font-size:20px; font-weight:500 }; .filter.active p { bottom:24px } (the little count floats a touch higher when active).
  • .filter h1 span { position:relative; text-transform:uppercase; font-size:75px; color:#000; line-height:80%; transition:color 0.3s }note the styling targets h1 span, not h1, because JS wraps every character in a span. line-height:80% keeps the giant type tight. The transition:color 0.3s handles the black↔magenta color swap in CSS.
  • .filter.active h1 span { color:#fb5eff } — active heading letters turn magenta.

Items (absolute, left):

  • .items { position:absolute; top:0; left:0; width:60%; height:100%; padding:2em; display:flex }.
  • .items-col { flex:1; height:max-content; padding:2em 1em }.
  • .items-col:nth-child(2) { position:relative; top:10em } — the second column is pushed down 10em, creating the staggered/masonry offset between the two columns.
  • .item { padding:1em 1em 4em 1em } (generous bottom gap between cards).
  • .item-img { width:100%; height:300px } — every card image is a fixed 300px-tall box, object-fit:cover.
  • .item-copy p { font-family:"PP Neue Montreal"; font-size:15px; font-weight:500; margin:0.5em 0 } — small caption under each image.

Responsive (@media (max-width:900px)): .items { width:100% }; .filters { z-index:2 } (filters float over the grid); .filter gets a frosted-glass chip look: background:rgba(255,255,255,0.1); backdrop-filter:blur(20px); border:1px solid rgba(255,255,255,0.25).

GSAP effect (be exact)

There are three moving parts, all plain gsap.to tweens (no timeline, no plugins). The magic is that headings are split into per-character spans so font-size can be staggered across letters.

1. Split every heading into character spans

Helper splitTextIntoSpans(selector): for each element matching the selector, take its innerText, split on "" (every character, including spaces), and replace innerHTML with each char wrapped as <span>${char}</span> (joined with ""). Call it once on load with selector ".filter h1", so all four headings become sequences of single-character spans. All CSS font sizing/coloring targets these spans.

2. Font-size swell/shrink tween (the star)

Helper animateFontSize(target, fontSize):

function animateFontSize(target, fontSize) {
  const spans = target.querySelectorAll("span");
  gsap.to(spans, {
    fontSize: fontSize,   // e.g. "300px" / "250px" / "75px"
    stagger: 0.025,       // 25ms between consecutive letters, left→right
    duration: 0.5,
    ease: "power2.out",
  });
}
  • Animated property: fontSize of each <span>, from its current CSS size (base 75px) to the target string.
  • stagger: 0.025 → each successive letter starts 25ms after the previous, so the word appears to inflate/deflate letter by letter from left to right.
  • duration: 0.5, ease: "power2.out", no delay.

Size constants (note the deliberate quirk — the on-load size and the on-click size differ):

  • defaultFontSize = "75px" (inactive resting size).
  • activeFontSize = "250px" (size a filter grows to when clicked).
  • On initial load, the pre-active "All" heading is grown to "300px" (a hardcoded value, larger than the 250px used for click-activations).
3. Item grid cross-fade + rebuild

Helper animateItems(filter) fades the whole .items container out, swaps its DOM contents, then fades back in:

function animateItems(filter) {
  gsap.to(itemsContainer, {
    opacity: 0,
    duration: 0.25,
    onComplete: () => {
      clearItems();            // empty both .items-col innerHTML
      addItemsToCols(filter);  // rebuild with filtered items
      gsap.to(itemsContainer, { opacity: 1, duration: 0.25 });
    },
  });
}
  • Two back-to-back opacity tweens: 1 → 0 (0.25s), then in the onComplete rebuild the DOM and tween 0 → 1 (0.25s). No easing specified (GSAP default power1.out). Net: a ~0.5s cross-fade that hides the content swap.

addItemsToCols(filter = "all"): filter the items array with filter === "all" || item.tag.includes(filter), then loop the survivors and alternate them between the two columns — maintain a running colIndex, append each item to itemsCols[colIndex % 2], increment colIndex. So item 0 → col 0, item 1 → col 1, item 2 → col 0, … The DOM node for each item is the .item template above.

On load (in order)
splitTextIntoSpans(".filter h1");
animateFontSize(document.querySelector(".filter.active h1"), "300px"); // grow "All" to 300px
addItemsToCols();  // populate both columns with all 34 items
Click handler (on each .filter)

For every .filter, add a click listener:

  1. If the clicked filter already has class active, return (no-op).
  2. Grab the currently active heading (.filter.active h1) and call animateFontSize(previousActiveH1, "75px") — shrink it back to base, letter by letter.
  3. Remove active from all filters, add active to the clicked one. (This flips the CSS color: old heading letters transition #fb5eff→#000 over 0.3s, new ones →#fb5eff.)
  4. Call animateFontSize(clickedH1, "250px") — swell the new heading to 250px, letter by letter.
  5. Read the clicked filter's data-filter and call animateItems(filterValue) to cross-fade the grid to that category.

So a click plays three concurrent tweens: old heading shrinking (0.5s staggered), new heading swelling (0.5s staggered, magenta), and the grid cross-fade (0.25s + 0.25s).

Assets / images

34 moody editorial photographs, mixed subjects (as a music/creative-studio portfolio wall): studio gear shot like still life (analog synth knobs, a reel-to-reel tape machine under tungsten, a microphone in an acoustic booth, mixing-console faders under blue light), low-light portraits (a neon-lit profile, a hooded face cut by a slash of light, black-and-white close-ups, a silhouette against a softbox), workspaces (a pottery bench, a plant-filled desk, a drafting table), architecture (a brutalist facade, terracotta arches, a curved plaster alcove), landscapes (salt flat, dunes, ocean, forest sunbeams, above the clouds) and movement (a dancer leaping at sunset, long-exposure blurs, satin in motion). Any aspect ratio works — each is cropped to a fixed 300px-tall landscape card via object-fit:cover; roughly half-column-wide, so ~4:3–3:2 landscape crops read best. Name them img1.jpg … img34.jpg and map them to the items array in the table order above (img N → item N). Each card shows its title as a small caption beneath the image.

Behavior notes

  • Click-only interaction — no scroll, hover, or load-time motion beyond the initial "All" swell. Re-clicking the active filter does nothing.
  • The active headline grows so large (250–300px) that it deliberately overflows the 50vw filters column and sprawls left across the page, overlapping the gallery — that oversized-type collision is the intended editorial look.
  • No prefers-reduced-motion handling in the original.
  • The whole piece is desktop-first; on ≤900px the grid goes full-width and the filters become frosted-glass chips layered above it (z-index:2).
  • Nothing here uses ScrollTrigger, Lenis, SplitText, CustomEase, canvas, or WebGL — it's pure gsap.to on fontSize (staggered across hand-made character spans) and on container opacity.

Images

This component ships with 34 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/filters-fielddaysound/img1.jpg
https://motionprompts.dev/c/filters-fielddaysound/img10.jpg
https://motionprompts.dev/c/filters-fielddaysound/img11.jpg
https://motionprompts.dev/c/filters-fielddaysound/img12.jpg
https://motionprompts.dev/c/filters-fielddaysound/img13.jpg
https://motionprompts.dev/c/filters-fielddaysound/img14.jpg
… 28 more under https://motionprompts.dev/c/filters-fielddaysound/

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--paper, --ink, --accent, --muted, --line, --display, --mono. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.

Adapting this to React

Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that runs never leaves you two of everything: two heading spans mid-tween off the same click, two full sets of item cards stacked inside the same two columns. The visible symptom is jitter or a doubled grid, and it will not reproduce in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.

*(1) The entry point* — The script waits for DOMContentLoaded. By the time a React component mounts, that event has already fired, so the listener is never called and the effect never runs — no split headings, no items, nothing to debug. Delete the listener and move its body — splitTextIntoSpans(".filter h1"), the initial animateFontSize call on the active heading, addItemsToCols(), the resize listener, and the four .filter click listeners — directly inside a useEffect with an empty dependency array.

*(2) Element lookups* — Every document.querySelector in the code above assumes this component owns the document. Give the component a root ref, render it on the outermost element, and scope every lookup to it. Unscoped selectors are not a style problem here: during the StrictMode remount two copies of the subtree exist for an instant, and an unscoped selector will bind to the one that is on its way out.

*(3) Cleanup* — Wrap the on-load work — the character split, the initial swell of the active heading, the item build, and the wiring of the resize and click listeners — in a gsap.context scoped to the root ref, and revert it in the cleanup:

useEffect(() => {
  const ctx = gsap.context((self) => {
    splitTextIntoSpans(".filter h1");
    animateFontSize(rootRef.current.querySelector(".filter.active h1"), getActiveFontSize());
    addItemsToCols();
    // wire the resize listener and the four .filter click listeners here
  }, rootRef);
  return () => ctx.revert();
}, []);

None of the three real animations this component performs run during that synchronous pass, though. animateFontSize on the outgoing and the newly-active heading, and the pair of gsap.to calls inside animateItems — the fade-out, and, nested inside its own onComplete, the fade-back-in — all fire later, from a .filter's click listener, an arbitrary amount of time after the factory has already returned, possibly after a StrictMode remount has thrown this closure away for a new one. Attribute each to the context explicitly, at the moment it runs, with the immediate-invoke form of self.add:

filterEl.addEventListener("click", () => {
  if (filterEl.classList.contains("active")) return;
  self.add(() => {
    animateFontSize(previousActiveH1, getDefaultFontSize());
    filters.forEach((f) => f.classList.remove("active"));
    filterEl.classList.add("active");
    animateFontSize(newActiveH1, getActiveFontSize());
    animateItems(filterEl.dataset.filter);
  });
});

self is safe to close over here: the listener is declared inside the same factory that received self as its argument, so there is no re-entry into gsap.context and none of the temporal-dead-zone hazard that rules out naming ctx inside the factory body. The nested tween inside animateItems's onComplete — the return to full opacity, once clearItems()/addItemsToCols(filter) have rebuilt the columns — needs its own self.add, not a free ride from the outer one: onComplete fires after the fade-out tween finishes, well outside the call that invoked the outer one, so wrap that inner gsap.to the same way, or it survives ctx.revert() and keeps writing opacity onto a container React has already discarded mid-transition.

The resize listener that recalculates every heading's font size (gsap.set on each filter's spans) has the mirror-image problem: it is a plain window listener, not a GSAP construct, so ctx.revert() never reaches it no matter where in the factory you attach it. Keep the function reference and call window.removeEventListener("resize", handleResize) in the same cleanup — skip this and the remount leaves two resize listeners each rewriting fontSize on the same spans on every orientation change.

ctx.revert() also does not touch the item cards themselves: addItemsToCols builds each .item with createElement/innerHTML and appends it into one of the two .items-col elements, and the on-load call never clears them first — clearItems() is otherwise only reached from inside animateItems, right before a click-driven rebuild. Call clearItems() (or replaceChildren() on both .items-col elements) in the effect's own cleanup too, or the StrictMode remount's second addItemsToCols() appends a second full set of 34 cards behind the first, doubling both columns instead of replacing their contents.

One thing this component does *not* need extra guarding for: splitTextIntoSpans rebuilds each <h1> from element.innerText, not innerHTML, so re-running it against an already-split heading reads back the plain characters and re-wraps them once — it does not nest a <span> inside a <span> the way re-running GSAP's SplitText plugin on its own output would. The only precondition is that ctx.revert() has already put the previous pass's spans back to their CSS default size before the second splitTextIntoSpans call runs, so the two mounts start from the same clean markup.