All components

Interactive Calendar Cards

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

Open live demo ↗ Raw prompt (.md)

What it does

A column of month cards, each showing a randomized grid of contribution-style pads with a few colored 'active' cells. Clicking an active pad triggers gsap.to to scale it up 20x to flood-fill the card, then reveals an image, title, copy and link that fly in with a staggered elastic.out gsap.fromTo (y and random rotation); a Back button reverses the animation.

How it's built

Categorycards
Techgsap
Complexitysection
Performance costlight
Mobile-safeyes

cards click-reveal gsap grid contribution-graph elastic interactive calendar dark

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

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) onlyno 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 deliberate height: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; — 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:#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-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:#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:

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

  1. 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.
  2. 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:none and 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 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.