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 / 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
.itemdivs inside.container > .gallery. JS injects one<img>into each (they start empty). nav(fixed top) andfooter(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 chrome — nav, 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 img1…img11, 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/topare effectively0—targetX/targetYare basically the raw values passed in. currentX/currentYchase 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·angleIncrementspaces the 11 tiles evenly and rotates them all by the shared, ever-advancingcurrentAngle. - Every tile is tweened with
gsap.to,duration:0.5,ease:"power1.out", and itsopacitygoes to 1 whenshowis 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: flipisMouseOverSpanon 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'stargetX/targetYatclientX − 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 callupdateGallery(0, 0, false)once — target snaps toward the top-left origin and all tiles tween toopacity:0over 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
}
});
currentAngleincreases 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
isMouseOverSpandoes the ticker callupdateGallery(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 spinningcurrentAnglebut 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.jpg … img11.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/mouseleaveon 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.tickerloop runs for the life of the page, always advancingcurrentAngle; 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:noneon.containerand.galleryis load-bearing: the ring sits above the text but must never intercept the hover that triggers it.- Wrap the whole script in
window.onload(notDOMContentLoaded) so the tiles exist and are measurable (offsetWidth/offsetHeight) before the ring is positioned.
Images
This component ships with 11 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/text-hover-effect/img1.jpg
https://motionprompts.dev/c/text-hover-effect/img10.jpg
https://motionprompts.dev/c/text-hover-effect/img11.jpg
https://motionprompts.dev/c/text-hover-effect/img2.jpg
https://motionprompts.dev/c/text-hover-effect/img3.jpg
https://motionprompts.dev/c/text-hover-effect/img4.jpg
… 5 more under https://motionprompts.dev/c/text-hover-effect/
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—--bg,--ink,--muted,--amber,--surface. 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
*,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.
Adapting this to React
Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelectorAll, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that runs never leaves you two of everything: two gsap.ticker subscriptions both advancing the shared currentAngle, and two sets of mouseenter/mousemove/mouseleave listeners on the same four spans, each pair racing to lerp the same 11 tiles toward two different targets. The visible symptom is a ring that spins twice as fast and jitters between two centers, and it will not reproduce in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.
*(1) The entry point* — the script waits for the window load event, which is after images and fonts have settled. useEffect runs earlier than that, at commit. Here that gap does not actually matter: every size this component reads — item.offsetWidth/offsetHeight inside updateGallery, container.getBoundingClientRect() — comes from CSS that fixes the box directly (.item is a hard-coded 100×70 rectangle, .container is full-bleed), not from the intrinsic dimensions of the 11 <img> tags. Nothing here needs the 11 images to have decoded first. Drop window.onload entirely and put the body straight inside a useEffect with an empty dependency array — there is no measurement in this component that justifies keeping an explicit wait.
*(2) Element lookups* — give the component a root ref on the wrapper that contains .hero-copy, .container and the four spans, and scope every lookup to it: the 11 .item nodes, the .container node, the four .hero-copy span nodes. The script currently walks document.querySelectorAll(".hero-copy span") twice, in two independent forEach blocks — once to wire the ring trigger (mouseenter/mousemove/mouseleave), once more to wire the paragraph-dimming color swap (mouseenter/mouseleave on span.parentNode). Collapse that into a single pass over the four span refs that attaches both listener groups per span, so cleanup has one list to walk instead of two independently-scoped ones that happen to target the same elements.
Also move the image injection out of the imperative script: items.forEach here builds an <img> with createElement/appendChild on every run, which is DOM-node creation, not element lookup. Render the 11 <img src={basePath + "img" + (i + 1) + ".jpg"}> tags directly inside their .item boxes in JSX, keyed by index. That removes a StrictMode failure mode for free: the imperative version, called twice, would append a second <img> into each .item on the remount; JSX replaces instead of accumulating.
The four values the ticker mutates every frame — currentAngle, currentX/currentY, targetX/targetY, isMouseOverSpan — belong in refs, not component state. They are written inside the gsap.ticker callback up to sixty times a second and read from mousemove, and none of them ever needs to appear in JSX. Routing them through useState would re-render the component on every tick for values the render output never uses; a plain useRef per value (or one ref holding all of them) gives the callbacks a stable place to read and write without fighting React's render cycle.
*(3) Cleanup* — wrap the animation in a gsap.context scoped to the root ref:
useEffect(() => {
const spans = rootRef.current.querySelectorAll(".hero-copy span"); // the 4 keywords
const spanHandlers = [];
const ticker = { fn: null };
const ctx = gsap.context((self) => {
// NAMED registration: `updateGallery`'s gsap.to calls never run during this
// synchronous pass — they run later, from mouseenter/mousemove and from the
// ticker callback below. Naming them here is what makes those later tweens
// count as context members; a bare closure over `gsap.to` would not.
self.add("updateGallery", (mouseX, mouseY, show = true) => {
/* the body of updateGallery exactly as described above */
});
spans.forEach((span) => {
const onEnter = (e) => { /* set isMouseOverSpan, call ctx.updateGallery(...) */ };
const onMove = (e) => { /* update the target refs while isMouseOverSpan is true */ };
const onLeave = () => { /* clear isMouseOverSpan, call ctx.updateGallery(0, 0, false) */ };
const onDimEnter = () => { /* dim the parent paragraph */ };
const onDimLeave = () => { /* restore the parent paragraph */ };
span.addEventListener("mouseenter", onEnter);
span.addEventListener("mousemove", onMove);
span.addEventListener("mouseleave", onLeave);
span.addEventListener("mouseenter", onDimEnter);
span.addEventListener("mouseleave", onDimLeave);
spanHandlers.push({ span, onEnter, onMove, onLeave, onDimEnter, onDimLeave });
});
ticker.fn = () => {
/* advance currentAngle, and while isMouseOverSpan call ctx.updateGallery(...) */
};
gsap.ticker.add(ticker.fn);
}, rootRef);
return () => {
gsap.ticker.remove(ticker.fn);
spanHandlers.forEach(({ span, onEnter, onMove, onLeave, onDimEnter, onDimLeave }) => {
span.removeEventListener("mouseenter", onEnter);
span.removeEventListener("mousemove", onMove);
span.removeEventListener("mouseleave", onLeave);
span.removeEventListener("mouseenter", onDimEnter);
span.removeEventListener("mouseleave", onDimLeave);
});
ctx.revert();
};
}, []);
Inside the factory the parameter is self; ctx is not assigned yet at that point in the synchronous pass, and naming it there throws Cannot access 'ctx' before initialization. updateGallery is registered with the name-plus-function form of self.add specifically so it can be called back later, from listeners and from the ticker, as ctx.updateGallery(...) — the single-argument form would run it once, immediately, with the GSAP context object in place of mouseX.
Two things ctx.revert() will not do for you here:
- The ticker keeps the ring spinning after revert.
gsap.ticker.addis not a tween or a trigger, so the context never sees it; onlygsap.ticker.remove(ticker.fn), called with the exact function reference that was added, actually stops the per-frame advance ofcurrentAngle. Skip it and a remount leaves a second orbit permanently superimposed on the first, both writingx/yonto the same 11 tiles every frame. - Ten
addEventListenercalls per remount (five per span, times two remaining spans' worth of duplication) are plain DOM subscriptions, not GSAP instances.ctx.revert()undoes the tweens and the inline styles GSAP wrote on the tiles, but it has no knowledge of the ring-trigger triplet or the dimming pair attached to each span. Remove all five per span by the same function reference that was attached, or a remounted instance leaves a second dimming handler racing the first to set the paragraph's color on every hover.