Counter Curtain Hero Reveal — Load Counter, Rotating Word, Clip-Path Curtain & Flip-Expanding Frame
Goal
Build a landing-page intro. A dark preloader shows the brand name top-left and a rotating word top-right (Studios → Season → Chamber → Archive → Vision), a three-digit counter bottom-left that runs 000 → 100, a caption bottom-right and a thin iridescent progress line. During the same 3 seconds a small glass photo frame in the hero (hidden under the preloader) cycles through ten photos and travels from the section's left edge to its slot inside the second headline row. Then: the preloader text blurs and fades, the panel wipes upward with a custom "hop" ease, the three headline rows slide their words in from their masks (row 1 and 3 from the left, row 2 from the right), the caption and nav fade/slide in, and the frame expands with GSAP Flip until it fills the whole section behind the type, which is mix-blend-mode: difference so it stays readable on any photo.
Tech
Vanilla HTML/CSS/JS with ES module imports. gsap (npm) with CustomEase, SplitText and Flip. Font: Hanken Grotesk (500).
Layout / HTML
<div class="cc">
<div class="cc-preloader" aria-hidden="true">
<div class="cc-preloader__header">
<div class="cc-preloader__row"><h1>Underlume</h1></div>
<div class="cc-preloader__row"><h1 class="cc-preloader__word">Studios</h1></div>
</div>
<div class="cc-preloader__footer">
<div class="cc-preloader__counter"><h1>000</h1></div>
<div class="cc-preloader__copy"><p>Currently developing</p></div>
</div>
<span class="cc-preloader__bar"></span>
</div>
<nav class="cc-nav"><p>Underlume</p><p>Menu</p></nav>
<section class="cc-hero">
<div class="cc-hero__header">
<div class="cc-hero__row"><h1>Everything</h1></div>
<div class="cc-hero__row">
<h1>Beneath</h1>
<div class="cc-hero__frame"><!-- ten <img> --></div>
</div>
<div class="cc-hero__row"><h1>The surface</h1></div>
</div>
<div class="cc-hero__footer"><p>Seen and unseen</p></div>
</section>
</div>
Styling
:root: --cc-ink:#f3f2ee, --cc-paper:#f3f2ee, --cc-preloader:#1b1f22, --cc-line:rgba(255,255,255,.22), --cc-frame-w:275px, --cc-frame-h:150px.
.cc—min-height:100svh; background:var(--cc-paper); font-family:"Hanken Grotesk". Allh1, p:margin:0; text-transform:uppercase; font-weight:500; letter-spacing:-.02em; line-height:.85;h1 { font-size: clamp(2.5rem, 10vw, 15rem) },p { font-size:.85rem; letter-spacing:.02em; line-height:1 }..cc-preloader—position:fixed; inset:0; z-index:20; display:flex; flex-direction:column; justify-content:space-between; padding:2.5rem; color:var(--cc-ink); background: radial-gradient(120vmax 60vmax at 50% 120%, rgba(255,255,255,.08), transparent 60%), var(--cc-preloader); clip-path:polygon(0% 0%,100% 0%,100% 100%,0% 100%); overflow:hidden. Second header row isjustify-content:flex-end; footer isdisplay:flex; justify-content:space-between; align-items:flex-end; counter usesfont-variant-numeric:tabular-nums..cc-preloader__bar—position:absolute; left:2.5rem; right:2.5rem; bottom:1.4rem; height:1px; background:linear-gradient(90deg, rgba(255,255,255,.9), rgba(178,196,255,.9), rgba(255,184,226,.8), rgba(160,255,228,.8)); transform:scaleX(0); transform-origin:left; opacity:.8..cc-nav—position:fixed; inset:0 0 auto 0; z-index:5; display:flex; justify-content:space-between; padding:2.5rem; color:#fff; mix-blend-mode:difference..cc-hero—position:relative; height:100svh; padding:2.5rem; display:flex; align-items:flex-end; overflow:hidden; isolation:isolate; background:var(--cc-paper)(the background inside the isolated group is what makesdifferencework)..cc-hero__header—position:relative; z-index:1; width:100%; display:flex; flex-direction:column. Rows:flex:1; display:flex; align-items:center; rows 1 and 3justify-content:flex-end; row 2gap:2.5rem. Rowh1:color:#fff; mix-blend-mode:difference..cc-hero__frame—position:relative; flex-shrink:0; width:var(--cc-frame-w); height:var(--cc-frame-h); border-radius:10px; overflow:hidden; box-shadow:0 0 0 1px var(--cc-line), 0 30px 60px -30px rgba(0,0,0,.5); z-index:2. Its images areposition:absolute; inset:0; object-fit:cover; opacity:0,.is-active { opacity:1 }. A::aftergradient (transparent 55% → rgba(0,0,0,.35)) fades in over 1s when the frame has.is-full..cc-hero__frame.is-full—position:absolute; inset:0; width:auto; height:auto; border-radius:0; box-shadow:none; z-index:0..cc-hero__footer—position:absolute; left:2.5rem; bottom:2.5rem; z-index:1; itspis white,mix-blend-mode:difference,opacity:0..cc-word(SplitText word class) —position:relative.@media (max-width:1000px):--cc-frame-w:100px; --cc-frame-h:56px, paddings1.5rem, row-2 gap1rem, footerbottom:25svh.
GSAP effect (be exact)
gsap.registerPlugin(CustomEase, SplitText, Flip); CustomEase.create("cc-hop", "0.8, 0, 0.1, 1"). Build in mount(config) → destroy().
const DEFAULTS = { startDelay: .5, loadDuration: 3, curtainDuration: 1, wordDuration: 1.25, expandDuration: 1.25, imageLoops: 3 };
const ROTATING = ["Studios", "Season", "Chamber", "Archive", "Vision"];
Prep
- Split each hero
h1withSplitText.create(h, { type:"words", mask:"words", wordsClass:"cc-word" }); set words of row 2 toxPercent: 100, rows 1 and 3 toxPercent: -100. gsap.set(nav, { y: -300 }); preloaderclipPathfull,display:flex; the three fading blocksopacity:1, filter:"blur(0px)"; barscaleX:0; footeropacity:0; first image.is-active; counter000; wordStudios.- Frame start offset:
pad = parseFloat(getComputedStyle(hero).paddingLeft);leftOffset = pad + hero.getBoundingClientRect().left - frame.getBoundingClientRect().left;gsap.set(frame, { x: leftOffset })— the frame begins flush with the section's left padding.
Renderers driven by plain objects:
counter.p→textContent = String(round(p)).padStart(3, "0").wordCycle.p→ROTATING[round(p)](only update when the index changes).imageCycle.p→round(p) % images.lengthtoggles.is-active.
Expand — state = Flip.getState(frame); add .is-full; gsap.set(frame, { x: 0 }); Flip.from(state, { duration: expandDuration, ease: "cc-hop", absolute: true }).
Timeline (delay: startDelay, L = loadDuration):
0—counter { p: 100, duration: L, ease: "none", onUpdate }.0— barscaleX: 1,L,ease:"none".0— framex: 0,L,ease:"none".0—wordCycle { p: ROTATING.length - 1, duration: L, ease:"none", onUpdate }.0—imageCycle { p: images.length * imageLoops - 1, duration: L, ease:"none", onUpdate }(three full laps over ten photos)."+=0.3"— header, counter and copy{ opacity: 0, filter: "blur(10px)", duration: .35, stagger: .04 };"<"baropacity: 0, .2s.- next — preloader
clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",curtainDuration,ease: "cc-hop",onComplete→display:none. "-=0.5"— all wordsxPercent: 0,wordDuration,power3.out,onComplete: expand."<"— footeropacity: 1, 1s,power3.out."<"— navy: 0, 1s,power3.out.
destroy() — kill timeline/tweens, revert splits, remove .is-full, clearProps on nav/preloader/bar/footer/frame/fading blocks, reset images/counter/word.
Assets / images
Ten landscape photos (≈16:9, ≥1200px — the last one becomes the full-section background, so it should be the strongest): a coherent set of landscapes and architecture in a muted palette. Photos 1–9 are only ever seen at 275×150 for a fraction of a second each.
Behavior notes
- The whole thing is a one-shot intro of ≈7s; the preloader is
position:fixedand removed from view at the end. mix-blend-mode: differenceon the headline needs a painted background inside the same stacking context (.cc-herohasisolation:isolateand its own background) — without it the text renders white on white.- Flip with
absolute:truelets the frame leave the flex row without the row collapsing; theBeneathheading keeps its place because it precedes the frame. - Mobile shrinks the frame to 100×56 and the paddings; the sequence is otherwise identical.
Images
This component ships with 10 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.
https://motionprompts.dev/c/counter-curtain-hero-reveal/img1.jpg
https://motionprompts.dev/c/counter-curtain-hero-reveal/img10.jpg
https://motionprompts.dev/c/counter-curtain-hero-reveal/img2.jpg
https://motionprompts.dev/c/counter-curtain-hero-reveal/img3.jpg
https://motionprompts.dev/c/counter-curtain-hero-reveal/img4.jpg
https://motionprompts.dev/c/counter-curtain-hero-reveal/img5.jpg
… 4 more under https://motionprompts.dev/c/counter-curtain-hero-reveal/
They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--cc-ink,--cc-paper,--cc-preloader,--cc-line,--cc-frame-w,--cc-frame-h. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - Rules on
html, body— the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it. - Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.