All components

Text Hover Image Ring

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

Open live demo ↗ Raw prompt (.md)

What it does

Hovering a highlighted word in the hero paragraph reveals a ring of 11 images that continuously orbits around the cursor. The images fan out on a circle via GSAP: a gsap.ticker loop advances the rotation angle each frame while gsap.to tweens each item's x/y, opacity in and out (0.5s, power1.out) with lerped cursor-following.

How it's built

Categoryhover
Techgsap
Complexitysection
Performance costlight
Mobile-safedesktop-first

text-hover image-reveal cursor-follow circular-orbit rotating-ring gsap editorial interactive

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

Text Hover Image Ring

Goal

Build a full-screen black editorial hero: one large centered agency paragraph in which four keywords are highlighted in a pixel/raster display font. The star effect is a hover reveal: when the cursor enters one of those highlighted words, a ring of 11 image tiles fades in and continuously orbits around the cursor while lerp-following it, and the rest of the paragraph dims to grey so the hovered word stays bright. Move off the word and the ring fades back out. The orbit and follow are entirely GSAP-driven (core gsap + gsap.ticker), no plugins.

Tech

Vanilla HTML/CSS/JS shipped as index.html + styles.css + an ES-module script.js (<script type="module" src="./script.js">). Use only gsap (npm) core — no GSAP plugins, no ScrollTrigger, no SplitText, no Lenis, no Three.js. The motion is: gsap.to tweens on each tile plus a global gsap.ticker.add rAF loop and a hand-rolled lerp. Must run in a fresh Vite + npm project. Import shape:

import gsap from "gsap";

Layout / HTML

<body>
  <div class="hero-copy">
    <p>
      We are a digital agency. From concept to <span>execution</span>, we
      design experiences, <span>products</span> and services that help our
      international <span>clients</span> create emotional and meaningful
      <span>connections</span> between their brand and people.
    </p>
  </div>

  <nav>
    <p>Motionprompts &nbsp;&nbsp;&nbsp;&nbsp; / &nbsp;&nbsp;&nbsp;&nbsp;26092017</p>
    <p>Youtube</p>
  </nav>

  <footer>
    <p>Subscribe</p>
    <p>MotionpromptsPRO</p>
  </footer>

  <div class="container">
    <div class="gallery">
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
    </div>
  </div>

  <script type="module" src="./script.js"></script>
</body>

Key points the JS/CSS depend on:

  • Exactly four <span> inside the single hero <p>, wrapping the words execution, products, clients, connections (in that order). These are the only hover targets.
  • Exactly 11 empty .item divs inside .container > .gallery. JS injects one <img> into each (they start empty).
  • nav (fixed top) and footer (fixed bottom) are corner chrome only, two <p> each. Use the neutral demo copy above — no real brand names.

Styling

Reset / global

  • * { margin:0; padding:0; box-sizing:border-box; }
  • html, body { width:100%; height:100vh; font-family:"Timmons NY", sans-serif; background:#000; } — near-black stage. "Timmons NY" is a tall condensed display face used only by the chrome; any condensed display sans is an acceptable fallback.
  • img { width:100%; height:100%; object-fit:cover; } — tiles center-crop their image.

Corner chromenav, footer { position:fixed; width:100%; display:flex; justify-content:space-between; padding:1em; text-transform:uppercase; font-size:30px; color:#fff; }. nav { top:0; }, footer { bottom:0; }. Two <p> each, pushed to the edges.

Ring stage.container { position:relative; width:100%; height:100%; overflow:hidden; pointer-events:none; } and .gallery { pointer-events:none; }. pointer-events:none on both is essential: the container sits above the hero paragraph in the DOM, so the ring must let mouse events pass through to the <span>s underneath — otherwise the tiles would swallow the hover and the effect could never trigger.

Tiles.item:

pointer-events: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100px;
height: 70px;
background: #b0b0b0;   /* placeholder grey before the image paints */
margin: 10px;
opacity: 0;            /* start invisible; GSAP fades them in on hover */

Every tile is a 100×70px (≈10:7 landscape) rectangle stacked on the container's exact center; its position on the ring comes entirely from GSAP x/y (see below). Starts at opacity:0.

Hero paragraph.hero-copy { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); } (dead-center block). .hero-copy p { font-size:50px; font-family:"FK Grotesk Neue"; color:#fff; text-align:center; letter-spacing:-0.04em; line-height:110%; } — a tight, centered grotesk. Fall back to any clean grotesk sans (e.g. system sans) if unavailable.

Highlighted words.hero-copy p span { cursor:pointer; font-family:"FK Raster Grotesk"; position:relative; z-index:1; display:inline-block; } plus .hero-copy p span:hover { color:#fff; }. The distinctive typographic contrast is the star of the styling: the four keywords render in a pixel/dot-matrix "raster" grotesk display font, visibly coarser than the smooth grotesk of the surrounding sentence. If that exact face isn't available, use any pixel/bitmap or blocky raster display font for the spans so they read as a different, chunkier typeface. position:relative; z-index:1 keeps the hovered word painting above its dimmed siblings.

GSAP effect (exhaustive — this is the whole component)

Wrap everything in window.onload = function () { … }. No plugins, no registerPlugin. The snippets below are a reference implementation, not a mandated copy-paste — you may restructure the code, but treat every literal number, easing, duration, and event target as load-bearing: they define the motion and must be reproduced exactly (radius:300, lerp 0.1, spin += 0.005, tween duration:0.5 / ease:"power1.out", the clientX-800/clientY-450 offsets, and the -item.offsetWidth/2 / -item.offsetHeight/2 recenter).

1. Constants and state
const items = document.querySelectorAll(".item");          // 11 tiles
const container = document.querySelector(".container");
const spans = document.querySelectorAll(".hero-copy span"); // 4 keywords
const numberOfItems = items.length;                         // 11
const angleIncrement = (2 * Math.PI) / numberOfItems;       // ≈ 0.5712 rad (32.7°) between neighbours
const radius = 300;                                         // ring radius in px
let currentAngle = 0;                                       // advances every frame (continuous spin)
let isMouseOverSpan = false;
let targetX = 0, targetY = 0;                               // ring-center target (lerp destination)
let currentX = 0, currentY = 0;                             // ring-center actual (lerped)
2. Inject the 11 images

On load, loop the 11 .item divs and append one <img> to each, sourced from a basePath + "img" + (index + 1) + ".jpg" pattern with alt = "Image " + (index + 1):

const basePath = "/img/";   // ← placeholder: point at YOUR image folder (see note)
items.forEach((item, index) => {
  const img = document.createElement("img");
  img.src = basePath + "img" + (index + 1) + ".jpg";   // img1.jpg … img11.jpg
  img.alt = "Image " + (index + 1);
  item.appendChild(img);
});

Tile *index* uses img{index+1}.jpg, so the 11 tiles map 1:1 to img1img11, in order.

> Image paths are placeholders — you must repoint them. basePath and the img1.jpg…img11.jpg filenames are just a naming convention from the reference repo; there is no /c/text-hover-effect/ folder in a fresh project. Set basePath to wherever your 11 images actually live so they resolve — e.g. a Vite public/ path like /img/, a relative ./assets/, or imported module URLs. If the sources 404 the ring still orbits but every tile shows only the flat #b0b0b0 placeholder grey, so the image-reveal effect looks broken. Supply 11 images (any that match the aspect/mood in *Assets*) named img1.jpg…img11.jpg (or adjust the pattern), and the ring fills with pictures as intended.

3. updateGallery(mouseX, mouseY, show = true) — lerp-follow + place the ring
const updateGallery = (mouseX, mouseY, show = true) => {
  targetX = mouseX - container.getBoundingClientRect().left;
  targetY = mouseY - container.getBoundingClientRect().top;

  currentX += (targetX - currentX) * 0.1;   // lerp factor 0.1 → smooth trailing follow
  currentY += (targetY - currentY) * 0.1;

  items.forEach((item, index) => {
    const angle = currentAngle + index * angleIncrement;
    const x = currentX + radius * Math.cos(angle) - item.offsetWidth / 2;   // −50
    const y = currentY + radius * Math.sin(angle) - item.offsetHeight / 2;  // −35
    gsap.to(item, {
      x: x,
      y: y,
      opacity: show ? 1 : 0,
      duration: 0.5,
      ease: "power1.out",
    });
  });
};

Details that matter:

  • The container is full-bleed, so its getBoundingClientRect().left/top are effectively 0targetX/targetY are basically the raw values passed in.
  • currentX/currentY chase the target with a lerp of 0.1 per call — the ring center trails the cursor with an easy, elastic-feeling lag rather than snapping.
  • Each tile is placed on a circle of radius 300 around (currentX, currentY): x = currentX + 300·cos(angle) − 50, y = currentY + 300·sin(angle) − 35 (the −50 / −35 recenter each 100×70 tile on its point). angle = currentAngle + index·angleIncrement spaces the 11 tiles evenly and rotates them all by the shared, ever-advancing currentAngle.
  • Every tile is tweened with gsap.to, duration:0.5, ease:"power1.out", and its opacity goes to 1 when show is true, 0 when false — that's the fade-in on enter / fade-out on leave.
4. Per-span hover handlers (the trigger)
spans.forEach((span) => {
  span.addEventListener("mouseenter", (e) => {
    isMouseOverSpan = true;
    updateGallery(e.clientX, e.clientY, true);   // ring appears at the cursor
  });
  span.addEventListener("mousemove", (e) => {
    if (isMouseOverSpan) {
      targetX = e.clientX - 800;                 // hard-coded offsets (~half a 1600×900 reference viewport)
      targetY = e.clientY - 450;
    }
  });
  span.addEventListener("mouseleave", () => {
    isMouseOverSpan = false;
    updateGallery(0, 0, false);                  // target (0,0) + opacity 0 → tiles fade out toward top-left
  });
});
  • mouseenter: flip isMouseOverSpan on and place the ring at the raw cursor position with tiles fading to opacity 1.
  • mousemove: while over a span, keep re-aiming the ring's targetX/targetY at clientX − 800, clientY − 450 (reproduce these literal constants — they're the original's fixed reference-viewport offsets, not a calculated center).
  • mouseleave: flip the flag off and call updateGallery(0, 0, false) once — target snaps toward the top-left origin and all tiles tween to opacity:0 over 0.5s.
5. The gsap.ticker loop (continuous spin + follow)
gsap.ticker.add(() => {
  currentAngle += 0.005;                    // advance the shared ring angle every frame
  if (currentAngle > 2 * Math.PI) currentAngle -= 2 * Math.PI;
  if (isMouseOverSpan) {
    updateGallery(targetX, targetY, true);  // re-lerp + reposition every frame while hovering
  }
});
  • currentAngle increases 0.005 rad per frame (~0.3 rad/s at 60fps → one full revolution roughly every ~21s), giving the ring its slow, endless clockwise orbit. It advances every frame regardless of hover, so the ring is at a fresh rotation each time you re-enter a word.
  • Only while isMouseOverSpan does the ticker call updateGallery(targetX, targetY, true) — that's what drives the per-frame lerp toward the target and the 0.5s tweens, so the orbiting ring smoothly chases the cursor. When not hovering, the ticker keeps spinning currentAngle but issues no tweens and the tiles rest invisible (opacity 0).
6. Dim-the-paragraph handler (second listener set)

A separate forEach over the same spans changes the surrounding text color:

document.querySelectorAll(".hero-copy span").forEach((span) => {
  span.addEventListener("mouseenter", () => { span.parentNode.style.color = "#545454"; });
  span.addEventListener("mouseleave", () => { span.parentNode.style.color = "#fff"; });
});

On enter, the parent <p> turns #545454 (mid grey), dimming the whole sentence; because the hovered span has its own :hover { color:#fff; } and z-index:1, only the active keyword stays white — the rest of the paragraph recedes. On leave, the paragraph returns to #fff.

Assets / images

11 orbiting ring images (img1.jpgimg11.jpg), one per tile, all center-cropped to 100×70px (≈10:7 landscape) via object-fit:cover. Use a cohesive but eclectic moody, cinematic, experimental-editorial set — high-contrast and atmospheric. A representative mix (any similar images work — do not use real brand imagery or logos):

  • cinematic / atmospheric scenes: a lone silhouetted figure walking on a reflective surface toward a bright light; an astronaut in a hazy amber landscape beneath a giant pale planet; a close-up of a helmet with a molten-gold reflective visor;
  • moody low-key portraits: a blonde woman against a dark smoky backdrop; a woman with wet hair pushed back, face partly hidden; a dim slot-framed lower face holding a cigarette; a figure with a pale-blue tulle veil over the head; a hand pressed to backlit frosted glass;
  • surreal 3D render: a chrome robotic figure in sunglasses and a mosaic conical hat;
  • still-life / cityscape: a golden lucky-cat figurine in a red circular niche; a hazy orange sunset skyline of high-rise towers.

Because the tiles render small, overall tone and contrast matter far more than resolution. Provide exactly 11 distinct images in this order (tile *index* → img{index+1}.jpg); if you have fewer, repeat to reach 11. These are supplied by you — the prompt does not ship them — so make sure the basePath in the JS points at them (see *GSAP effect §2*), otherwise the ring shows blank grey tiles.

Behavior notes

  • Desktop / pointer only. The entire effect is driven by mouseenter / mousemove / mouseleave on the four keywords — there is no touch handling and no reduced-motion branch, so it is not mobile-safe as authored.
  • Continuous rAF spin. The gsap.ticker loop runs for the life of the page, always advancing currentAngle; it only repositions/fades the tiles while a keyword is hovered.
  • Everything eases, nothing snaps. The ring center lerps toward its target at 0.1 per frame, and each tile tween is duration:0.5, ease:"power1.out", so the ring trails and settles behind the cursor.
  • pointer-events:none on .container and .gallery is load-bearing: the ring sits above the text but must never intercept the hover that triggers it.
  • Wrap the whole script in window.onload (not DOMContentLoaded) so the tiles exist and are measurable (offsetWidth/offsetHeight) before the ring is positioned.