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 (CDN is fine, it is not an npm dep):
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
Layout / HTML
The static HTML is tiny — everything else is generated by JS. Only a container with a nav lives in markup:
<div class="container">
<nav>
<div class="logo"><a href="#">Motionprompts</a></div>
<div class="nav-items">
<a href="#">Blog</a>
<a href="#">Contact</a>
</div>
</nav>
</div>
script.js appends one .card per month into .container, after the nav. The per-card DOM the JS builds is:
<div class="card">
<div class="card-title"><p>May</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:
<button>Back</button>
<div class="card-item img"><img src="<detail image>" alt="" /></div>
<div class="card-item copy">
<h1>Modern Art Evolution</h1>
<p>Explore the dynamic evolution of modern art…</p>
</div>
<div class="card-item copy link">
<a href="https://example.com/…">Explore More</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, 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 neutral art/gallery blurb (titles like "Modern Art Evolution", "Sculpture Innovations", "Abstract Painting Odyssey", "Graphic Design Trends", "Contemporary Photography", "Mixed Media Masterpieces", "Conceptual Art Series", "Street Art Chronicles"; each copy is one 1–2 sentence paragraph; linkLabel like "Explore More" / "Discover Now" / "Read More"; linkSrc a placeholder https://example.com/...). No client or third-party brand names.
Styling
- Reset:
* { margin:0; padding:0; box-sizing:border-box; }. html, body { width:100%; height:150%; font-family:"Rubik"; background-color:#1a1a1a; }— note the deliberateheight:150%(leaves scroll room below the stacked cards). Load Rubik from Google Fonts (300–900). (Big Shoulders Display may also be linked but is not used for visible text.)img { width:100%; height:100%; object-fit:cover; }..container { width:100%; height:100%; }.nav { width:30%; margin:2em auto 0 auto; padding:2em 0.25em; display:flex; justify-content:space-between; align-items:center; }..nav-items { display:flex; gap:2em; }.nav a { text-decoration:none; color:#fff; }..card:position:relative; width:460px; height:400px; margin:2em auto; padding:2em; border-radius:1.5rem; display:flex; flex-direction:column; gap:0.5em; background-color:#272727; overflow:hidden;— theoverflow:hiddenis essential: it clips the pad as it scales 20× so the enlargement reads as the card flooding with color..card-title { color:#fff; padding-bottom:1em; }..row { width:100%; display:flex; gap:0.5em; }..pad:position:relative; width:50px; height:50px; border-radius:0.5rem; background-color:#333333; z-index:0; pointer-events:none;— dim grey cells, 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-titleand.card-contentare 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:#fff; 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 { border:none; outline:none; border-radius:4em; font-family:"Rubik"; padding:0.5em 1em; background-color:#fff; cursor:pointer; }(the white "Back" pill)..img { width:100%; height:200px; margin:1em 0; border-radius:1em; overflow:hidden; }(fixed 200px-tall image well)..copy { margin:0.5em 0; padding:1.5em; border-radius:1em; background:#fff; color:#000; },.copy h1 { font-size:20px; font-weight:500; margin-bottom:0.35em; },.copy p { font-size:14px; line-height:150%; color:gray; }..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)
import gsap from "gsap";
import data from "./data.js";
Constants used while building:
const activeColors = ["#5fa5f9", "#e879f9", "#a78bfa", "#2cd4bf"]; // blue, magenta, violet, teal
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)
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:
- Raise this pad above its siblings so its growth covers the card:
clickablePads.forEach(p => p.style.zIndex = "0"); pad.style.zIndex = "1";. - Populate
card.querySelector(".card-content").innerHTMLwith the Back button + three.card-itemblocks (img / copy / copy.link) fromitems[i](see HTML above). - 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`.
- 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. - 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"):
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-items 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
8 distinct editorial / product detail photos, each dropped into a fixed 200px-tall rounded well with object-fit:cover (source aspect ratios vary — landscape, portrait and square all work). They are moody, single-subject studio/product shots on dark or neutral backgrounds (e.g. a glossy 3D app icon on grey, a frosted cosmetic bottle catching a shaft of light, a kraft-paper product box on wood, a white wireframe figure on black, a spotlit pump bottle, a metallic bottle in a gift tin, numbered capsule containers on black, a phone screen glowing a single word in the dark). Any set of 8 clean, high-contrast product/editorial images reads correctly. There are 10 item slots across the four months (4+1+3+2), so two images are reused (the two used by the first card also serve the last card). 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:noneand never respond. - The page itself is a simple vertical stack of cards (
height:150%body) that scrolls normally; each card animates independently and self-contained inside its ownoverflow:hiddenbox. - 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.