Radial Rotating Text Layout
Goal
Build a full-screen black editorial page where 60 uppercase text labels are arranged around one enormous circle (a ring far bigger than the viewport, so only a curved slice of it is ever visible). Each label is rotated to sit tangent to the circle. Scrolling an extremely tall page slowly rotates the entire ring with a springy elastic follow. A custom trailing cursor box (a 3:4 portrait frame) eases behind the real pointer, and hovering any label reveals that label's preview image inside the cursor box via a bottom-to-top clip-path polygon wipe; moving off wipes it back out through the top. The star effect is the scroll-driven radial rotation combined with the clip-path image reveal that lives inside the trailing cursor.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) — core only, no plugins. There is no ScrollTrigger, no SplitText, no CustomEase, no Lenis, no Three.js. The scroll rotation is driven by a plain native document scroll listener that feeds window.scrollY into a gsap.to. All logic runs inside a DOMContentLoaded handler. Import as import gsap from "gsap";.
Layout / HTML
<body>
<div class="cursor"></div>
<nav>
<a href="#">Motionprompts <span>/</span> 16 06 2024</a>
<p>Unlock Source Code with PRO</p>
</nav>
<footer>
<div class="links">
<a href="#">Subscribe</a>
<a href="#">Instagram</a>
<a href="#">Twitter</a>
</div>
<p>Link in description</p>
</footer>
<div class="container">
<div class="gallery"></div>
</div>
<script type="module" src="./script.js"></script>
</body>
.cursoris the trailing preview frame — empty at start; hover-preview<img>elements are created and appended into it at runtime..gallerystarts empty; the 60.itemlabels are generated by JS and appended into it.nav/footerare fixed chrome (neutral demo copy — no real brand names).
Styling
Global reset: * { margin:0; padding:0; box-sizing:border-box; }.
html, body { width:100%; height:3000vh; background:#000; font-family:"Circular Std", sans-serif; }— the 3000vh height is essential: it makes the page ~30 viewports tall so there is a long scroll range to rotate the ring. Any clean geometric sans fallback is fine.a, p { text-decoration:none; color:#fff; font-size:14px; }.a span { padding:0 2em; }.nav, footer:position:fixed; width:100%; padding:2em; display:flex; justify-content:space-between; align-items:center; z-index:2; mix-blend-mode:difference;.nav { top:0; }footer { bottom:0; }..links { display:flex; gap:2em; }. (mix-blend-mode:difference so the chrome inverts against whatever passes behind it.).cursor { position:fixed; top:0; left:0; width:300px; height:400px; z-index:0; pointer-events:none; }— a 300×400 (3:4 portrait) box, pinned top-left and moved via GSAP transforms.z-index:0is deliberate: it sits behind the gallery text (which hasz-index:autoand comes later in the DOM), so the revealed preview image renders *behind* the rotating labels..cursor img { position:absolute; top:0; left:0; width:100%; height:100%; object-fit:cover; }(the globalimg { … }rule) — each preview fills the whole 300×400 frame..gallery { position:fixed; width:200%; height:100%; left:-75%; overflow:hidden; }— an oversized fixed stage whose origin (its top-left corner, offset toleft:-75%) is the coordinate origin for the items'x/ytransforms;overflow:hiddenclips the ring's off-screen arc..item { position:absolute; top:0; left:0; transform:translate(-50%,-50%); width:800px; height:80px; cursor:pointer; }— each label box is 800×80, centered on its own anchor point viatranslate(-50%,-50%)before GSAP adds the ringx/y/rotation..item p { width:100%; font-size:42px; font-weight:500; text-transform:uppercase; color:#fff; }..item p span { padding:0 20px; font-size:16px; }— the small parenthetical count that trails each label.
The effect (be exhaustive — reproduce every constant)
Everything below runs inside document.addEventListener("DOMContentLoaded", …). Grab const cursor = document.querySelector(".cursor") and const gallery = document.querySelector(".gallery").
Ring constants
const numberOfItems = 60;
const radius = 1100; // px — far larger than the viewport
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
const angleIncrement = (2 * Math.PI) / numberOfItems; // 60 evenly spaced slots
Build the 60 labels (creation loop for i = 0 … 59)
For each i:
- Create
<div class="item">, a child<p>, and a child<span>. p.textContent = names[i](see Content), thencount.textContent = "(" + (Math.floor(Math.random()*50)+1) + ")"→ a random integer 1–50. Append order:item.appendChild(p); gallery.appendChild(item); p.appendChild(count);so the markup is<p>NAME<span>(N)</span></p>.- Compute the item's slot on the ring:
``js const angle = i * angleIncrement; const x = centerX + radius * Math.cos(angle); const y = centerY + radius * Math.sin(angle); const rotation = (angle * 180) / Math.PI; // radians → degrees ``
- Place it instantly:
gsap.set(item, { x: x + "px", y: y + "px", rotation: rotation });
→ labels fan out evenly around the full circle, each rotated to lie tangent to the ring (item 0 at 3-o'clock horizontal, rotation growing clockwise).
- In the same loop, wire this item's
mouseover/mouseout(see Hover reveal), capturingifor its image src.
Scroll rotation — updatePosition() (the signature effect)
A single function, called once on load and on every document scroll event:
function updatePosition() {
const scrollAmount = window.scrollY * 0.0001; // tiny multiplier
document.querySelectorAll(".item").forEach((item, index) => {
const angle = index * angleIncrement + scrollAmount; // scroll offsets EVERY item's angle
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
const rotation = (angle * 180) / Math.PI;
gsap.to(item, {
duration: 0.05,
x: x + "px",
y: y + "px",
rotation: rotation,
ease: "elastic.out(1, 0.3)",
});
});
}
updatePosition();
document.addEventListener("scroll", updatePosition);
- The
0.0001multiplier is intentionally minuscule: because the page is 30 viewports tall, scrolling top→bottom adds only a couple of radians, so the whole ring rotates slowly as you scroll. Every label's angle gets the same offset, so the entire ring turns as one rigid wheel. - Each per-item tween is
duration: 0.05withease: "elastic.out(1, 0.3)"— a short springy settle. Rapid scroll events fire many overlapping tweens that GSAP overwrites, giving a slightly elastic, jittery-alive follow rather than a rigid lock.
Custom cursor follow — mousemove on document
document.addEventListener("mousemove", (e) => {
gsap.to(cursor, {
x: e.clientX - 150, // 150 = half of 300 width
y: e.clientY - 200, // 200 = half of 400 height
duration: 1,
ease: "power3.out",
});
});
- The
-150 / -200offsets center the 300×400 box on the pointer. duration: 1,ease: "power3.out"→ the box lags and trails the real cursor, easing into place over ~1s. This lag is what makes it read as a floating preview panel chasing the mouse.
Hover reveal — mouseover on each item (bottom-to-top clip-path wipe)
item.addEventListener("mouseover", () => {
const img = document.createElement("img");
img.src = `/…/img${i + 1}.jpg`; // per-item preview (i is 0-based → img1…img60)
img.style.clipPath = "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)"; // all 4 corners at the BOTTOM → zero-height sliver → invisible
cursor.appendChild(img);
gsap.to(img, {
clipPath: "polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)", // full rectangle → top edge rises up
duration: 1,
ease: "power3.out",
});
});
- Start state = a degenerate polygon with all points on the bottom edge (y=100%), so the image is clipped to nothing. The tween drives the two top points up to
y=0%, so the image wipes into view from the bottom edge upward over 1s (power3.out). - The image is appended into
.cursor, so it appears inside the trailing box and, because.cursorisz-index:0, it renders behind the rotating text.
Hover out — mouseout on each item (wipe up and out)
item.addEventListener("mouseout", () => {
const imgs = cursor.getElementsByTagName("img");
if (imgs.length) {
const lastImg = imgs[imgs.length - 1];
Array.from(imgs).forEach((img) => {
if (img !== lastImg) {
gsap.to(img, {
clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)", // all corners at the TOP → collapse upward
duration: 1,
delay: 0.5,
ease: "power3.out",
onComplete: () => setTimeout(() => img.remove(), 1000), // DOM cleanup 1s after the tween ends
});
}
});
gsap.to(lastImg, {
clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
duration: 1,
ease: "power3.out",
delay: 0.25,
});
}
});
- Exit target = all points on the top edge (y=0%), so from the full rectangle the bottom edge rises to meet the top → the image continues wiping upward off the top. Same
power3.out,duration: 1. - Cleanup quirk to reproduce faithfully: on each
mouseout, every<img>currently in the cursor wipes out. The most recently added image (lastImg) getsdelay: 0.25and is not removed from the DOM (it lingers, fully clipped/invisible). All other images getdelay: 0.5and are removed 1s after their tween completes. So rapidly sweeping across many labels stacks several clipped-away images in the cursor that get progressively cleaned up on subsequent mouseouts.
Content
60 short (2–4 word) fictional space-interior names, rendered uppercase. They are neutral invented copy — do not use real brands. Reproduce this list in order (item 0 → "Lunar Horizon Lounge", …):
Lunar Horizon Lounge, Martian Red Quarters, Orbit Oasis Chamber, Neon Nexus Home,
Quantum Quiet Quarters, Galactic Gateway Studio, Starlight Sky Suite, Void Vector Villa,
Cosmic Cove Nook, Satellite Space Site, Plasma Peak Penthouse, Asteroid Alley Loft,
Celestial City Condo, Pulsar Point Pavilion, Gravity Garden Suite, Interstellar Ivy Inn,
Nebula Nest Nook, Exoplanet Escape Estate, Meteorite Mansion Den, Black Hole Bungalow,
Warp World Workshop, Photon Particle Pod, Dark Matter Den, Event Horizon Home,
Solar Flare Studio, Quantum Leap Lounge, Supernova Sun Suite, Eclipse Edge Enclave,
Galaxy Garden Gazebo, Time Traveler Terrace, Orbital Observatory Outpost, Gravity Grove Grotto,
Cosmos Cottage Core, Space-Time Spiral Studio, Alien Array Atrium, Dimensional Dome Dwelling,
Vortex Valley Villa, Starship Station Studio, Quantum Quasar Quarters, Planetary Plaza Penthouse,
Rocket Range Room, Spectrum Spire Space, Terraforming Tower Terrace, Universe Utopia Unit,
Void Vista View, Wormhole Wall Window, Xenon Xeriscape Xanadu, Yield Yacht Yard,
Zenith Zone Zephyr, Alpha Aurora Atrium, Beta Bridge Bastion, Gamma Garden Gateway,
Delta Dome Den, Epsilon Echo Estate, Zeta Zenith Zone, Eta Echo Enclave,
Theta Theater Thicket, Iota Island Inn, Kappa Keep Kiosk, Lambda Loft Lounge
Any set of 60 short uppercase labels works; the sci-fi naming just sets the editorial tone.
Assets / images
60 preview files img1.jpg … img60.jpg, one per label, each shown inside the 300×400 (3:4 portrait) cursor frame and cropped with object-fit:cover. There are only 15 unique images cycled every 15 (file N and file N+15 share content), so you really only need 15 source images repeated. The set is a cohesive experimental / futuristic / editorial collection of moody abstract 3D renders and graphic frames on a mostly dark or soft-neutral palette — e.g.:
- greyscale figures whose skin is a swirling topographic contour texture, on a soft grey gradient;
- a caped silhouette against saturated red;
- a molten-lava mountain half-submerged in water (glowing orange peak, dark base);
- a glossy orange sphere orbited by clusters of matte grey spheres on black;
- a scan-line-glitched blue/violet orb on a cyan field;
- an extreme close-up of a large smooth orange sphere fading into black;
- a bulbous metaball form studded with glossy black chrome bubbles on light grey;
- a minimal poster of a red semicircle sun over a blue horizon with vertical motion-blur streaks;
- a warm-orange long-exposure of dancers in heavy motion blur;
- a smeared, melting monochrome 3D head;
- stacked glossy copper fins arcing around a glowing amber core;
- an iridescent liquid-glass swirl with orange/cyan chromatic aberration on a pale ground;
- a monochrome bust covered in topographic line texture, hand on chin;
- a grainy monochrome gradient sphere on a pale ground;
- two dark glossy stone spheres nearly touching on white.
Any ~15 abstract, high-contrast experimental images at ~3:4 work. Do not use real brand imagery.
Behavior notes
- Desktop / pointer experience. The custom cursor and hover reveal require a mouse; there is no touch fallback and no reduced-motion branch in the original. Treat as desktop-only.
- The whole page is one giant fixed stage; there is no visible scrollbar content — the 3000vh height exists purely to give
window.scrollYa long range to rotate the ring. Scroll up/down = spin the wheel of text. updatePositionruns once immediately (atscrollY ≈ 0) so the ring is placed even before any scroll; it re-runs on every scroll event.- No console errors on load. The fixed
nav/footersit above everything (z-index:2,mix-blend-mode:difference); the trailing preview sits below the text (z-index:0), giving the layered look where labels overlay their own revealed image.
Images
This component ships with 60 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/radial-rotating-texts/img1.jpg
https://motionprompts.dev/c/radial-rotating-texts/img10.jpg
https://motionprompts.dev/c/radial-rotating-texts/img11.jpg
https://motionprompts.dev/c/radial-rotating-texts/img12.jpg
https://motionprompts.dev/c/radial-rotating-texts/img13.jpg
https://motionprompts.dev/c/radial-rotating-texts/img14.jpg
… 54 more under https://motionprompts.dev/c/radial-rotating-texts/
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—--ink,--ink-soft,--star,--gold,--slate,--hairline. 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. - Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.
Adapting this to React
Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, 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 full rings of labels stacked on the same .gallery, two scroll listeners each nudging the wheel forward. The visible symptom is a doubled, overlapping ring or a wheel that spins twice as fast as it should, 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 DOMContentLoaded. By the time a React component mounts, that event has already fired, so the listener is never called and the effect never runs — no ring, no cursor, nothing to debug. Delete the listener and put its body — the 60-item build loop, updatePosition, and the two document-level listeners — directly inside a useEffect with an empty dependency array.
*(2) Element lookups* — document.querySelector(".cursor") and document.querySelector(".gallery") assume this component owns the document; resolve both from a root ref instead. The bigger offender is inside updatePosition: it calls document.querySelectorAll(".item") fresh on every scroll event — a full-document query firing many times a second while the user scrolls, and, on the StrictMode remount, one with no way to tell the outgoing ring's 60 nodes from the incoming ring's. The build loop already holds a reference to each item the instant it creates it; push each one into an array as you build it, and have updatePosition iterate that array. It never has to touch the DOM tree to find its targets.
*(3) Cleanup* — This component's tweens fall into two groups the context treats differently, and getting that distinction wrong is the whole risk here. gsap.context only auto-tracks animations created during the synchronous run of its factory — the 60 gsap.set calls in the build loop qualify, because that loop runs once, inline, while the factory executes. The gsap.to calls inside updatePosition do not: updatePosition is invoked later, from a native scroll listener, and a gsap.to fired from an event callback is invisible to the context even though the function itself was defined inside the factory. Register it explicitly instead, through the context's own self.add, and call it back through ctx from outside:
useEffect(() => {
const itemEls = [];
const ctx = gsap.context((self) => {
// build the 60 items exactly as above: gsap.set(item, { x, y, rotation }) each,
// then itemEls.push(item)
self.add("updatePosition", () => {
const scrollAmount = window.scrollY * 0.0001;
itemEls.forEach((item, index) => {
const angle = index * angleIncrement + scrollAmount;
gsap.to(item, {
x: centerX + radius * Math.cos(angle) + "px",
y: centerY + radius * Math.sin(angle) + "px",
rotation: (angle * 180) / Math.PI,
// same short spring-like timing the original updatePosition used
});
});
});
self.updatePosition(); // initial placement, tracked because it runs through self
}, rootRef);
const onScroll = () => ctx.updatePosition();
document.addEventListener("scroll", onScroll);
document.addEventListener("mousemove", handlePointerMove); // cursor-follow tween — same untracked-callback case, harmless here because its only target is the cursor box this component itself unmounts
return () => {
document.removeEventListener("scroll", onScroll);
document.removeEventListener("mousemove", handlePointerMove);
ctx.revert();
galleryRef.current?.replaceChildren();
};
}, []);
Inside the factory the argument is self, never ctx — const ctx = gsap.context(...) has not finished assigning at the point the factory body runs, so writing ctx.add( there throws Cannot access 'ctx' before initialization and takes the whole tree down. self.add("updatePosition", fn) is the two-argument, name-based overload: it registers the method and returns it for later use as ctx.updatePosition(), which is what the scroll listener calls. Do not reach for the one-argument self.add(fn) form here — that runs its callback immediately, during setup, with the context itself as the argument, not a scroll position, which is not what a recurring handler needs.
Two things ctx.revert() does not undo, both present in this component: the scroll and mousemove listeners live on document, entirely outside the context's root, so revert never touches them — remove them by hand against the exact function reference passed to addEventListener, or the leftover scroll listener keeps calling ctx.updatePosition() (and creating new tweens) on a context that has already been reverted. And the 60 .item nodes are plain elements the effect built with document.createElement/appendChild, not JSX — reverting the context undoes the inline transform styles GSAP wrote to them, but it does not remove the nodes from .gallery. If the next mount's build loop runs before the previous mount's items are gone, the ring doubles to 120 overlapping labels stacked on each other; clear .gallery's children in the same cleanup (galleryRef.current?.replaceChildren()), or — better — build the 60 items as a .map() over the catalog data in JSX and let the effect only place and animate the refs it collects, so React itself owns creating and destroying the nodes and this failure mode cannot occur. The per-item mouseover/mouseout handlers and the hover clip-path tweens need no separate teardown under either approach: they live on the item nodes and the images they append into .cursor, and once those nodes are gone — by the explicit clear or by React unmounting the list — the listeners and any still-running tween on them go with them.