All components

Griflan Hover Effect

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

Open live demo ↗ Raw prompt (.md)

What it does

Three stacked service rows expand on hover with an elastic GSAP tween: a fanned stack of three images slides up behind the title, the title recolors from red to cream, and after 0.2s pill-shaped tags drop into the row under Matter.js gravity, bouncing and settling on an invisible floor. On mouseleave the tags fade out and the row springs back closed.

How it's built

Categoryhover
Techgsap, matter-js
Complexitysection
Performance costheavy
Mobile-safedesktop-first

hover physics matter-js elastic expand tags image-stack services-list

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

Griflan Hover Effect — Elastic Service Rows with Falling Physics Tags

Goal

Build a dark, full-viewport list of three stacked "service" rows, each a single huge uppercase word (SILHOUETTE, CHROMA, PERSONA). On hover a row does three things at once: it springs open to more than double its height with a bouncy elastic tween, a fanned stack of three overlapping images slides up into view from behind the title, and the title recolors from red to cream. Then, 0.2s later, the row's category tags — pill-shaped labels — drop in from the top under real gravity (a Matter.js physics simulation), bounce, and settle in a little heap on an invisible floor at the bottom of the row. On mouseleave the tags fade out, the images slide back down behind the title, the color returns to red, and the row springs shut. The star effect is the combination of the elastic expand + image-stack reveal + physics-driven tag drop.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) and matter-js (npm) — no GSAP plugins, no smooth-scroll, no Three.js. GSAP drives the row expand/collapse, the image slide, the color change and the tag opacity; Matter.js runs a per-row rigid-body world that makes the tags fall and pile up. Ship one index.html (<link rel="stylesheet" href="./styles.css"> and <script type="module" src="./script.js">), one styles.css, one ES-module script.js. Must run in a fresh Vite + npm project.

Layout / HTML

<section class="services">
  <div class="service"
       data-tags="Editorial, Fashion Identity, Monochrome, Shadow Play, Minimalism, Studio Portraits">
    <div class="service-name"><h1>Silhouette</h1></div>
    <div class="service-images">
      <div class="img"><img src="<row1 image 1>" /></div>
      <div class="img"><img src="<row1 image 2>" /></div>
      <div class="img"><img src="<row1 image 3>" /></div>
    </div>
  </div>

  <div class="service"
       data-tags="Color Theory, Graphics, Poster Design, Saturation, Pop Art, Visual Energy">
    <div class="service-name"><h1>Chroma</h1></div>
    <div class="service-images">
      <div class="img"><img src="<row2 image 1>" /></div>
      <div class="img"><img src="<row2 image 2>" /></div>
      <div class="img"><img src="<row2 image 3>" /></div>
    </div>
  </div>

  <div class="service"
       data-tags="Character Design, Portraits, Visual Storytelling, Emotion, Identity, Artistic Direction">
    <div class="service-name"><h1>Persona</h1></div>
    <div class="service-images">
      <div class="img"><img src="<row3 image 1>" /></div>
      <div class="img"><img src="<row3 image 2>" /></div>
      <div class="img"><img src="<row3 image 3>" /></div>
    </div>
  </div>
</section>
  • Exactly three .service rows, each with the same internal structure: a .service-name > h1 (the giant word) plus a .service-images wrapper holding three .img boxes, each with one <img>.
  • Each row carries a data-tags attribute: a comma-separated list of ~6 short category labels. The JS reads it with .split(",") (labels keep their leading space — do not trim). These strings become the physics pills.
  • The .tags-container and each .tag element are created at runtime by the JS (not authored in HTML), but the .tag CSS class must exist in the stylesheet so runtime measurement and styling are correct.

Styling

Fonts (Google Fonts): the titles use "Barlow Condensed" weight 900; the pill tags use "Instrument Serif". Import both.

Palette

  • Section background: #171717 (near-black).
  • Title default color: #ff3831 (bright red). Title hover color: #FFFFD9 (cream).
  • Tag text/border: #ffffd9 (cream) on a #171717 fill.

Reset / base: * { margin:0; padding:0; box-sizing:border-box; }. img { width:100%; height:100%; object-fit:cover; }.

Section.services { position:relative; width:100%; height:100svh; padding:2rem; background:#171717; color:#ff3831; display:flex; flex-direction:column; justify-content:center; align-items:center; } (the three rows stack vertically, centered on screen).

Row.service { position:relative; width:max-content; height:10rem; display:flex; align-items:flex-end; will-change:height; overflow:hidden; cursor:pointer; }. The row is only as wide as its word; overflow:hidden is essential — it clips the fanned images (which live above the title) and the falling tags to the row's box. Height is the animated property.

Title.service-name h1 { position:relative; text-transform:uppercase; font-family:"Barlow Condensed",sans-serif; font-size:10rem; font-weight:900; letter-spacing:-0.1rem; line-height:1; background-color:#171717; z-index:2; }. The solid #171717 background on the h1 is load-bearing: it paints over the lower part of the image stack so the images look like they emerge from *behind* the letters. z-index:2 keeps the title above the images.

Image wrapper.service-images { position:absolute; top:0%; left:50%; transform:translate(-50%,0%); width:25rem; height:20rem; overflow:hidden; } (centered horizontally over the row, anchored to its top).

Image boxes.img { position:absolute; top:50%; left:50%; transform:translate(-50%,50%); width:15rem; height:10rem; border-radius:0.35rem; overflow:hidden; }. All three start translated down by 50% of their own height (translate(-50%,50%)), so at rest they sit low behind the title. They are fanned:

  • .img:nth-child(1) { transform-origin:bottom left; transform:translate(-50%,50%) rotate(-5deg); margin-top:-1.5rem; }
  • .img:nth-child(2) { transform-origin:bottom right; transform:translate(-50%,50%) rotate(2.5deg); margin-top:-1.5rem; }
  • .img:nth-child(3) keeps the default (no rotation) and sits on top of the fan.

Tag pill.tag { position:absolute; font-family:"Instrument Serif",sans-serif; color:#ffffd9; background:#171717; border:1px solid #ffffd9; border-radius:4rem; padding:0.5rem 1.5rem; white-space:nowrap; opacity:0; will-change:transform,opacity; }. Fully rounded (border-radius 4rem) so it reads as a lozenge. Starts invisible (opacity 0) and is positioned/rotated only via inline transform written each frame by the physics loop.

Tags container.tags-container { position:absolute; top:0; left:0; width:100%; height:100%; z-index:10; pointer-events:none; } (overlays the whole row, above the images, and never eats hover events).

GSAP + Matter.js effect (be exhaustive)

Imports & setup
import gsap from "gsap";
import Matter from "matter-js";

const services = document.querySelectorAll(".service");
const { Engine, World, Bodies, Body } = Matter;
Measuring pill sizes up front

Before any physics, measure each pill's rendered size so the rigid bodies match the DOM. A helper creates a throwaway .tag, appends it to <body>, reads offsetWidth/offsetHeight, and removes it:

function getTagDimensions(label) {
  const ghost = document.createElement("div");
  ghost.classList.add("tag");
  ghost.textContent = label;
  document.body.appendChild(ghost);
  const size = { width: ghost.offsetWidth, height: ghost.offsetHeight };
  ghost.remove();
  return size;
}

Per row: tagLabels = service.dataset.tags.split(",") and tagSizes = tagLabels.map(getTagDimensions).

Per-row state

For each .service, capture serviceImages = service.querySelectorAll(".img") (the 3 boxes) and serviceName = service.querySelector("h1"), and hold mutable state: engine, tagElements[], tagBodies[], rafId, tagsContainer, isHovered (bool), tagDropTimer (a GSAP delayedCall handle).

createTags() — build the physics world and drop the pills
  1. Call cleanupTags() first (idempotent reset).
  2. Read serviceWidth = service.offsetWidth, serviceHeight = service.offsetHeight (the *expanded* size, since this runs after the row has grown).
  3. Create a div.tags-container and append it to the row.
  4. engine = Engine.create({ gravity: { x: 0, y: 2 } }) — gravity points down at 2× the Matter default, so tags fall briskly.
  5. Build three static boundary bodies with wallThickness = 20 and floorOffset = window.innerWidth < 1000 ? 25 : 50:
  6. Floor: Bodies.rectangle(serviceWidth/2, serviceHeight - floorOffset + wallThickness/2, serviceWidth*3, wallThickness, { isStatic:true }) — a wide bar set floorOffset px above the row's bottom edge, so the pile settles a little inset from the bottom.
  7. Left wall: Bodies.rectangle(-wallThickness/2, serviceHeight/2, wallThickness, serviceHeight*3, { isStatic:true }).
  8. Right wall: Bodies.rectangle(serviceWidth+wallThickness/2, serviceHeight/2, wallThickness, serviceHeight*3, { isStatic:true }).
  9. World.add(engine.world, [floor, leftWall, rightWall]).
  10. For each label i:
  11. Create a div.tag, set textContent = label, append to the container.
  12. tagWidth = tagSizes[i].width, tagHeight = tagSizes[i].height.
  13. Spawn position: startX = serviceWidth*0.25 + Math.random()*serviceWidth*0.5 (random within the middle 50% of the row), startY = -(tagHeight/2) - i*5 (just above the top, each successive pill nudged 5px higher so they don't spawn stacked exactly).
  14. angle = (Math.random() - 0.5) * 0.4 (a small random tilt, ±0.2 rad).
  15. body = Bodies.rectangle(startX, startY, tagWidth, tagHeight, { chamfer:{ radius: tagHeight/2 }, restitution:0.15, friction:0.6, density:0.002 })chamfer radius = half the height rounds the rectangle into a true pill shape; low restitution (0.15) = little bounce; high friction (0.6) so they don't slide; light density (0.002).
  16. Body.setAngle(body, angle), then World.add(engine.world, body).
  17. Fade the pill in with GSAP: gsap.to(tagElement, { opacity:1, duration:0.3, delay:i*0.04, ease:"power2.out" }) — a staggered 0.04s-per-tag reveal.
  18. Push into tagElements / tagBodies.
  19. Start a rAF loop update(): each frame call Engine.update(engine, 1000/60) (fixed ~16.67ms step), then for every tag write tagElement.style.transform = translate(${body.position.x - tagWidth/2}px, ${body.position.y - tagHeight/2}px) rotate(${body.angle}rad) (convert the body's center-based position to the element's top-left, and mirror its rotation). Store rafId = requestAnimationFrame(update) and re-arm each frame.
cleanupTags()

cancelAnimationFrame(rafId), Engine.clear(engine), remove the tagsContainer from the DOM, and reset tagElements=[], tagBodies=[], engine=null, rafId=null, tagsContainer=null.

mouseenter (open the row)
isHovered = true;
const expandedHeight = window.innerWidth < 1000 ? "12.5rem" : "25rem";
gsap.killTweensOf(service);
gsap.killTweensOf(serviceImages);
gsap.killTweensOf(serviceName);

Then run three tweens in parallel:

  • Row heightgsap.to(service, { height: expandedHeight, duration:0.75, ease:"elastic.out(1,0.5)" }) — the springy over-shoot open.
  • Title colorgsap.to(serviceName, { color:"#FFFFD9", duration:0.25, ease:"power4.out" }) (red → cream, fast).
  • Image stackgsap.to(serviceImages, { y:"-50%", duration:0.75, ease:"elastic.out(1,0.5)", stagger:0.075 }). This animates each .img's translateY from its CSS rest of +50% up to -50% (i.e. the fan slides up from behind/below the title into full view above it). The stagger:0.075 makes the three images arrive one after another with the same elastic spring.
  • Delayed tag droptagDropTimer = gsap.delayedCall(0.2, () => { if (isHovered) createTags(); }). After 200ms — only if the pointer is still over the row — the physics tags spawn and fall.
mouseleave (close the row)
isHovered = false;
const collapsedHeight = window.innerWidth < 1000 ? "5rem" : "10rem";
if (tagDropTimer) tagDropTimer.kill();          // cancel a pending drop
gsap.killTweensOf(service);
gsap.killTweensOf(serviceImages);
gsap.killTweensOf(serviceName);
  • Fade out & remove tags: if (tagElements.length) gsap.to(tagElements, { opacity:0, duration:0.25, ease:"power2.out", onComplete:cleanupTags }); else call cleanupTags() directly (covers leaving before the 0.2s drop fired).
  • Title colorgsap.to(serviceName, { color:"#ff3831", duration:0.25, ease:"power4.out" }) (cream → red).
  • Image stackgsap.to(serviceImages, { y:"50%", duration:0.75, ease:"elastic.out(1,0.5)", stagger:0.075 }) (slide back down behind the title).
  • Row heightgsap.to(service, { height: collapsedHeight, duration:0.5, ease:"elastic.out(1,0.75)" }) — note the collapse is faster (0.5s) and stiffer (elastic.out(1,0.75)) than the open.
Timing / easing summary

| Action | property | from → to | duration | ease | stagger/delay | |---|---|---|---|---|---| | open row | height | 10rem → 25rem (5→12.5 mobile) | 0.75 | elastic.out(1,0.5) | — | | open title | color | #ff3831#FFFFD9 | 0.25 | power4.out | — | | open images | y | 50%-50% | 0.75 | elastic.out(1,0.5) | 0.075 | | tag drop | (physics spawn) | — | — | — | delayedCall 0.2s | | tag fade-in | opacity | 0 → 1 | 0.3 | power2.out | delay i×0.04 | | close tags | opacity | 1 → 0 | 0.25 | power2.out | — | | close title | color | #FFFFD9#ff3831 | 0.25 | power4.out | — | | close images | y | -50%50% | 0.75 | elastic.out(1,0.5) | 0.075 | | close row | height | 25rem → 10rem (12.5→5 mobile) | 0.5 | elastic.out(1,0.75) | — |

Assets / images

Stylized editorial portrait illustrations, provided as small sets per row (the JS references up to three <img> per .service). The real asset set is 8 images — three for row 1, three for row 2, two for row 3, some reused across rows. Each is framed 3:2 landscape (roughly 1200×800, displayed in a 15rem×10rem box with object-fit:cover). All images share one consistent visual language — flat, graphic pop-art / silkscreen illustration of a single head-and-shoulders figure with cool blue-toned (monochrome-blue) skin, bold black linework and cel-shading, set against a flat single-color background; no photos, no client or third-party branding. Backgrounds vary across a small palette of warm cream, mid/cobalt blue, and bright red, with sparse yellow accents (gold hoop earrings, sunglasses, striped knit). The actual subjects: a short-haired figure in a white cap and dark jacket on a cream ground; a wavy-haired figure in a cream/off-white blazer with a gold earring on a cobalt-blue ground; a figure in yellow sunglasses and a blue-yellow striped sweater on a red ground; and a bob-haired figure in a blue-and-cream striped top on a blue ground — all rendered in the same blue-skinned illustrated style.

Because every image reads the same graphically, the effect does not depend on per-row themes: any set of 3 same-style portraits works, and sets may reuse the same illustrations. Keep them tonally consistent so the fanned stack looks like one collection.

Within each row the three images fan as: image 1 rotated −5° (back-left), image 2 rotated +2.5° (back-right), image 3 flat on top.

Behavior notes

  • Per-row independent physics: each service builds and tears down its own Matter.js engine on hover in/out — never a shared world. The rAF loop only runs while a row is open; cleanupTags() fully disposes it.
  • Race-safe drop: the tag drop is a 0.2s delayedCall guarded by if (isHovered), and mouseleave kills that timer — quickly flicking across a row never leaves orphaned tags.
  • Kill-before-tween: every hover handler calls gsap.killTweensOf() on the row, images and title first, so rapid re-hover snaps cleanly to the new target instead of queuing conflicting elastic tweens.
  • Responsive (< 1000px / CSS max-width:999px): row height 5rem→12.5rem (vs 10→25rem), title 5rem, image wrapper 12.5rem×10rem, image boxes 7.5rem×5rem (margin-top −0.75rem), smaller tag padding/font, and floorOffset 25 (vs 50). The layout is otherwise identical; the effect is pointer/hover-driven so it targets desktop.