Looping preview · motionprompts.dev
The build prompt
From motionprompts.dev22,868 chars
# Interactive Calendar Cards — click a grid pad to flood-fill and reveal a card ## Goal Build a centered column of dark "month" cards on a near-black page. Each card shows its month name and a randomized **contribution-graph style grid of small square pads**, most of them dim, with a handful of brightly colored **active** pads. The star effect: clicking an active pad makes it **scale up 20× to flood-fill the whole card in its color**, and once the card is filled, a detail panel (image + title + paragraph + link) **flies in from below with a staggered `elastic.out` bounce and a random per-item rotation that unwinds to zero**. A **Back** button reverses the whole thing — the panel drops away and the pad shrinks back to a single cell. ## Tech Vanilla HTML/CSS/JS with ES module imports. Use **`gsap` (npm) only** — **no GSAP plugins, no Lenis, no Three.js, no ScrollTrigger**. All animation is plain `gsap.to` / `gsap.fromTo` / `gsap.set` tweens triggered by click. Ship one `index.html` (`<link rel="stylesheet" href="./styles.css">` + `<script type="module" src="./script.js">`), one `styles.css`, one ES-module `script.js`, and a `data.js` module that default-exports the card data array. Must run in a fresh Vite + npm project. The revealed link uses an **Ionicons** `arrow-forward-outline` glyph. Load Ionicons in the `<head>` via its ESM web-component script, served from your own origin: ```html <script type="module" src="/vendor/ionicons/ionicons.esm.js"></script> <script nomodule src="/vendor/ionicons/ionicons.js"></script> ``` Get those two files with `npm i [email protected]` and copy `node_modules/ionicons/dist/ionicons/` into your public directory. Copy the **whole** folder: the loader fetches its `p-*.entry.js` chunks and one `svg/<name>.svg` per icon at runtime, resolved relative to the script's own URL. Ionicons is not a build-time dependency — it stays a plain `<script>`, never an `import`. ## Layout / HTML The static HTML is tiny — everything else is generated by JS. Only a container with a nav lives in markup: ```html <div class="container"> <nav> <div class="logo"><a href="#">Stillroom</a></div> <div class="nav-items"> <a href="#">Archive</a> <a href="#">Prints</a> </div> </nav> <header class="intro"> <!-- static, never animated --> <p class="eyebrow">Shoot diary · May – August</p> <h1>One frame a day</h1> <p class="lede"> The studio's summer log, kept as a calendar. Tap a lit day to open the frame we kept, and the notes that went with it. </p> <p class="legend"> <span class="swatch lit"></span>frame kept <span class="swatch dim"></span>camera stayed shut </p> </header> </div> <footer class="site-footer"> <p>Stillroom is a two-person still-life studio in Lisbon.</p> <p class="fine">Ten frames survived the summer. Each is an edition of 25.</p> </footer> ``` `script.js` appends one `.card` per month **into `.container`**, after the nav. The per-card DOM the JS builds is: ```html <div class="card"> <div class="card-title"><p>May<span>4 frames kept</span></p></div> <div class="card-content"><!-- empty until a pad is clicked --></div> <div class="row"><div class="pad"></div> …7 pads… </div> <!-- row 0 --> <div class="row"><div class="pad"></div> …7 pads… </div> <!-- row 1 --> <div class="row"><div class="pad"></div> …7 pads… </div> <!-- row 2 --> <div class="row"><div class="pad"></div> …7 pads… </div> <!-- row 3 --> <div class="row"><div class="pad"></div> …2–4 pads… </div> <!-- row 4 (random count) --> </div> ``` When an active pad is clicked, `.card-content` is filled with: ```html <button>Back</button> <div class="card-item img"><img src="<detail image>" alt="" /></div> <div class="card-item copy"> <span class="kicker">May 6 · Frame 118</span> <h1>Sable, up close</h1> <p>Portrait morning at the wildlife sanctuary. One 85mm lens, no flash…</p> </div> <div class="card-item copy link"> <a href="#archive-118">View print</a> <ion-icon name="arrow-forward-outline"></ion-icon> </div> ``` ### Data (`data.js`) Default-export an array of single-key month objects; each key's value is an array of item objects `{ img, kicker, h1, copy, linkLabel, linkSrc }`. Use 4 months so the demo shows different active-pad counts per card (the number of items in a month === how many active pads that card gets): - **May** → 4 items, **June** → 1 item, **July** → 3 items, **August** → 2 items. Copy voice is a photographer's shoot diary: `kicker` is a date and frame number ("May 6 · Frame 118"), `h1` is the picture's name ("Sable, up close", "Irises on red tile", "Ranunculus, near dark"), `copy` is one or two sentences of shooting notes, `linkLabel` is "View print" and `linkSrc` an in-page anchor (`#archive-118`). The card title carries the month plus a count span ("May" + "4 frames kept"). No client or third-party brand names. ## Styling - Reset: `* { margin:0; padding:0; box-sizing:border-box; }`. - Palette — near-black ground, ivory paper, one ember accent (the ramp `#f04e23 → #7a1f00` is what the JS paints on kept days): ```css :root { --bg: #141414; --card: #1d1d1d; --pad: #262626; --paper: #efece3; --ink: #141414; --muted: #9c9c96; --accent: #f04e23; --ember: #b03410; --r-card: 18px; --r-inner: 10px; --r-cell: 5px; } ``` - Fonts: **Inter** for body, **Space Grotesk** for titles and headings, **Space Mono** for the small uppercase kickers and counts. - `html, body { width:100%; min-height:100%; font-family:"Inter", system-ui, sans-serif; background-color: var(--bg); }` plus a **safelight**: a non-repeating `radial-gradient(760px 440px at 50% 44px, rgba(240,78,35,.14), rgba(20,20,20,0) 72%)` behind everything, one warm lamp above the fold that motivates why kept days glow. - `img { width:100%; height:100%; object-fit:cover; }`. - `.container { width:100%; height:100%; }`. - `nav { width:460px; max-width:calc(100% - 2.5rem); margin:0 auto; padding:2.25em 0 1em; display:flex; justify-content:space-between; align-items:center; }` — the same 460px column as the cards. `.nav-items { display:flex; gap:2em; }`. - **`.card`**: `position:relative; width:460px; max-width:calc(100% - 2.5rem); height:400px; margin:0 auto 1.75rem; padding:2em; border-radius:var(--r-card); display:flex; flex-direction:column; gap:0.5em; background-color:var(--card); overflow:hidden; transition:height .25s ease;` — the `overflow:hidden` is essential: it clips the pad as it scales 20× so the enlargement reads as the card flooding with color. - `.card-title { color:var(--paper); padding-bottom:0.75em; }`; its `p` is Space Grotesk `1.4rem/600`, and the `span` inside it is Space Mono `11px` uppercase in `--muted` (the "4 frames kept" count). - `.row { width:100%; display:flex; gap:0.5em; }`. - **`.pad`**: `position:relative; width:calc((100% - 3em) / 7); aspect-ratio:1/1; border-radius:var(--r-cell); background-color:var(--pad); z-index:0; pointer-events:none;` — dim cells that divide the card width into seven columns, non-interactive by default. - **`.pad.active`**: `pointer-events:all; cursor:pointer;` (JS also paints each active pad a bright color inline). - Decorative dim: `.card .row:nth-child(3) .pad:nth-child(1), …:nth-child(2), …:nth-child(3) { opacity:0.35; }` — because `.card-title` and `.card-content` are the card's first two children, `:nth-child(3)` is the **first pad row**; its first three pads render at 35% opacity (a subtle top-left fade). - **`.card-content`**: `position:absolute; top:0; left:0; width:100%; height:100%; padding:2em; color:var(--ink); display:flex; flex-direction:column; justify-content:safe center; gap:0.85em; overflow-y:auto; pointer-events:none; z-index:2; opacity:0;` — the reveal panel, stacked above the pads (z-index 2), starts fully transparent and click-through. - `button { position:absolute; top:1.4em; right:1.4em; z-index:3; border:none; outline:none; border-radius:var(--r-inner); font-family:"Inter"; font-size:0.8rem; font-weight:600; padding:0.6em 1.2em; background-color:var(--ink); color:var(--paper); cursor:pointer; }` (the "Back" chip, pinned to the panel's top-right corner). - `.img { width:100%; height:150px; margin:0; border-radius:var(--r-inner); overflow:hidden; box-shadow:0 12px 28px rgba(0,0,0,.3); }` (fixed 150px-tall image well). - `.copy { margin:0; padding:1.4em 1.5em; border-radius:var(--r-inner); background:var(--paper); color:var(--ink); box-shadow:0 12px 28px rgba(0,0,0,.2); }`, `.copy h1 { font-family:"Space Grotesk"; font-size:1.35rem; font-weight:600; letter-spacing:-0.02em; margin-bottom:0.3em; }`, `.copy p { font-size:0.9rem; line-height:1.55; color:#55544e; }`, `.copy .kicker { font-family:"Space Mono"; uppercase, in var(--ember); }`. - `.copy.link { display:flex; justify-content:space-between; align-items:center; margin:1em 0; }` (label on the left, arrow icon on the right). - `.card-item { position:relative; }`. - Media `@media (max-width:900px)`: `nav { width:100%; padding:2em; }`; `.card { width:360px; height:320px; gap:0.25em; }`; `.row { gap:0.25em; }`; `.pad { width:40px; height:40px; }`. ## GSAP effect (be exhaustive) ### Build order (`DOMContentLoaded`) ```js import gsap from "gsap"; import data from "./data.js"; ``` Constants used while building: ```js const activeColors = ["#f04e23", "#d43f15", "#a82f0b", "#7a1f00"]; // the ember ramp, brightest → deepest ``` For each month object in `data`: read the month name (its single key) and its `items` array. Create `.card`, then `.card-title` (`<p>${month}</p>`), then an **empty** `.card-content`, then call `generatePads(card, items.length, items)`, then append the card to `.container`. ### Grid generation — `generatePads(card, activePadCount, items)` ```js const rowsConfig = [7, 7, 7, 7, Math.floor(Math.random() * 3) + 2]; // 5 rows: four of 7, last of 2–4 const clickablePads = []; rowsConfig.forEach((padCount, rowIndex) => { const row = document.createElement("div"); row.classList.add("row"); for (let i = 0; i < padCount; i++) { const pad = document.createElement("div"); pad.classList.add("pad"); row.appendChild(pad); // only the MIDDLE rows are eligible to become active (skip first and last row) if (rowIndex !== 0 && rowIndex !== rowsConfig.length - 1) clickablePads.push(pad); } card.appendChild(row); }); shuffleArray(clickablePads); // Fisher–Yates in place setActivePads(clickablePads, card, activePadCount, items); ``` So every card has **5 rows**: rows 0–3 hold 7 pads each and the last row holds a random 2–4 pads. The **eligible ("clickable") pool is rows 1, 2, 3 only** (21 pads); the top row and the last row are never activated. `shuffleArray` is a standard in-place Fisher–Yates shuffle. ### Activating pads — `setActivePads(clickablePads, card, activePadCount, items)` Take the **first `activePadCount`** pads of the shuffled pool (`activePadCount` === number of items for that month). For each such `pad` at index `i`: - `pad.classList.add("active")`. - Paint it a **random** color: `pad.style.backgroundColor = activeColors[Math.floor(Math.random()*activeColors.length)]`. - Attach a **click** handler (below). The item shown is `items[i]` — the i-th active pad maps to the i-th data item. ### The reveal (click on an active pad) — the star tween On click: 1. **Raise this pad above its siblings** so its growth covers the card: `clickablePads.forEach(p => p.style.zIndex = "0"); pad.style.zIndex = "1";`. 2. Populate `card.querySelector(".card-content").innerHTML` with the Back button + three `.card-item` blocks (img / copy / copy.link) from `items[i]` (see HTML above). 3. **Flood-fill:** scale the clicked pad up 20×: ```js gsap.to(pad, { scale: 20, duration: 0.3, onComplete: () => { /* step 4 + 5 */ } }); ``` The pad is a 50×50 rounded square; at `scale:20` it becomes a 1000×1000 block of its color that overflows and paints the entire 460×400 card (clipped by the card's `overflow:hidden`). No ease specified → GSAP default `power1.out`. 4. **Fade the panel in** (inside the scale's `onComplete`): `gsap.to(cardContent, { opacity: 1, pointerEvents: "all", duration: 0.075 })` — the `.card-content` (z-index 2) becomes visible and interactive almost instantly over the flooded color. 5. **Fly the three items in with an elastic stagger** (also in the same `onComplete`, started together with step 4): ```js gsap.fromTo( cardContent.querySelectorAll(".card-item"), { y: 100, rotation: () => gsap.utils.random(-30, 30), opacity: 0 }, { y: 0, rotation: 0, opacity: 1, duration: 2, ease: "elastic.out", stagger: 0.1 } ); ``` Each of the 3 `.card-item` blocks starts **100px below**, **transparent**, and **rotated a random amount between −30° and +30°** (the `rotation` FROM value is a **function** so every item gets its own random tilt), then springs to `y:0, rotation:0, opacity:1`. `duration:2`, `ease:"elastic.out"` (GSAP's default `elastic.out(1, 0.3)` — a pronounced overshoot-and-settle bounce), `stagger:0.1` so item 2 starts 0.1s after item 1 and item 3 0.1s after item 2. The result: image, then title card, then link row each drop in and wobble to rest. ### The reversal (click "Back") A click handler on `card.querySelector("button")`: ```js gsap.to(cardContent, { opacity: 0, pointerEvents: "none", duration: 0.2, onComplete: () => { gsap.to(pad, { scale: 1, duration: 0.3, onComplete: () => { pad.style.zIndex = "0"; cardContent.style.opacity = "0"; cardContent.style.pointerEvents = "none"; gsap.set(cardContent.querySelectorAll(".card-item"), { clearProps: "all" }); } }); } }); ``` Sequence: the panel **fades out** (`duration:0.2`), then the pad **shrinks back** from 20× to `scale:1` (`duration:0.3`, default ease), then state is reset — pad z-index back to 0, panel forced transparent & click-through, and `clearProps:"all"` on the `.card-item`s wipes the inline transforms so a re-open animates cleanly from scratch. After this the same (or another) active pad can be clicked again. ### Timing summary (one open→close cycle) open pad scale→20 (0.3s) → [panel fade-in 0.075s ∥ 3 items elastic fly-in, 2s each, 0.1s stagger] … Back → panel fade-out 0.2s → pad scale→1 (0.3s) → reset. ## Assets / images **10 still-life photographs**, one per item slot (4+1+3+2 across the four months), each dropped into a fixed 150px-tall rounded well with `object-fit:cover` (source aspect ratios vary — landscape, portrait and square all work). They are single-subject studio frames, shot dark and directional, matching the diary entries that name them: a sable antelope portrait through glass; irises against red tile; an orange in first light; a shadow bouquet; the same irises a week older; water and prisms; an egg beside stemware; wool in motion; ranunculus near dark; stone and clay. Any set of quiet, high-contrast still lifes reads correctly — the point is that they look like ten days of the same photographer's work. No logos or real brand marks. ## Behavior notes - **Per-load randomness:** the last row's pad count (2–4), which pads become active (shuffled pool), and each active pad's color (from the 4-color set) are re-randomized every page load — no two loads look identical. Active-pad **count** per card is deterministic (== that month's item count). - **Interaction is click-only** — there is no scroll, hover, or load animation; the grid sits still until an active pad is clicked. Non-active pads have `pointer-events:none` and never respond. - The page itself is a simple vertical stack of cards that scrolls normally (above `1100px` the container becomes a two-column grid, `26rem` of intro copy beside the `460px` card column); each card animates independently and self-contained inside its own `overflow:hidden` box. - Only one panel is meaningfully open per card at a time; opening a different active pad re-fills the same `.card-content`. No reduced-motion branch is required. - Desktop and mobile both work; below 900px cards and pads shrink per the media query. ## Images This component ships with 10 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/cards/img1.jpg https://motionprompts.dev/c/cards/img10.jpg https://motionprompts.dev/c/cards/img2.jpg https://motionprompts.dev/c/cards/img3.jpg https://motionprompts.dev/c/cards/img4.jpg https://motionprompts.dev/c/cards/img5.jpg … 4 more under https://motionprompts.dev/c/cards/ ``` 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`** — `--bg`, `--card`, `--pad`, `--paper`, `--ink`, `--muted`, `--accent`, `--ember`, `--r-card`, `--r-inner`, `--r-cell`. 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 `*`, `html, 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. ## 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 triggers on the same element disagreeing about the same scrub, two smooth scrollers pulling on the same wheel event. The visible symptom is jitter or doubled speed, 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 cards, no pads, nothing on the page and nothing to debug. Delete the listener and move its body — the `data.forEach` loop that builds one `.card`, one `.card-title`, an empty `.card-content`, and the five pad rows for every month — directly inside a `useEffect` with an empty dependency array. *(2) Element lookups* — The single `document.querySelector` in this script finds `.container`, the element every generated `.card` gets appended to. Give the component a root ref and append into that node instead of querying the document for it. Nothing else here needs scoping: `card.querySelector(".card-content")` and `card.querySelector("button")` already operate on `card`, a reference to the exact element this same effect just created a moment earlier, not a document-wide selector — leave those calls as they are. *(3) Cleanup* — Wrap the whole build — card creation, pad generation, and the click listeners `setActivePads` attaches to each active pad — in a `gsap.context` scoped to the root ref, and revert it in the cleanup: ```jsx useEffect(() => { const ctx = gsap.context((self) => { data.forEach((monthData) => { /* build one .card, its rows and pads, exactly as described above */ }); }, rootRef); return () => ctx.revert(); }, []); ``` This component's animations don't run while that factory executes, though — they run later, on click, from inside listeners the factory attaches but never itself invokes. The scale-up flood-fill on the clicked pad, the fade-in of `.card-content`, the elastic fly-in of its three `.card-item` blocks, and the reversed fade-and-shrink behind the "Back" button all fire an arbitrary amount of time after `gsap.context`'s synchronous pass has already finished — possibly after a StrictMode remount has already thrown this closure away and built a new one. A tween created at that point is not automatically inside the context just because the context wraps the code that scheduled it; it has to be attributed explicitly, at the moment it runs, with the immediate-invoke form of `self.add`: ```jsx pad.addEventListener("click", () => { self.add(() => { gsap.to(pad, { scale: 20, onComplete: () => { /* the card-content fade-in and the elastic .card-item stagger, as above */ }, }); }); }); ``` `self` is safe to close over here because the click listener is defined inside the same factory that received it as an argument — 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, since this call happens long after `self` was assigned, from a callback the browser invokes on its own schedule. The same wrapping belongs on the nested `button` listener that fades `.card-content` out and shrinks the pad back to one — it is attached from inside the pad's own click callback, so it is even more deferred, and its tweens need the same attribution to be reverted with everything else. One more thing `ctx.revert()` will not do: it undoes tweens, triggers and the inline styles GSAP wrote, but the `.card`, `.row` and `.pad` elements this effect built with `createElement`/`appendChild` are plain DOM, not GSAP state. If the cleanup only calls `ctx.revert()`, the StrictMode remount appends a second complete set of cards on top of the first instead of replacing it — the container ends up with eight cards instead of four, each pad wired to a listener closing over a `self` whose context has already been reverted. Clear the container's children in the same cleanup: ```jsx return () => { ctx.revert(); containerRef.current.replaceChildren(); }; ``` Everything the effect creates must be undone in the function it returns. The test of a correct adaptation is not that it looks right on first load — it is that you can navigate away to another route and come back and nothing has accumulated.