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
.servicerows, each with the same internal structure: a.service-name > h1(the giant word) plus a.service-imageswrapper holding three.imgboxes, each with one<img>. - Each row carries a
data-tagsattribute: 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-containerand each.tagelement are created at runtime by the JS (not authored in HTML), but the.tagCSS 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#171717fill.
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
- Call
cleanupTags()first (idempotent reset). - Read
serviceWidth = service.offsetWidth,serviceHeight = service.offsetHeight(the *expanded* size, since this runs after the row has grown). - Create a
div.tags-containerand append it to the row. engine = Engine.create({ gravity: { x: 0, y: 2 } })— gravity points down at 2× the Matter default, so tags fall briskly.- Build three static boundary bodies with
wallThickness = 20andfloorOffset = window.innerWidth < 1000 ? 25 : 50: - Floor:
Bodies.rectangle(serviceWidth/2, serviceHeight - floorOffset + wallThickness/2, serviceWidth*3, wallThickness, { isStatic:true })— a wide bar setfloorOffsetpx above the row's bottom edge, so the pile settles a little inset from the bottom. - Left wall:
Bodies.rectangle(-wallThickness/2, serviceHeight/2, wallThickness, serviceHeight*3, { isStatic:true }). - Right wall:
Bodies.rectangle(serviceWidth+wallThickness/2, serviceHeight/2, wallThickness, serviceHeight*3, { isStatic:true }). World.add(engine.world, [floor, leftWall, rightWall]).- For each label
i: - Create a
div.tag, settextContent = label, append to the container. tagWidth = tagSizes[i].width,tagHeight = tagSizes[i].height.- 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). angle = (Math.random() - 0.5) * 0.4(a small random tilt, ±0.2 rad).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).Body.setAngle(body, angle), thenWorld.add(engine.world, body).- 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. - Push into
tagElements/tagBodies. - Start a rAF loop
update(): each frame callEngine.update(engine, 1000/60)(fixed ~16.67ms step), then for every tag writetagElement.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). StorerafId = 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 height →
gsap.to(service, { height: expandedHeight, duration:0.75, ease:"elastic.out(1,0.5)" })— the springy over-shoot open. - Title color →
gsap.to(serviceName, { color:"#FFFFD9", duration:0.25, ease:"power4.out" })(red → cream, fast). - Image stack →
gsap.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). Thestagger:0.075makes the three images arrive one after another with the same elastic spring. - Delayed tag drop →
tagDropTimer = 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 callcleanupTags()directly (covers leaving before the 0.2s drop fired). - Title color →
gsap.to(serviceName, { color:"#ff3831", duration:0.25, ease:"power4.out" })(cream → red). - Image stack →
gsap.to(serviceImages, { y:"50%", duration:0.75, ease:"elastic.out(1,0.5)", stagger:0.075 })(slide back down behind the title). - Row height →
gsap.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
delayedCallguarded byif (isHovered), andmouseleavekills 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/ CSSmax-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, andfloorOffset25 (vs 50). The layout is otherwise identical; the effect is pointer/hover-driven so it targets desktop.