# 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.

```js
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.

```html
<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:

```html
<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)`:

```js
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:

```js
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)

```js
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): 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-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`.
