All components

Inline Spot Hover Cards

GSAP animation component · Published 2026-09-05 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Headline with three inline dots that open into image cards on hover: the dot grows to a fixed card size while the photo fades and sharpens in; a GSAP ticker loop then lerps the card toward the pointer (drift clamped to a radius, tilt clamped to an angle) and moves the photo the opposite way, as if seen through glass, with an iridescent edge on the open card. Leaving collapses it back to a breathing dot. Desktop only.

How it's built

Categoryhover
Techgsap
Complexitywidget
Performance costlight
Mobile-safedesktop-first

hover headline inline 3d-tilt lerp glass iridescent cards

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

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

  • .ismin-height:100svh; background:var(--is-paper); color:var(--is-ink); font-family:"Barlow Condensed". .is-stagemin-height:100svh; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2rem; overflow:hidden; padding:2rem 1rem.
  • .is-headlinetext-transform:uppercase; font-size:clamp(3rem,9vw,8.5rem); font-weight:900; line-height:.82; letter-spacing:-.02em. .is-linedisplay:flex; align-items:center; justify-content:center; white-space:nowrap.
  • .is-spotposition:relative; flex-shrink:0; width:.4em; height:.4em; margin:0 .2em; transform:translateY(.05em); perspective:800px; cursor:pointer.
  • .is-spot__cardposition: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 infinite where is-breathe pulses a box-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 imgposition: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 with mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask-composite: exclude, opacity:01 under .is-spot.is-open (0.5s), z-index:2. ::after — a glass sheen linear-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} and aim = {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).

mouseleavehovering = 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 in rem, so it is the same size on every line.
  • perspective lives on the .is-spot wrapper; the card rotates inside it.
  • The hover branch is guarded by innerWidth >= 1000 on 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: --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 *, *::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.