All components

Rotating Hand Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

A single ScrollTrigger pins the section over eight viewport heights and scrubs a clock-like rod that spins 1800deg via gsap.set(rotationZ) on each frame; every 360deg cycle swaps the intro headline, at cycle 3 a portrait fades in and the paragraphs stagger in, then the rod stretches to full height, scales up 20x, fades out and reveals the giant brand wordmark.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scroll pin scrub rotation reveal lenis onupdate editorial hero

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

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, its p in black.
  • h1: font-size: 30px; font-weight: 500; letter-spacing: -0.01em. The h1 span is 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. Its h1 is 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):

  1. <span>time to</span> be brave
  2. <span>time to</span> be playful
  3. <span>time to</span> design the future
  4. <span>time to</span> meet harrnish
  5. <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):

  1. Rotation (occupies the first 5/8 of the pin):
  2. rotationProgress = Math.min((progress * 8) / 5, 1) — rotation completes at progress 5/8 and clamps at 1 after.
  3. totalRotation = rotationProgress * 1800 - 90 — 5 full turns (1800°), starting at −90° (rod pointing horizontally).
  4. rotationInCycle = ((totalRotation + 90) % 360) - 90 — wraps to the current cycle's angle.
  5. gsap.set(handContainer, { rotationZ: rotationInCycle }) — set directly every frame (scrubbed by scroll, no easing).
  1. Cycle detection / headline swap: newCycle = Math.floor((totalRotation + 90) / 360). When newCycle !== currentCycle and 0 <= newCycle < 5, store it and call updateHeaderText(). Additionally:
  2. When entering cycle 3 (and !imageRevealed): gsap.to(handImage, { opacity: 1, duration: 0.3 }) and gsap.to(introCopy, { x: 0, opacity: 1, duration: 0.5, stagger: 0.1 }) (the two paragraphs slide from x:20 to 0). Set imageRevealed = true.
  3. 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.
  4. These four tweens are the only time-based tweens; everything else is gsap.set scrubbed from progress.
  1. Rod stretch + headline fade (progress 5/8 → 6/8): while progress <= 6/8:
  2. animationProgress = Math.max(0, (progress - 5/8) / (1/8)).
  3. Height: gsap.utils.interpolate(52.75, 100, animationProgress)gsap.set(hand, { height: newHeight + "%" }) (rod grows to full container height).
  4. gsap.set(intro, { opacity: 1 }) and fade the h1 and its span: gsap.utils.interpolate(1, 0, animationProgress) applied via gsap.set to both h1Element and h1Span.
  5. Else (progress > 6/8): gsap.set(intro, { opacity: 0 }).
  1. 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.
  1. Rod fade-out (progress 7/8 → 7.5/8): while progress <= 7.5/8: opacityProgress = Math.max(0, (progress - 7/8) / (0.5/8)), opacity gsap.utils.interpolate(1, 0, opacityProgress) set on .hand.
  1. 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-content at opacity: 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: 1000vh on 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.