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>:
div.loader— fixed full-viewport overlay (z-index: 2) containing:div.overlaywith twodiv.blockchildren (side-by-side dark panels, each 100% height / 50% width via flex).div.intro-logocentered, with two words:div.word#word-1 > h1 > spancontaining "Kind" (the span gets the italic serif font) anddiv.word#word-2 > h1containing "Root".div.divider— a 1px-wide vertical white line, full height, horizontally centered.div.spinner-container(centered,bottom: 10%) withdiv.spinnerinside.div.countercentered, holding fivediv.countgroups stacked on top of each other (each absolutely positioned at the same center point). Each.counthas twodiv.digit > h1children. The digit pairs, in DOM order, are:0 0,2 7,6 5,9 8,9 9.
div.container— relative,100vw × 100svh,overflow: hidden, containing:div.hero-img > img— absolutely positioned full-viewport background image,z-index: -1,object-fit: cover.div.nav— absolute top bar, flex with three equal-flex children:div.logo > a("KindRoot"),div.nav-linkswith four links ("Rituals", "Our Roots", "Lookbook", "Stories"), anddiv.btn > acontaining<ion-icon name="cart-outline">.div.header— centered column (padding-top: 25svh,gap: 1.5em) withdiv.hero-copyholding twodiv.line > h1headline lines — line 1:<span>Rooted</span> in care,; line 2:grown with <span>kindness</span>(spans use the italic serif font) — and one morediv.line > pbelow with "Skincare that stays true to nature and to you".div.cta— white pill anchored bottom-center (bottom: 3em), containingdiv.cta-label > p("View all products", absolutely centered in the pill) anddiv.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: italicfor 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 childflex: 1;.nav-linkscentered withgap: 1.5em;.btnright-aligned, its link a white pill60px × 40px,border-radius: 40px, black icon,font-size: 16px..cta:left: 50%,transform: translateX(-50%), width50%, height60px,padding: 0.5rem, white background,border-radius: 4rem, flex withjustify-content: flex-end..cta-label pis black..cta-icon: square (aspect-ratio: 1, full height), dark#303030circle (border-radius: 60px), white icon, centered content.- Loader:
.blockbackground#303030..intro-logocentered viatop/left 50% + translate(-50%,-50%), flexgap: 0.25rem;#word-1nudgedleft: -0.5remwithpadding-right: 0.25rem;.word h1is2.5rem..divider:1pxwide, full height, white,transform-origin: center top..spinner:50pxcircle,1.4px solid #fffborder withborder-top-color: rgba(255,255,255,0.125), spun by a CSS keyframe (rotate 360deg, 1s linear infinite). - Counter digits:
.count .digit h1uses the italic serif family at15rem, weight 400; each.digithaspadding-top: 1rem;.countis 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 .digitall getclip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%)to act as reveal masks (overflowstays visible; the clip does the masking)..line h1,.line p,.cta-label p,.count .digit h1start attransform: translateY(120%)(hidden below their mask), withwill-change: transform.#word-1 h1starts attranslateY(-120%)(enters from above);#word-2 h1starts attranslateY(120%)(enters from below)..blockstarts with full-coverclip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)..dividerstarts attransform: scaleY(0)..hero-imgstarts attransform: scale(1.5).- Media query
max-width: 900px:h1drops to2.5rem,.nav-linkshidden,.ctawidth90%.
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.
- Counter roll. Select all
.countgroups; for eachcountatindex, grab its two.digit h1: 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.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.- Spinner out.
tl.to(".spinner", { opacity: 0, duration: 0.3 })(appended after the counter finishes). - 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. - Divider grows.
tl.to(".divider", { scaleY: "100%", duration: 1 })(appended). ItsonCompletefires a separategsap.to(".divider", { opacity: 0, duration: 0.3, delay: 0.3 })so the line fades out shortly after fully drawing. - Logo words out.
tl.to("#word-1 h1", { y: "100%", duration: 1, delay: 0.3 })(appended) and simultaneouslytl.to("#word-2 h1", { y: "-100%", duration: 1 }, "<")— the words exit in the opposite directions they entered (crossing past each other). - 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. ItsonStartfiresgsap.to(".hero-img", { scale: 1, duration: 2, ease: "hop" })so the background image settles from 1.5 → 1 while the blocks lift. - 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 from120%, 0.2s apart, starting together with the block wipe. - 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 itstranslateX(-50%)centering), then the dark icon circle pops in 0.75s later. - 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: hiddenon 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-eventsa non-issue by simply leaving it — or optionally setpointer-events: noneon.loaderso nav links remain clickable. - Use
will-change: transformon the animated movers (nav, cta, cta-icon, divider, hero-img, masked h1/p elements) for smoothness. - Use
100svh(not100vh) for the loader and container heights so mobile browser chrome doesn't cause jumps.