All components

TrueKind Landing Page Reveal

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

Open live demo ↗ Raw prompt (.md)

What it does

A preloader-to-hero landing reveal driven by a single GSAP timeline with a custom 'hop' ease: a two-digit counter rolls through milestone numbers (00, 27, 65, 98, 99) with a staggered vertical roll, the 'KindRoot' logo words slide together, a vertical divider scales up and fades, then two dark blocks wipe upward via clip-path while the background hero image scales from 1.5 to 1 and the nav, masked headline lines, paragraph and CTA pill slide and scale into place. Plays automatically once on page load.

How it's built

Categorypreloader
Techgsap
GSAP pluginsCustomEase
Complexitypage
Performance costlight
Mobile-safeyes

preloader reveal counter clip-path customease landing hero stagger skincare

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

Skincare Landing Page Reveal — counter preloader into full-bleed hero

Goal

Build a full-page preloader-to-hero reveal for a natural skincare brand landing page. On load, a single GSAP timeline (with a custom "hop" ease) rolls a giant two-digit counter through 00 → 27 → 65 → 98 → 99, slides the logo words together over a growing vertical divider, then wipes two dark overlay blocks upward with clip-path while the background hero image de-zooms from 1.5 to 1 and the nav, masked headline lines, subcopy and CTA pill animate into place. It plays automatically, exactly once.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin CustomEase. Icons come from Ionicons v7 loaded via the unpkg CDN module script (https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js as type="module", plus the nomodule fallback ionicons.js). No smooth-scroll library, no ScrollTrigger — everything runs on DOMContentLoaded.

Layout / HTML

Two top-level siblings in <body>:

  1. div.loader — fixed full-viewport overlay (z-index: 2) containing:
  2. div.overlay with two div.block children (side-by-side dark panels, each 100% height / 50% width via flex).
  3. div.intro-logo centered, with two words: div.word#word-1 > h1 > span containing "Kind" (the span gets the italic serif font) and div.word#word-2 > h1 containing "Root".
  4. div.divider — a 1px-wide vertical white line, full height, horizontally centered.
  5. div.spinner-container (centered, bottom: 10%) with div.spinner inside.
  6. div.counter centered, holding five div.count groups stacked on top of each other (each absolutely positioned at the same center point). Each .count has two div.digit > h1 children. The digit pairs, in DOM order, are: 0 0, 2 7, 6 5, 9 8, 9 9.
  1. div.container — relative, 100vw × 100svh, overflow: hidden, containing:
  2. div.hero-img > img — absolutely positioned full-viewport background image, z-index: -1, object-fit: cover.
  3. div.nav — absolute top bar, flex with three equal-flex children: div.logo > a ("KindRoot"), div.nav-links with four links ("Rituals", "Our Roots", "Lookbook", "Stories"), and div.btn > a containing <ion-icon name="cart-outline">.
  4. div.header — centered column (padding-top: 25svh, gap: 1.5em) with div.hero-copy holding two div.line > h1 headline lines — line 1: <span>Rooted</span> in care,; line 2: grown with <span>kindness</span> (spans use the italic serif font) — and one more div.line > p below with "Skincare that stays true to nature and to you".
  5. div.cta — white pill anchored bottom-center (bottom: 3em), containing div.cta-label > p ("View all products", absolutely centered in the pill) and div.cta-icon (<ion-icon name="arrow-forward-outline">) hugging the right edge.

Styling

  • Fonts: @import url("https://fonts.cdnfonts.com/css/pp-neue-montreal");. Body font: "PP Neue Montreal", sans-serif. Accent serif for the italic spans and big counter digits: font-family: "PP Editorial Old", "PP Neue Montreal", font-style: italic for the spans, -webkit-font-smoothing: antialiased.
  • Global reset (* { margin:0; padding:0; box-sizing:border-box }). img { width:100%; height:100%; object-fit:cover }.
  • h1: centered, #fff, 5rem, weight 500, line-height: 1. h1 span: the italic serif, weight 500.
  • a, p: uppercase, #fff, 12px, weight 500, line-height: 1, no underline. Logo link is capitalized (not uppercase), 14px, bolder.
  • .nav: padding: 1.25em 1.5em, flex, gap: 1.5em, each direct child flex: 1; .nav-links centered with gap: 1.5em; .btn right-aligned, its link a white pill 60px × 40px, border-radius: 40px, black icon, font-size: 16px.
  • .cta: left: 50%, transform: translateX(-50%), width 50%, height 60px, padding: 0.5rem, white background, border-radius: 4rem, flex with justify-content: flex-end. .cta-label p is black. .cta-icon: square (aspect-ratio: 1, full height), dark #303030 circle (border-radius: 60px), white icon, centered content.
  • Loader: .block background #303030. .intro-logo centered via top/left 50% + translate(-50%,-50%), flex gap: 0.25rem; #word-1 nudged left: -0.5rem with padding-right: 0.25rem; .word h1 is 2.5rem. .divider: 1px wide, full height, white, transform-origin: center top. .spinner: 50px circle, 1.4px solid #fff border with border-top-color: rgba(255,255,255,0.125), spun by a CSS keyframe (rotate 360deg, 1s linear infinite).
  • Counter digits: .count .digit h1 uses the italic serif family at 15rem, weight 400; each .digit has padding-top: 1rem; .count is absolutely centered so all five pairs occupy the same spot.
  • Initial (pre-animation) states set in CSS — critical:
  • .nav { transform: translateY(-120%) }
  • .cta { transform: translateX(-50%) scale(0) } and .cta-icon { transform: scale(0) }
  • .line, .cta-label, .word, .count .digit all get clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%) to act as reveal masks (overflow stays visible; the clip does the masking).
  • .line h1, .line p, .cta-label p, .count .digit h1 start at transform: translateY(120%) (hidden below their mask), with will-change: transform.
  • #word-1 h1 starts at translateY(-120%) (enters from above); #word-2 h1 starts at translateY(120%) (enters from below).
  • .block starts with full-cover clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%).
  • .divider starts at transform: scaleY(0).
  • .hero-img starts at transform: scale(1.5).
  • Media query max-width: 900px: h1 drops to 2.5rem, .nav-links hidden, .cta width 90%.

GSAP effect (the important part)

Register CustomEase and create a custom ease named "hop" from the cubic-bezier 0.9, 0, 0.1, 1. On DOMContentLoaded, build ONE gsap.timeline({ delay: 0.3, defaults: { ease: "hop" } }). All tweens below use that ease unless stated.

  1. Counter roll. Select all .count groups; for each count at index, grab its two .digit h1:
  2. tl.to(digits, { y: "0%", duration: 1, stagger: 0.075 }, index * 1) — absolute position param: pair *n* starts exactly at *n* seconds into the timeline.
  3. tl.to(digits, { y: "-100%", duration: 1, stagger: 0.075 }, index * 1 + 1) — each pair rolls up and out one second after it rolled in. Apply this to every pair (including the last). Net effect: each number slides up into the mask, holds implicitly via the overlap, and exits upward as the next slides in — a continuous odometer roll through 00, 27, 65, 98, 99 over ~5s.
  4. Spinner out. tl.to(".spinner", { opacity: 0, duration: 0.3 }) (appended after the counter finishes).
  5. Logo words in. tl.to(".word h1", { y: "0%", duration: 1 }, "<") — starts at the same time as the spinner fade; "Kind" drops in from above while "Root" rises from below, meeting at center.
  6. Divider grows. tl.to(".divider", { scaleY: "100%", duration: 1 }) (appended). Its onComplete fires a separate gsap.to(".divider", { opacity: 0, duration: 0.3, delay: 0.3 }) so the line fades out shortly after fully drawing.
  7. Logo words out. tl.to("#word-1 h1", { y: "100%", duration: 1, delay: 0.3 }) (appended) and simultaneously tl.to("#word-2 h1", { y: "-100%", duration: 1 }, "<") — the words exit in the opposite directions they entered (crossing past each other).
  8. Block wipe + hero de-zoom. tl.to(".block", { clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)", duration: 1, stagger: 0.1, delay: 0.75 }, "<") — both dark panels collapse to a zero-height strip at the top (an upward wipe), the second panel trailing the first by 0.1s. Its onStart fires gsap.to(".hero-img", { scale: 1, duration: 2, ease: "hop" }) so the background image settles from 1.5 → 1 while the blocks lift.
  9. Nav + headline + copy in. tl.to([".nav", ".line h1", ".line p"], { y: "0%", duration: 1.5, stagger: 0.2 }, "<") — the nav slides down from -120% while each masked headline line and the paragraph rise from 120%, 0.2s apart, starting together with the block wipe.
  10. CTA pill. tl.to([".cta", ".cta-icon"], { scale: 1, duration: 1.5, stagger: 0.75, delay: 0.75 }, "<") — the white pill scales up from 0 (keeping its translateX(-50%) centering), then the dark icon circle pops in 0.75s later.
  11. CTA label. tl.to(".cta-label p", { y: "0%", duration: 1.5, delay: 0.5 }, "<") — the "View all products" text rises inside its clip mask.

Note the chained "<" position params on steps 6–9: they all anchor to the start of step 5's word-exit, offset only by their own delay values, so the loader exit and hero entrance overlap into one continuous move. Total sequence runs roughly 9–10 seconds and never repeats.

Assets / images

One image: a full-bleed background hero, revealed behind the loader — a close-up natural beauty/skincare editorial portrait (bare glowing skin, soft warm neutral backdrop). Landscape orientation works best; it covers the whole viewport via object-fit: cover, so any high-res photo ≥ 1920×1080 is fine.

Behavior notes

  • The animation runs once on page load; there is no scroll interaction and the page is exactly one viewport tall (overflow: hidden on the container).
  • The loader stays in the DOM after finishing (the blocks are clipped away and the divider/spinner faded to 0), which is fine because everything inside it is invisible by then; keep pointer-events a non-issue by simply leaving it — or optionally set pointer-events: none on .loader so nav links remain clickable.
  • Use will-change: transform on the animated movers (nav, cta, cta-icon, divider, hero-img, masked h1/p elements) for smoothness.
  • Use 100svh (not 100vh) for the loader and container heights so mobile browser chrome doesn't cause jumps.