Rotating Hand Animation — pinned scroll hero with a spinning clock-hand rod
Goal
Build a full-page scroll-driven hero: the section pins for 8 viewport heights while a thin vertical rod (like a clock hand) spins 5 full turns (1800°) around the center of the screen, driven directly by scroll progress. Each completed 360° cycle swaps the intro headline text. On the 4th cycle a portrait photo fades in inside the rod and two paragraphs slide in. In the final stretch the rod grows to full height, scales up 20×, fades out, and a giant brand wordmark is revealed underneath.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) with the ScrollTrigger plugin, plus lenis for smooth scrolling. No other libraries.
Layout / HTML
<section class="sticky">— the pinned hero. Inside it, three absolutely-positioned layers:<div class="hand-container">containing<div class="hand">which contains a single<img>(the portrait).<div class="intro">containing<h1><span>time to</span> be brave</h1>followed by two<p>elements of placeholder lorem-ipsum copy (2–3 lines each).<div class="website-content">containing<h1>harrnish</h1>(the brand wordmark).<section class="about">after it — a simple white section with a centered paragraph like "(Your next section goes here)" so there is somewhere to scroll to after the pin releases.- Module script tag at the end of
<body>.
Styling
- Global reset (
* { margin:0; padding:0; box-sizing:border-box }), antialiased font smoothing. html, body:width: 100vw; height: 1000vh(tall page), font-family "PP Neue Montreal", sans-serif fallback, color#fff.- Every
section:width: 100vw; height: 100vh. .sticky: background#161616(near-black)..about: white background (#ffffff), flex-centered content, itspin black.h1:font-size: 30px; font-weight: 500; letter-spacing: -0.01em. Theh1 spanis muted grey#6e6e6e(so "time to" is grey and the rest is white).p:font-size: 16px; font-weight: 500; color: #555555; text-align: justify; line-height: 130%.img:width: 100%; height: 100%; object-fit: cover; opacity: 0(hidden by default — revealed later by GSAP)..hand-container:position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%),width: 800px; height: 800px,display: flex; justify-content: center; align-items: flex-start(the rod hangs from the top edge of the container so rotating the container swings it like a clock hand),transform-origin: center center; transform-style: preserve-3d; will-change: transform; z-index: 2..hand:position: absolute; width: 5.5%; height: 52.75%(a thin rod slightly longer than the container's radius),background-color: rgb(238, 238, 238)(off-white),border-radius: 1000px(fully pill-shaped),overflow: hidden; will-change: transform; opacity: 1..intro:position: absolute; top: calc(50% - 20px); left: 25%; width: 22.5%..intro p:position: relative; margin-top: 0.75em; transform: translateX(20px); opacity: 0(hidden, offset 20px right — GSAP slides them in)..website-content:position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; opacity: 0. Itsh1is huge:font-size: 10vw; letter-spacing: -0.03em.- Include the standard Lenis helper CSS (
.lenis.lenis-smooth { scroll-behavior: auto !important; },[data-lenis-prevent] { overscroll-behavior: contain; },.lenis.lenis-stopped { overflow: hidden; },.lenis.lenis-smooth iframe { pointer-events: none; }). - Media query
max-width: 900px:.intro { width: 35%; left: 2em; },h1 { font-size: 18px; },p { font-size: 13px; }.
GSAP effect (be exact)
Register ScrollTrigger. Everything runs inside a DOMContentLoaded listener.
Lenis setup: const lenis = new Lenis(); lenis.on("scroll", ScrollTrigger.update); drive it from GSAP's ticker: gsap.ticker.add((time) => lenis.raf(time * 1000)); gsap.ticker.lagSmoothing(0).
Single ScrollTrigger (no timeline, no scrub property — all animation is computed manually in onUpdate from self.progress):
trigger: .sticky,start: "top top",end: "+=" + (window.innerHeight * 8)(pinnedHeight = 8 viewport heights),pin: true,pinSpacing: true.
Headline cycling state: an array of 5 headline HTML strings (each keeps the grey <span> prefix):
<span>time to</span> be brave<span>time to</span> be playful<span>time to</span> design the future<span>time to</span> meet harrnish<span>time to</span> see project one
Track currentCycle (init -1) and imageRevealed (init false). An updateHeaderText() helper sets h1Element.innerHTML = introHeaders[Math.min(currentCycle, introHeaders.length - 1)] and re-queries the span reference (it is replaced by innerHTML). Call updateHeaderText() once on load after creating the ScrollTrigger so the first headline renders.
Inside onUpdate(self), with progress = self.progress (0→1 across the 8-viewport pin):
- Rotation (occupies the first 5/8 of the pin):
rotationProgress = Math.min((progress * 8) / 5, 1)— rotation completes at progress 5/8 and clamps at 1 after.totalRotation = rotationProgress * 1800 - 90— 5 full turns (1800°), starting at −90° (rod pointing horizontally).rotationInCycle = ((totalRotation + 90) % 360) - 90— wraps to the current cycle's angle.gsap.set(handContainer, { rotationZ: rotationInCycle })— set directly every frame (scrubbed by scroll, no easing).
- Cycle detection / headline swap:
newCycle = Math.floor((totalRotation + 90) / 360). WhennewCycle !== currentCycleand0 <= newCycle < 5, store it and callupdateHeaderText(). Additionally: - When entering cycle 3 (and
!imageRevealed):gsap.to(handImage, { opacity: 1, duration: 0.3 })andgsap.to(introCopy, { x: 0, opacity: 1, duration: 0.5, stagger: 0.1 })(the two paragraphs slide from x:20 to 0). SetimageRevealed = true. - When leaving cycle 3 (any other cycle while
imageRevealed): reverse it —gsap.to(handImage, { opacity: 0, duration: 0.3 }),gsap.to(introCopy, { x: 20, opacity: 0, duration: 0.5, stagger: 0.1 }),imageRevealed = false. - These four tweens are the only time-based tweens; everything else is
gsap.setscrubbed from progress.
- Rod stretch + headline fade (progress 5/8 → 6/8): while
progress <= 6/8: animationProgress = Math.max(0, (progress - 5/8) / (1/8)).- Height:
gsap.utils.interpolate(52.75, 100, animationProgress)→gsap.set(hand, { height: newHeight + "%" })(rod grows to full container height). gsap.set(intro, { opacity: 1 })and fade the h1 and its span:gsap.utils.interpolate(1, 0, animationProgress)applied viagsap.setto bothh1Elementandh1Span.- Else (progress > 6/8):
gsap.set(intro, { opacity: 0 }).
- Rod scale-up (progress 6/8 → 7/8): while
progress <= 7/8:scaleProgress = Math.max(0, (progress - 6/8) / (1/8)),newScale = gsap.utils.interpolate(1, 20, scaleProgress),gsap.set(hand, { scale: newScale })— the rod blows up to 20× and floods the screen.
- Rod fade-out (progress 7/8 → 7.5/8): while
progress <= 7.5/8:opacityProgress = Math.max(0, (progress - 7/8) / (0.5/8)), opacitygsap.utils.interpolate(1, 0, opacityProgress)set on.hand.
- Wordmark reveal (progress 7.5/8 → 1): when
progress > 7.5/8:revealProgress = (progress - 7.5/8) / (0.5/8),gsap.set(".website-content", { opacity: gsap.utils.interpolate(0, 1, revealProgress) }). Otherwise keep.website-contentatopacity: 0.
All the gsap.set phases are fully reversible by scrolling back up because every value derives from progress.
Assets / images
- 1 image: a black-and-white portrait photograph of a person, roughly portrait/square aspect. It sits inside the thin rod (
object-fit: cover, mostly cropped by the rod's pill shape) and only becomes visible during the 4th rotation cycle.
Behavior notes
- The effect is scroll-scrubbed and reversible; there is no autoplay.
- The page is intentionally very tall (
height: 1000vhon body plus pin spacing) so the pin has room. - Works on mobile (media query shrinks type and repositions the intro), no WebGL/canvas.
- The rod starts pointing horizontally (−90°) and sweeps clockwise like a clock hand as the user scrolls.