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, served from your own origin:

<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 ionicons@7.1.0 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:

<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 &middot; May – August</p>
    <h1>One frame a&nbsp;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:

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

<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)
import gsap from "gsap";
import data from "./data.js";

Constants used while building:

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

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:

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:

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:

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.