Inline Spot Hover Cards — Dots in a Headline that Open into Pointer-Tilting Glass Cards
Goal
Build a centred, heavy condensed headline (six lines) where three of the lines contain an inline square dot between two words. Each dot is a folded photo card: on hover it grows from 0.4em to an 18×14rem card, its photo fades in while sharpening from a blur, an iridescent glass edge lights up, and a per-frame loop makes the card drift toward the pointer (clamped to a 25px radius) and tilt on both axes (clamped to ±20°) while the photo inside moves the opposite way, like looking through a lens. Leaving shrinks it back to a dot that gently "breathes". Desktop only (≥1000px). GSAP core.
Tech
Vanilla HTML/CSS/JS with ES module imports. gsap (npm), no plugins. Font: Barlow Condensed 900.
Layout / HTML
<div class="is">
<section class="is-stage">
<h2 class="is-headline">
<span class="is-line">We frame the</span>
<span class="is-line">
<span class="is-word">Worlds</span>
<span class="is-spot"><span class="is-spot__card"><img src="…" alt="" /></span></span>
<span class="is-word">reality</span>
</span>
<span class="is-line">Was too</span>
<span class="is-line"><span class="is-word">Small</span><span class="is-spot">…</span><span class="is-word">to</span></span>
<span class="is-line">Ever hold</span>
<span class="is-line"><span class="is-word">On</span><span class="is-spot">…</span><span class="is-word">to</span></span>
</h2>
<p class="is-hint">Hover the dots</p>
</section>
</div>
Styling
:root: --is-paper:#eef0ea, --is-ink:#121316, --is-line:rgba(255,255,255,.5), --is-card-w:18rem, --is-card-h:14rem, --is-hue:0deg (register with @property as an <angle>).
.is—min-height:100svh; background:var(--is-paper); color:var(--is-ink); font-family:"Barlow Condensed"..is-stage—min-height:100svh; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2rem; overflow:hidden; padding:2rem 1rem..is-headline—text-transform:uppercase; font-size:clamp(3rem,9vw,8.5rem); font-weight:900; line-height:.82; letter-spacing:-.02em..is-line—display:flex; align-items:center; justify-content:center; white-space:nowrap..is-spot—position:relative; flex-shrink:0; width:.4em; height:.4em; margin:0 .2em; transform:translateY(.05em); perspective:800px; cursor:pointer..is-spot__card—position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:.4em; height:.4em; border-radius:.04em; background:var(--is-ink); overflow:hidden; animation: is-breathe 2.6s ease-in-out infinitewhereis-breathepulses abox-shadow: 0 0 0 0 rgba(18,19,22,.28)→0 0 0 .18em rgba(18,19,22,0). The animation is removed on hover /.is-open..is-spot__card img—position:absolute; inset:0; object-fit:cover; pointer-events:none; transform:scale(1.4); opacity:0; filter:blur(10px)..is-spot__card::before— iridescent 1.5px ring:conic-gradient(from var(--is-hue), rgba(255,255,255,.15), rgba(178,196,255,.85) 80deg, rgba(255,184,226,.7) 160deg, rgba(255,255,255,.15) 220deg, rgba(160,255,228,.8) 300deg, rgba(255,255,255,.15)), masked to the border withmask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask-composite: exclude,opacity:0→1under.is-spot.is-open(0.5s),z-index:2.::after— a glass sheenlinear-gradient(160deg, rgba(255,255,255,.28), transparent 45%), same opacity toggle,z-index:1..is-hint—.8rem, 500,letter-spacing:.16em, uppercase,opacity:.45.@media (max-width:999px):.is-spot { cursor:default; pointer-events:none }, hide the hint.prefers-reduced-motion: no breathing.
GSAP effect (be exact)
Build in mount(config) → destroy().
const DEFAULTS = { tiltMax: 20, driftMax: 25, smoothing: .075, openDuration: .75, closeDuration: .5, cardWidth: 18, cardHeight: 14 };
const OPEN = { width: "18rem", height: "14rem", borderRadius: ".6rem" }; // from cardWidth/cardHeight
const DOT = { width: ".4em", height: ".4em", borderRadius: ".04em" };
const CENTER = { x: 0, y: 0, rotateX: 0, rotateY: 0, xPercent: -50, yPercent: -50 };
- At mount start a slow hue rotation:
gsap.to(root, { "--is-hue": "360deg", duration: 8, ease: "none", repeat: -1 }). - Per spot keep
live = {x,y,tx,ty}andaim = {x,y,tx,ty},hovering = false,frame = null.
Tracking loop (added to gsap.ticker while hovering): each tick live.k += (aim.k − live.k) * smoothing for the four values; gsap.set(card, { x: live.x, y: live.y, rotateX: live.tx, rotateY: live.ty }); gsap.set(image, { x: −live.x, y: −live.y }).
mouseenter (desktop only) — hovering = true, add .is-open, zero live/aim, gsap.set(card, CENTER), gsap.set(image, { x:0, y:0 }), start the loop, then gsap.to(card, { ...OPEN, duration: openDuration, ease: "power3.out", overwrite: "auto" }) and gsap.to(image, { opacity: 1, scale: 1.08, filter: "blur(0px)", duration: openDuration * .8, ease: "power2.out", overwrite: "auto" }).
mousemove — from the spot's centre (cx, cy): dx, dy = client − centre; if hypot > driftMax scale both down to the radius → aim.x/aim.y. Tilt from the card's current bounds: aim.ty = clamp((clientX − cx) / (cardW/2)) * −tiltMax, aim.tx = clamp((clientY − cy) / (cardH/2)) * tiltMax (clamp to ±1).
mouseleave — hovering = false, remove .is-open, zero the tilt aims, remove the ticker callback, gsap.to(card, { ...DOT, x:0, y:0, rotateX:0, rotateY:0, duration: closeDuration, ease: "power3.out", overwrite:"auto", onComplete }) where onComplete (if not re-hovered) does gsap.set(card, { clearProps: "width,height,borderRadius", ...CENTER }) and resets the image; plus gsap.to(image, { opacity: 0, scale: 1.4, filter: "blur(10px)", duration: closeDuration * .5, ease: "power2.in" }).
destroy() — remove listeners and any ticker callbacks, kill the hue tween and card/image tweens, clearProps:"all", remove .is-open and --is-hue.
Assets / images
Three landscape photos (≈3:2, ~600px) with a clear subject in the centre — a forest, a building, a sky — since only the middle of each is visible in the 18×14rem card and the crop shifts a little with the pointer.
Behavior notes
- Everything about the dot's size is in
em, so it scales with the headline; the open card is inrem, so it is the same size on every line. perspectivelives on the.is-spotwrapper; the card rotates inside it.- The hover branch is guarded by
innerWidth >= 1000on enter, move and leave; below that the dots are inert decoration. - Only the prefixed variables are global.
Images
This component ships with 3 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/inline-spot-hover-cards/img1.jpg
https://motionprompts.dev/c/inline-spot-hover-cards/img2.jpg
https://motionprompts.dev/c/inline-spot-hover-cards/img3.jpg
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—--is-paper,--is-ink,--is-line,--is-card-w,--is-card-h,--is-hue. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 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
*, *::before, *::after,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.