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 classactive. 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.) .itemsholds exactly two empty.items-colcolumns; 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 likeAnton, 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 likeHelvetica,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-alignedmax-contentand 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 targetsh1 span, noth1, because JS wraps every character in a span.line-height:80%keeps the giant type tight. Thetransition:color 0.3shandles 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:
fontSizeof each<span>, from its current CSS size (base75px) 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 theonCompleterebuild the DOM and tween0 → 1(0.25s). No easing specified (GSAP defaultpower1.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:
- If the clicked filter already has class
active, return (no-op). - Grab the currently active heading (
.filter.active h1) and callanimateFontSize(previousActiveH1, "75px")— shrink it back to base, letter by letter. - Remove
activefrom all filters, addactiveto the clicked one. (This flips the CSS color: old heading letters transition #fb5eff→#000 over 0.3s, new ones →#fb5eff.) - Call
animateFontSize(clickedH1, "250px")— swell the new heading to 250px, letter by letter. - Read the clicked filter's
data-filterand callanimateItems(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): low-light and studio portraits (people styling hair, jewelry close-ups, silhouettes, veiled faces), still lifes (potted plants, candlesticks, vases, a book beside coffee, a singing bowl), architecture (stone archways, spiral staircase, Corinthian colonnade), and landscapes/scenes (desert dunes, an ocean wave, a beach at sunset, a tree-lined street, a skater in golden light). 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-motionhandling 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.toonfontSize(staggered across hand-made character spans) and on containeropacity.