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.