Curtain + Logo Page Transition
Clicking a nav link closes six vertical slats over the page from the top, draws a monogram and wordmark with a 00→100 counter, swaps the content underneath, then opens the slats from the bottom as the new page rises in.
- Page Transitions
- gsap
- svg
- intermediate
- light perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1715 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Curtain + Logo Page Transition
**What it does (visual):** A small fake site has a fixed top bar (brand name on the left, three mono-spaced nav links on the right — "Studio", "Work", "Journal" — the active one underlined by a 1px `#d4ff3a` line) and a page area with a large light-weight serif headline, a paragraph and a gradient image block. When a nav link is clicked, six full-height vertical slats (each 1/6 of the viewport wide, filled with the text colour so the curtain is an inverted panel) grow downward from the top edge of the screen, left to right, 0.05s apart, each taking 0.6s with the `expo.inOut` ease (slow start, fast middle, slow stop). About half a second in, while the slats are still closing, a thin-stroke monogram (an "H" with a circle through the crossbar) draws itself in the centre of the curtain as four stroked SVG paths, a serif wordmark "Halden" rises out of a mask below it, and a tiny mono counter below that counts from 00 to 100 over 0.9s. Once the counter has finished, the page content behind the curtain is swapped (the user cannot see this), the logo holds for 0.3s, then the logo group drifts up 20px and fades out over 0.4s. The slats then shrink **toward the bottom edge**, right to left, 0.05s apart, revealing the new page, whose eyebrow, headline, copy and media rise 40px into place with a soft deceleration, staggered 70ms. On first load the same sequence plays from the closed state (a preloader): logo draws, counter runs, curtain opens onto the first page. Left idle (and with `true` enabled), the demo navigates itself to the next page every 3.2s until the user clicks a link.
**Tech stack:**
- GSAP 3.13+ core only (no plugins). SVG stroke drawing is done with `strokeDasharray` / `strokeDashoffset` and `path.getTotalLength()`.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`
- npm: `npm i gsap` → `import gsap from "gsap";`
- Fonts: a light variable serif for headlines and wordmark (demo uses Fraunces 300), a monospace for eyebrows/counter, a sans for body.
**HTML structure:**
```html
<div class="clt">
<header class="clt__nav">
<a class="clt__brand" href="#studio" data-page="studio">Halden</a>
<nav class="clt__links">
<a href="#studio" data-page="studio" class="is-active" aria-current="page">Studio</a>
<a href="#work" data-page="work">Work</a>
<a href="#journal" data-page="journal">Journal</a>
</nav>
</header>
<main class="clt__stage">
<section class="clt__page is-active" data-page="studio">
<p class="clt__eyebrow" data-reveal>Studio · Est. 2019</p>
<h1 class="clt__title" data-reveal>Quiet interfaces for loud brands.</h1>
<p class="clt__copy" data-reveal>…</p>
<div class="clt__media" data-reveal></div>
</section>
<section class="clt__page" data-page="work"><!-- eyebrow, title, 3 tiles, each data-reveal --></section>
<section class="clt__page" data-page="journal"><!-- eyebrow, title, 3 list rows, each data-reveal --></section>
</main>
<div class="clt__curtain" aria-hidden="true">
<div class="clt__slats">
<span class="clt__slat"></span><!-- ×6 total -->
</div>
<div class="clt__logo">
<svg class="clt__mark" viewBox="0 0 120 120" fill="none" stroke-width="1.5" stroke-linecap="round">
<path d="M22 18 V102"/><path d="M98 18 V102"/><path d="M22 60 H98"/><path d="M60 42 a18 18 0 1 1 -0.01 0"/>
</svg>
<span class="clt__wordmark-mask"><span class="clt__wordmark">Halden</span></span>
<span class="clt__counter">00</span>
</div>
</div>
</div>
```
Every element that should rise in after the curtain opens carries `data-reveal`.
**CSS requirements:**
- `.clt`: `position: relative; min-height: 100vh; background: var(--bg); color: var(--text);`
- `.clt__nav`: `position: fixed; inset: 0 0 auto 0; z-index: 20; display: flex; justify-content: space-between; align-items: center; padding: 1.4rem 6vw; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px);`
- `.clt__links a`: mono 11px uppercase, `letter-spacing: .12em; color: var(--muted); position: relative;` with `::after { height: 1px; background: var(--accent); /* --accent: #d4ff3a */ transform: scaleX(0); transform-origin: left; transition: transform .4s cubic-bezier(.65,0,.35,1); }` and `.is-active::after { transform: scaleX(1) }`.
- `.clt__stage`: `min-height: 100vh; padding: calc(6rem + 4vw) 6vw 8vw;` — `.clt__page { display: none; max-width: 1200px; margin-inline: auto; } .clt__page.is-active { display: block; }`
- `.clt__title`: display serif, `font-weight: 300; font-size: clamp(2.5rem, 7vw, 6.5rem); line-height: .95; letter-spacing: -0.04em; max-width: 14ch;`
- `.clt__copy`: `max-width: 46ch; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.55; margin-top: 2rem;`
- `.clt__media`: `overflow: hidden; border-radius: 10px;` painted with two radial gradients + a linear gradient from per-tile `--c1/--c2` colours (no image assets); `aspect-ratio: 16/7` on the studio page, `4/5` for the three work tiles (3-column grid, gap `clamp(1rem, 2.5vw, 2.5rem)`).
- `.clt__curtain`: `position: fixed; inset: 0; z-index: 30; pointer-events: none;` — visible on first paint so the page loads covered (preloader state); GSAP toggles `autoAlpha` afterwards.
- `.clt__slats`: `position: absolute; inset: 0; display: grid; grid-template-columns: repeat(6, 1fr);`
- `.clt__slat`: `background: var(--text); transform: scaleY(1); transform-origin: top center; will-change: transform;` (closed by default; the intro timeline opens it) — adjacent slats get `box-shadow: -1px 0 0 color-mix(in srgb, var(--bg) 18%, transparent)` so the seams read as hairlines.
- `.clt__logo`: `position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 1.25rem; color: var(--bg); opacity: 0; visibility: hidden;`
- `.clt__mark`: `width: clamp(72px, 9vw, 112px); stroke: currentColor;` — `.clt__wordmark-mask { overflow: hidden }`, `.clt__wordmark { font-weight: 300; font-size: clamp(2rem, 4vw, 3.25rem); letter-spacing: -0.04em; line-height: 1; }`, `.clt__counter { mono 11px; letter-spacing: .2em; opacity: .55; font-variant-numeric: tabular-nums; }`
- Light theme is automatic: slats use `var(--text)` and the logo uses `var(--bg)`, so the curtain is always the inverse of the page.
**Animation spec (exact values):**
Constants: `SLAT_DURATION = 0.6`, `SLAT_STAGGER = 0.05`, `SLAT_EASE = 'expo.inOut'`, `HOLD = 0.3`, `AUTO_CYCLE = 3.2` (only when `true` is true), `riseY = 40` (24 under 768px), accent colour `#d4ff3a`.
`transition(next, { intro })` builds one timeline (`defaults: { ease: 'expo.inOut' }`), killing any previous one:
1. Setup sets (0s): `curtain { autoAlpha: 1 }`; `slats { transformOrigin: 'top center', scaleY: intro ? 1 : 0 }`; `logo { autoAlpha: 0, y: 0 }`; `wordmark { yPercent: 110 }`; each mark path `{ strokeDasharray: L, strokeDashoffset: L }` where `L = path.getTotalLength()`. Add label `'close'`.
2. **Close** (skipped when `intro`): `tl.to(slats, { scaleY: 1, duration: 0.6, stagger: 0.05 }, 'close')` — six slats, scaleY 0 → 1 from the top, left → right; the last slat finishes at `0.6 + 5 × 0.05` s.
3. Label `'logo'` at `'close+=0.55'` (or `'close'` when intro):
- `tl.to(logo, { autoAlpha: 1, duration: 0.3, ease: 'power2.out' }, 'logo')`
- `tl.to(markPaths, { strokeDashoffset: 0, duration: 0.8, ease: 'power3.inOut', stagger: 0.04 }, 'logo')` — four paths draw from L → 0.
- `tl.to(wordmark, { yPercent: 0, duration: 0.7, ease: 'power3.out' }, 'logo+=0.15')` — rises through the mask.
- `tl.to(counter, { value: 100, duration: 0.9, ease: 'power2.inOut', onUpdate: setCounter }, 'logo')` — `counter = { value: 0 }`; `setCounter` writes `String(Math.round(value)).padStart(2, '0')`.
4. Label `'swap'` at `'logo+=0.9'`: `tl.call(swapPage, [next], 'swap')` — toggles `.is-active` on pages and nav links, sets `aria-current`, scrolls to top. The screen is fully covered at this moment.
5. Label `'exit'` at `'swap+=0.3'` (the HOLD): `tl.to(logo, { y: -20, autoAlpha: 0, duration: 0.4, ease: 'power2.in' }, 'exit')`.
6. Label `'open'` at `'exit+=0.2'`: `tl.set(slats, { transformOrigin: 'bottom center' }, 'open')` then `tl.to(slats, { scaleY: 0, duration: 0.6, stagger: { each: 0.05, from: 'end' } }, 'open')` — scaleY 1 → 0 toward the bottom, right → left.
7. New content: `tl.fromTo(next page's [data-reveal], { y: riseY, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.9, ease: 'power3.out', stagger: 0.07, clearProps: 'all' }, 'open+=0.25')` — use `fromTo`, not `from`, so a pre-hidden element never records `0` as its end value.
8. `onComplete`: `gsap.set(curtain, { autoAlpha: 0 })`, then — if `true` is true — `gsap.delayedCall(3.2, () => transition(nextInOrder))` unless the user has clicked a link.
Intro: the CSS ships the curtain closed (slats `scaleY(1)`), so on load call `transition('studio', { intro: true })` immediately — total ≈ 2.1s + 0.3; a normal navigation adds the close (0.6 + 5 × 0.05 s, overlapped by 0.55s).
**Interaction:** Clicking any nav link (or the brand) calls `e.preventDefault()`, marks the demo as user-driven (stops the auto cycle) and runs `transition(page)` unless that page is already active or a transition is running. Nav links underline on hover/active via the `::after` scaleX transition (0.4s). No scroll dependency.
**Responsive behavior:** `gsap.matchMedia()` with `(max-width: 767px)` reduces the content rise from 40px to 24px; the six slats simply become narrower (grid keeps 6 columns). Under 768px the work grid becomes 2 columns (third tile spans full width at 16/9), the hint text in the nav is hidden, and the journal rows collapse to two columns. Touch works identically — the effect is click-driven.
**Accessibility:** With `prefers-reduced-motion: reduce` the curtain is `display: none` and links swap pages instantly (`swapPage` only — no counter, no slats, no rise). The curtain is `aria-hidden="true"`; the active link carries `aria-current="page"`. Links keep a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 4px`). Content is swapped while covered, so there is no half-rendered state exposed to screen readers mid-transition.
**Performance rules:** Only `transform`, `opacity`/`visibility` and SVG `stroke-dashoffset` are animated. `will-change: transform` lives on the six slats only. The curtain is `pointer-events: none` and `visibility: hidden` when idle so it never intercepts input or paints. Everything is created inside `gsap.context(…, root)` and reverted on unmount (`ctx.revert()`), and the pending `delayedCall` is killed. Because the auto cycle uses `gsap.delayedCall`, it pauses with `gsap.globalTimeline` when the host pauses the demo.
**Customization knobs:**
- `SLAT_DURATION` (0.6) / `SLAT_STAGGER` (0.05) — curtain speed and the diagonal of the wipe; 8–10 slats with 0.035 stagger gives a finer blind.
- `SLAT_EASE` (`expo.inOut`) — `power4.inOut` is softer, `expo.in` for a slam.
- `HOLD` (0.3) — the pause with the logo fully drawn.
- `--accent` (#d4ff3a) — the nav underline and focus ring.
- Slat colour (`var(--text)`) — swap for `var(--accent)` for a brand-coloured curtain and set `.clt__logo { color: var(--bg) }` accordingly.
- `AUTO_CYCLE` (true, every 3.2s) — disable / remove `scheduleAuto()` in production.
- Replace `swapPage` with your router's navigation (await the fetch before the `'exit'` label by pausing the timeline at `'swap'` and resuming when the new HTML is ready).
**Acceptance criteria:**
- [ ] Clicking a nav link closes six slats from the top, left → right, over ≈0.6 + 5 × 0.05 s with `expo.inOut`.
- [ ] Monogram paths draw in (no pop-in), wordmark rises through its mask, counter reads 00 → 100 in 0.9s.
- [ ] Page content changes only while fully covered; the logo holds 0.3s before exiting (y −20, fade 0.4s).
- [ ] Slats open toward the bottom edge, right → left; new page content rises 40px with a 70ms stagger.
- [ ] A second click during a transition is ignored; the idle auto-cycle (`true`) stops after the first user click.
- [ ] Reduced motion: no curtain, instant swap. No console errors; curtain never blocks clicks when idle. Add this effect to my existing site/section: **Curtain + Logo Page Transition**. Keep my nav and pages; add a fixed `.clt__curtain` (`inset: 0; pointer-events: none; visibility: hidden`) containing six `.clt__slat` grid columns (`background: var(--text); transform: scaleY(0); transform-origin: top`) and a centred `.clt__logo` with my SVG logo paths (stroke only), a masked wordmark and a `00` counter. On link click, build a GSAP timeline (`defaults.ease: 'expo.inOut'`): slats `scaleY 0→1, duration 0.6, stagger 0.05` from the top; at `+0.55s` fade the logo in (0.3s), draw the paths with `strokeDashoffset L→0` (0.8s, `power3.inOut`, stagger 0.04), rise the wordmark (`yPercent 110→0`, 0.7s, `power3.out`) and tween a counter object `0→100` (0.9s, `power2.inOut`); swap the page content at `logo+0.9s`, hold 0.3s, exit the logo (`y: -20, autoAlpha: 0`, 0.4s, `power2.in`), then set `transformOrigin: 'bottom'` and open the slats `scaleY 1→0` (0.6s, `stagger: { each: 0.05, from: 'end' }`), and `from()` the new page's `[data-reveal]` elements `{ y: 40, autoAlpha: 0, duration: 0.9, ease: 'power3.out', stagger: 0.07 }` starting 0.25s into the open. Ignore clicks while the timeline is active. Use `#d4ff3a` as the accent for the active-link underline. Under `prefers-reduced-motion` hide the curtain and swap instantly. Wrap in `gsap.context()` and revert on unmount. Do not change my fonts, colours or layout. ### EFFECT (React / Next.js): Curtain + Logo Page Transition
Build **Curtain + Logo Page Transition** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/CurtainLogoPageTransition.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP);
export default function CurtainLogoPageTransition() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="clt">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`CurtainLogoPageTransition.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### EFFECT: Curtain + Logo Page Transition
**What it does (visual):** A small fake site has a fixed top bar (brand name on the left, three mono-spaced nav links on the right — "Studio", "Work", "Journal" — the active one underlined by a 1px `#d4ff3a` line) and a page area with a large light-weight serif headline, a paragraph and a gradient image block. When a nav link is clicked, six full-height vertical slats (each 1/6 of the viewport wide, filled with the text colour so the curtain is an inverted panel) grow downward from the top edge of the screen, left to right, 0.05s apart, each taking 0.6s with the `expo.inOut` ease (slow start, fast middle, slow stop). About half a second in, while the slats are still closing, a thin-stroke monogram (an "H" with a circle through the crossbar) draws itself in the centre of the curtain as four stroked SVG paths, a serif wordmark "Halden" rises out of a mask below it, and a tiny mono counter below that counts from 00 to 100 over 0.9s. Once the counter has finished, the page content behind the curtain is swapped (the user cannot see this), the logo holds for 0.3s, then the logo group drifts up 20px and fades out over 0.4s. The slats then shrink **toward the bottom edge**, right to left, 0.05s apart, revealing the new page, whose eyebrow, headline, copy and media rise 40px into place with a soft deceleration, staggered 70ms. On first load the same sequence plays from the closed state (a preloader): logo draws, counter runs, curtain opens onto the first page. Left idle (and with `true` enabled), the demo navigates itself to the next page every 3.2s until the user clicks a link.
**Tech stack:**
- GSAP 3.13+ core only (no plugins). SVG stroke drawing is done with `strokeDasharray` / `strokeDashoffset` and `path.getTotalLength()`.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`
- npm: `npm i gsap` → `import gsap from "gsap";`
- Fonts: a light variable serif for headlines and wordmark (demo uses Fraunces 300), a monospace for eyebrows/counter, a sans for body.
**HTML structure:**
```html
<div class="clt">
<header class="clt__nav">
<a class="clt__brand" href="#studio" data-page="studio">Halden</a>
<nav class="clt__links">
<a href="#studio" data-page="studio" class="is-active" aria-current="page">Studio</a>
<a href="#work" data-page="work">Work</a>
<a href="#journal" data-page="journal">Journal</a>
</nav>
</header>
<main class="clt__stage">
<section class="clt__page is-active" data-page="studio">
<p class="clt__eyebrow" data-reveal>Studio · Est. 2019</p>
<h1 class="clt__title" data-reveal>Quiet interfaces for loud brands.</h1>
<p class="clt__copy" data-reveal>…</p>
<div class="clt__media" data-reveal></div>
</section>
<section class="clt__page" data-page="work"><!-- eyebrow, title, 3 tiles, each data-reveal --></section>
<section class="clt__page" data-page="journal"><!-- eyebrow, title, 3 list rows, each data-reveal --></section>
</main>
<div class="clt__curtain" aria-hidden="true">
<div class="clt__slats">
<span class="clt__slat"></span><!-- ×6 total -->
</div>
<div class="clt__logo">
<svg class="clt__mark" viewBox="0 0 120 120" fill="none" stroke-width="1.5" stroke-linecap="round">
<path d="M22 18 V102"/><path d="M98 18 V102"/><path d="M22 60 H98"/><path d="M60 42 a18 18 0 1 1 -0.01 0"/>
</svg>
<span class="clt__wordmark-mask"><span class="clt__wordmark">Halden</span></span>
<span class="clt__counter">00</span>
</div>
</div>
</div>
```
Every element that should rise in after the curtain opens carries `data-reveal`.
**CSS requirements:**
- `.clt`: `position: relative; min-height: 100vh; background: var(--bg); color: var(--text);`
- `.clt__nav`: `position: fixed; inset: 0 0 auto 0; z-index: 20; display: flex; justify-content: space-between; align-items: center; padding: 1.4rem 6vw; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px);`
- `.clt__links a`: mono 11px uppercase, `letter-spacing: .12em; color: var(--muted); position: relative;` with `::after { height: 1px; background: var(--accent); /* --accent: #d4ff3a */ transform: scaleX(0); transform-origin: left; transition: transform .4s cubic-bezier(.65,0,.35,1); }` and `.is-active::after { transform: scaleX(1) }`.
- `.clt__stage`: `min-height: 100vh; padding: calc(6rem + 4vw) 6vw 8vw;` — `.clt__page { display: none; max-width: 1200px; margin-inline: auto; } .clt__page.is-active { display: block; }`
- `.clt__title`: display serif, `font-weight: 300; font-size: clamp(2.5rem, 7vw, 6.5rem); line-height: .95; letter-spacing: -0.04em; max-width: 14ch;`
- `.clt__copy`: `max-width: 46ch; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.55; margin-top: 2rem;`
- `.clt__media`: `overflow: hidden; border-radius: 10px;` painted with two radial gradients + a linear gradient from per-tile `--c1/--c2` colours (no image assets); `aspect-ratio: 16/7` on the studio page, `4/5` for the three work tiles (3-column grid, gap `clamp(1rem, 2.5vw, 2.5rem)`).
- `.clt__curtain`: `position: fixed; inset: 0; z-index: 30; pointer-events: none;` — visible on first paint so the page loads covered (preloader state); GSAP toggles `autoAlpha` afterwards.
- `.clt__slats`: `position: absolute; inset: 0; display: grid; grid-template-columns: repeat(6, 1fr);`
- `.clt__slat`: `background: var(--text); transform: scaleY(1); transform-origin: top center; will-change: transform;` (closed by default; the intro timeline opens it) — adjacent slats get `box-shadow: -1px 0 0 color-mix(in srgb, var(--bg) 18%, transparent)` so the seams read as hairlines.
- `.clt__logo`: `position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 1.25rem; color: var(--bg); opacity: 0; visibility: hidden;`
- `.clt__mark`: `width: clamp(72px, 9vw, 112px); stroke: currentColor;` — `.clt__wordmark-mask { overflow: hidden }`, `.clt__wordmark { font-weight: 300; font-size: clamp(2rem, 4vw, 3.25rem); letter-spacing: -0.04em; line-height: 1; }`, `.clt__counter { mono 11px; letter-spacing: .2em; opacity: .55; font-variant-numeric: tabular-nums; }`
- Light theme is automatic: slats use `var(--text)` and the logo uses `var(--bg)`, so the curtain is always the inverse of the page.
**Animation spec (exact values):**
Constants: `SLAT_DURATION = 0.6`, `SLAT_STAGGER = 0.05`, `SLAT_EASE = 'expo.inOut'`, `HOLD = 0.3`, `AUTO_CYCLE = 3.2` (only when `true` is true), `riseY = 40` (24 under 768px), accent colour `#d4ff3a`.
`transition(next, { intro })` builds one timeline (`defaults: { ease: 'expo.inOut' }`), killing any previous one:
1. Setup sets (0s): `curtain { autoAlpha: 1 }`; `slats { transformOrigin: 'top center', scaleY: intro ? 1 : 0 }`; `logo { autoAlpha: 0, y: 0 }`; `wordmark { yPercent: 110 }`; each mark path `{ strokeDasharray: L, strokeDashoffset: L }` where `L = path.getTotalLength()`. Add label `'close'`.
2. **Close** (skipped when `intro`): `tl.to(slats, { scaleY: 1, duration: 0.6, stagger: 0.05 }, 'close')` — six slats, scaleY 0 → 1 from the top, left → right; the last slat finishes at `0.6 + 5 × 0.05` s.
3. Label `'logo'` at `'close+=0.55'` (or `'close'` when intro):
- `tl.to(logo, { autoAlpha: 1, duration: 0.3, ease: 'power2.out' }, 'logo')`
- `tl.to(markPaths, { strokeDashoffset: 0, duration: 0.8, ease: 'power3.inOut', stagger: 0.04 }, 'logo')` — four paths draw from L → 0.
- `tl.to(wordmark, { yPercent: 0, duration: 0.7, ease: 'power3.out' }, 'logo+=0.15')` — rises through the mask.
- `tl.to(counter, { value: 100, duration: 0.9, ease: 'power2.inOut', onUpdate: setCounter }, 'logo')` — `counter = { value: 0 }`; `setCounter` writes `String(Math.round(value)).padStart(2, '0')`.
4. Label `'swap'` at `'logo+=0.9'`: `tl.call(swapPage, [next], 'swap')` — toggles `.is-active` on pages and nav links, sets `aria-current`, scrolls to top. The screen is fully covered at this moment.
5. Label `'exit'` at `'swap+=0.3'` (the HOLD): `tl.to(logo, { y: -20, autoAlpha: 0, duration: 0.4, ease: 'power2.in' }, 'exit')`.
6. Label `'open'` at `'exit+=0.2'`: `tl.set(slats, { transformOrigin: 'bottom center' }, 'open')` then `tl.to(slats, { scaleY: 0, duration: 0.6, stagger: { each: 0.05, from: 'end' } }, 'open')` — scaleY 1 → 0 toward the bottom, right → left.
7. New content: `tl.fromTo(next page's [data-reveal], { y: riseY, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.9, ease: 'power3.out', stagger: 0.07, clearProps: 'all' }, 'open+=0.25')` — use `fromTo`, not `from`, so a pre-hidden element never records `0` as its end value.
8. `onComplete`: `gsap.set(curtain, { autoAlpha: 0 })`, then — if `true` is true — `gsap.delayedCall(3.2, () => transition(nextInOrder))` unless the user has clicked a link.
Intro: the CSS ships the curtain closed (slats `scaleY(1)`), so on load call `transition('studio', { intro: true })` immediately — total ≈ 2.1s + 0.3; a normal navigation adds the close (0.6 + 5 × 0.05 s, overlapped by 0.55s).
**Interaction:** Clicking any nav link (or the brand) calls `e.preventDefault()`, marks the demo as user-driven (stops the auto cycle) and runs `transition(page)` unless that page is already active or a transition is running. Nav links underline on hover/active via the `::after` scaleX transition (0.4s). No scroll dependency.
**Responsive behavior:** `gsap.matchMedia()` with `(max-width: 767px)` reduces the content rise from 40px to 24px; the six slats simply become narrower (grid keeps 6 columns). Under 768px the work grid becomes 2 columns (third tile spans full width at 16/9), the hint text in the nav is hidden, and the journal rows collapse to two columns. Touch works identically — the effect is click-driven.
**Accessibility:** With `prefers-reduced-motion: reduce` the curtain is `display: none` and links swap pages instantly (`swapPage` only — no counter, no slats, no rise). The curtain is `aria-hidden="true"`; the active link carries `aria-current="page"`. Links keep a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 4px`). Content is swapped while covered, so there is no half-rendered state exposed to screen readers mid-transition.
**Performance rules:** Only `transform`, `opacity`/`visibility` and SVG `stroke-dashoffset` are animated. `will-change: transform` lives on the six slats only. The curtain is `pointer-events: none` and `visibility: hidden` when idle so it never intercepts input or paints. Everything is created inside `gsap.context(…, root)` and reverted on unmount (`ctx.revert()`), and the pending `delayedCall` is killed. Because the auto cycle uses `gsap.delayedCall`, it pauses with `gsap.globalTimeline` when the host pauses the demo.
**Customization knobs:**
- `SLAT_DURATION` (0.6) / `SLAT_STAGGER` (0.05) — curtain speed and the diagonal of the wipe; 8–10 slats with 0.035 stagger gives a finer blind.
- `SLAT_EASE` (`expo.inOut`) — `power4.inOut` is softer, `expo.in` for a slam.
- `HOLD` (0.3) — the pause with the logo fully drawn.
- `--accent` (#d4ff3a) — the nav underline and focus ring.
- Slat colour (`var(--text)`) — swap for `var(--accent)` for a brand-coloured curtain and set `.clt__logo { color: var(--bg) }` accordingly.
- `AUTO_CYCLE` (true, every 3.2s) — disable / remove `scheduleAuto()` in production.
- Replace `swapPage` with your router's navigation (await the fetch before the `'exit'` label by pausing the timeline at `'swap'` and resuming when the new HTML is ready).
**Acceptance criteria:**
- [ ] Clicking a nav link closes six slats from the top, left → right, over ≈0.6 + 5 × 0.05 s with `expo.inOut`.
- [ ] Monogram paths draw in (no pop-in), wordmark rises through its mask, counter reads 00 → 100 in 0.9s.
- [ ] Page content changes only while fully covered; the logo holds 0.3s before exiting (y −20, fade 0.4s).
- [ ] Slats open toward the bottom edge, right → left; new page content rises 40px with a 70ms stagger.
- [ ] A second click during a transition is ignored; the idle auto-cycle (`true`) stops after the first user click.
- [ ] Reduced motion: no curtain, instant swap. No console errors; curtain never blocks clicks when idle.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<div class="clt">
<header class="clt__nav">
<a class="clt__brand" href="#studio" data-page="studio">Halden</a>
<nav class="clt__links" aria-label="Site">
<a href="#studio" data-page="studio" class="is-active" aria-current="page">Studio</a>
<a href="#work" data-page="work">Work</a>
<a href="#journal" data-page="journal">Journal</a>
</nav>
<p class="clt__hint">Click a link ↗</p>
</header>
<main class="clt__stage">
<section class="clt__page is-active" data-page="studio">
<p class="clt__eyebrow" data-reveal>Studio · Est. 2019</p>
<h1 class="clt__title" data-reveal>Quiet interfaces for loud brands.</h1>
<p class="clt__copy" data-reveal>Halden is a nine-person design practice in Oslo. We shape products, identities and the motion that holds them together.</p>
<div class="clt__media clt__media--studio" data-reveal>
<span class="clt__media-label">The workshop, Grünerløkka</span>
</div>
</section>
<section class="clt__page" data-page="work">
<p class="clt__eyebrow" data-reveal>Selected work · 2024—26</p>
<h1 class="clt__title" data-reveal>Three projects, one obsession.</h1>
<div class="clt__grid">
<article class="clt__tile" data-reveal>
<div class="clt__media clt__media--ember"></div>
<h2>Ember Bank</h2><p>Mobile banking · 2026</p>
</article>
<article class="clt__tile" data-reveal>
<div class="clt__media clt__media--moss"></div>
<h2>Moss Botanica</h2><p>Brand system · 2025</p>
</article>
<article class="clt__tile" data-reveal>
<div class="clt__media clt__media--violet"></div>
<h2>Violet Hour</h2><p>Campaign site · 2024</p>
</article>
</div>
</section>
<section class="clt__page" data-page="journal">
<p class="clt__eyebrow" data-reveal>Journal · Notes on motion</p>
<h1 class="clt__title" data-reveal>Why every transition needs a pause.</h1>
<ol class="clt__list">
<li data-reveal><span class="clt__list-num">01</span><span class="clt__list-title">The 300ms hold</span><span class="clt__list-meta">Essay · 6 min</span></li>
<li data-reveal><span class="clt__list-num">02</span><span class="clt__list-title">Slats, wipes and other curtains</span><span class="clt__list-meta">Essay · 9 min</span></li>
<li data-reveal><span class="clt__list-num">03</span><span class="clt__list-title">Counting to one hundred</span><span class="clt__list-meta">Note · 3 min</span></li>
</ol>
</section>
</main>
<div class="clt__curtain" aria-hidden="true">
<div class="clt__slats">
<span class="clt__slat"></span><span class="clt__slat"></span><span class="clt__slat"></span>
<span class="clt__slat"></span><span class="clt__slat"></span><span class="clt__slat"></span>
</div>
<div class="clt__logo">
<svg class="clt__mark" viewBox="0 0 120 120" fill="none" stroke-width="1.5" stroke-linecap="round">
<path d="M22 18 V102" />
<path d="M98 18 V102" />
<path d="M22 60 H98" />
<path d="M60 42 a18 18 0 1 1 -0.01 0" />
</svg>
<span class="clt__wordmark-mask"><span class="clt__wordmark">Halden</span></span>
<span class="clt__counter">00</span>
</div>
</div>
</div>
```
```css
.clt { position: relative; min-height: 100vh; background: var(--bg); color: var(--text); }
/* Nav */
.clt__nav {
position: fixed; inset: 0 0 auto 0; z-index: 20;
display: flex; align-items: center; justify-content: space-between; gap: 1.5rem;
padding: 1.4rem 6vw; border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px);
}
.clt__brand { font-family: var(--font-display); font-weight: 400; font-size: 1.25rem; letter-spacing: -0.03em; text-decoration: none; }
.clt__links { display: flex; gap: clamp(1rem, 3vw, 2.5rem); }
.clt__links a {
position: relative; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em;
color: var(--muted); text-decoration: none; padding-block: .3rem; transition: color .3s;
}
.clt__links a::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--knob-accent, var(--accent)); transform: scaleX(0); transform-origin: left; transition: transform .4s cubic-bezier(.65, 0, .35, 1); }
.clt__links a:hover, .clt__links a.is-active { color: var(--text); }
.clt__links a.is-active::after { transform: scaleX(1); }
.clt__links a:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 4px; }
.clt__hint { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; color: var(--muted); }
/* Pages */
.clt__stage { position: relative; min-height: 100vh; padding: calc(6rem + 4vw) 6vw 8vw; }
.clt__page { display: none; max-width: 1200px; margin-inline: auto; }
.clt__page.is-active { display: block; }
.clt__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.clt__title {
font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 20;
font-size: clamp(2.5rem, 7vw, 6.5rem); line-height: .95; letter-spacing: -0.04em; max-width: 14ch; text-wrap: balance;
}
.clt__copy { max-width: 46ch; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.55; margin-top: 2rem; }
.clt__media {
position: relative; overflow: hidden; border-radius: 10px; background: var(--surface);
background-image:
radial-gradient(90% 70% at 20% 15%, var(--c1), transparent 72%),
radial-gradient(60% 60% at 85% 85%, color-mix(in srgb, var(--c2) 70%, transparent), transparent 72%),
linear-gradient(165deg, color-mix(in srgb, var(--c1) 55%, var(--bg)) 0%, color-mix(in srgb, var(--c2) 18%, var(--bg)) 100%);
}
.clt__media::after {
content: ''; position: absolute; inset: 0; opacity: .35; mix-blend-mode: overlay;
background: repeating-linear-gradient(90deg, rgba(255,255,255,.05) 0 1px, transparent 1px 3px), repeating-linear-gradient(0deg, rgba(255,255,255,.04) 0 1px, transparent 1px 3px);
}
.clt__media--studio { --c1: #3a4a6b; --c2: #6b5a2a; aspect-ratio: 16 / 7; margin-top: 3.5rem; }
.clt__media--ember { --c1: #7a3a2a; --c2: #d9a066; }
.clt__media--moss { --c1: #2a5c48; --c2: #a7c957; }
.clt__media--violet { --c1: #4a2a6b; --c2: #c77dff; }
.clt__media-label { position: absolute; left: 1.25rem; bottom: 1.1rem; font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: rgba(255,255,255,.75); }
.clt__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(1rem, 2.5vw, 2.5rem); margin-top: 3.5rem; }
.clt__tile .clt__media { aspect-ratio: 4 / 5; }
.clt__tile h2 { font-family: var(--font-display); font-weight: 400; font-size: clamp(1.25rem, 1.8vw, 1.75rem); letter-spacing: -0.03em; margin-top: 1rem; }
.clt__tile p { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); margin-top: .35rem; }
.clt__list { list-style: none; margin-top: 3.5rem; border-top: 1px solid var(--line); }
.clt__list li { display: grid; grid-template-columns: 3rem 1fr auto; align-items: baseline; gap: 1.5rem; padding: 1.6rem 0; border-bottom: 1px solid var(--line); }
.clt__list-num { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.clt__list-title { font-family: var(--font-display); font-weight: 300; font-size: clamp(1.4rem, 2.6vw, 2.5rem); letter-spacing: -0.03em; line-height: 1.05; }
.clt__list-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); }
/* Curtain */
.clt__curtain { position: fixed; inset: 0; z-index: 30; pointer-events: none; } /* starts closed; JS opens it */
.clt__slats { position: absolute; inset: 0; display: grid; grid-template-columns: repeat(6, 1fr); }
.clt__slat { display: block; background: var(--text); transform: scaleY(1); transform-origin: top center; will-change: transform; }
.clt__slat + .clt__slat { box-shadow: -1px 0 0 color-mix(in srgb, var(--bg) 18%, transparent); }
.clt__logo { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 1.25rem; color: var(--bg); opacity: 0; visibility: hidden; }
.clt__mark { width: clamp(72px, 9vw, 112px); height: auto; stroke: currentColor; overflow: visible; }
.clt__wordmark-mask { display: block; overflow: hidden; padding-bottom: .1em; }
.clt__wordmark { display: block; font-family: var(--font-display); font-weight: 300; font-size: clamp(2rem, 4vw, 3.25rem); letter-spacing: -0.04em; line-height: 1; }
.clt__counter { font-family: var(--font-mono); font-size: 11px; letter-spacing: .2em; opacity: .55; font-variant-numeric: tabular-nums; }
@media (max-width: 767px) {
.clt__nav { padding: 1.1rem 5vw; }
.clt__hint { display: none; }
.clt__stage { padding: 6.5rem 5vw 12vw; }
.clt__grid { grid-template-columns: 1fr 1fr; }
.clt__tile:last-child { grid-column: 1 / -1; }
.clt__tile:last-child .clt__media { aspect-ratio: 16 / 9; }
.clt__media--studio { aspect-ratio: 4 / 3; }
.clt__list li { grid-template-columns: 2rem 1fr; }
.clt__list-meta { grid-column: 2; }
}
@media (prefers-reduced-motion: reduce) {
.clt__curtain { display: none; }
.clt__links a::after { transition: none; }
}
```
```js
// Curtain + Logo Transition — six slats close over the page, a wordmark draws in, content swaps, slats open from the bottom.
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { slatDuration: 0.6, slatStagger: 0.05, slatEase: 'expo.inOut', hold: 0.3, accent: '#d4ff3a', autoCycle: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const AUTO_CYCLE = 3.2; // seconds of idle before the demo navigates itself
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.clt');
const links = gsap.utils.toArray('.clt__nav a[data-page]');
const pages = gsap.utils.toArray('.clt__page');
const curtain = root.querySelector('.clt__curtain');
const slats = gsap.utils.toArray('.clt__slat');
const logo = root.querySelector('.clt__logo');
const markPaths = gsap.utils.toArray('.clt__mark path');
const wordmark = root.querySelector('.clt__wordmark');
const counterEl = root.querySelector('.clt__counter');
const order = pages.map((p) => p.dataset.page);
let current = 'studio';
let tl = null;
let idle = null;
let userDrove = false;
let ctx = null;
let ac = null; // AbortController: listeners of the current build
const pageEl = (name) => pages.find((p) => p.dataset.page === name);
const revealsOf = (name) => gsap.utils.toArray(pageEl(name).querySelectorAll('[data-reveal]'));
function swapPage(next) {
pages.forEach((p) => p.classList.toggle('is-active', p.dataset.page === next));
links.forEach((a) => {
const active = a.dataset.page === next && a.closest('.clt__links');
a.classList.toggle('is-active', !!active);
if (active) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
});
current = next;
window.scrollTo({ top: 0, behavior: 'instant' });
}
function build() {
// Tear down the previous build: in-flight timeline, pending auto-cycle, listeners, recorded tweens.
tl?.kill(); tl = null;
idle?.kill(); idle = null;
ac?.abort();
ctx?.revert();
ac = new AbortController();
const { signal } = ac;
root.style.setProperty('--knob-accent', K('accent'));
const SLAT_DURATION = K('slatDuration'); // per-slat scaleY travel
const SLAT_STAGGER = K('slatStagger'); // between slats (reversed on open)
const SLAT_EASE = K('slatEase');
const HOLD = K('hold'); // pause with the logo fully drawn
ctx = gsap.context(() => {
if (reduced) {
// Reduced motion: instant swap, no curtain, no counter.
links.forEach((a) => a.addEventListener('click', (e) => { e.preventDefault(); swapPage(a.dataset.page); }, { signal }));
return;
}
const mm = gsap.matchMedia();
let riseY = 40;
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (c) => { riseY = c.conditions.mobile ? 24 : 40; });
const counter = { value: 0 };
const setCounter = () => { counterEl.textContent = String(Math.round(counter.value)).padStart(2, '0'); };
function transition(next, { intro = false } = {}) {
if (tl) tl.kill();
idle?.kill();
counter.value = 0; setCounter();
tl = gsap.timeline({
defaults: { ease: SLAT_EASE },
onComplete: () => { tl = null; gsap.set(curtain, { autoAlpha: 0 }); scheduleAuto(); },
});
// 1. Close: slats grow from the top edge, left → right.
tl.set(curtain, { autoAlpha: 1 })
.set(slats, { transformOrigin: 'top center', scaleY: intro ? 1 : 0 })
.set(logo, { autoAlpha: 0, y: 0 })
.set(wordmark, { yPercent: 110 })
.set(markPaths, { strokeDasharray: (i, el) => el.getTotalLength(), strokeDashoffset: (i, el) => el.getTotalLength() })
.add('close');
if (!intro) tl.to(slats, { scaleY: 1, duration: SLAT_DURATION, stagger: SLAT_STAGGER }, 'close');
// 2. Logo: fade, stroke draw, masked wordmark rise, counter 00 → 100.
tl.add('logo', intro ? 'close' : 'close+=0.55')
.to(logo, { autoAlpha: 1, duration: 0.3, ease: 'power2.out' }, 'logo')
.to(markPaths, { strokeDashoffset: 0, duration: 0.8, ease: 'power3.inOut', stagger: 0.04 }, 'logo')
.to(wordmark, { yPercent: 0, duration: 0.7, ease: 'power3.out' }, 'logo+=0.15')
.to(counter, { value: 100, duration: 0.9, ease: 'power2.inOut', onUpdate: setCounter }, 'logo');
// 3. Swap content underneath while hidden, hold, then the logo exits.
tl.add('swap', 'logo+=0.9').call(swapPage, [next], 'swap')
.add('exit', `swap+=${HOLD}`)
.to(logo, { y: -20, autoAlpha: 0, duration: 0.4, ease: 'power2.in' }, 'exit');
// 4. Open: slats shrink toward the bottom edge, right → left; new content rises.
tl.add('open', 'exit+=0.2')
.set(slats, { transformOrigin: 'bottom center' }, 'open')
.to(slats, { scaleY: 0, duration: SLAT_DURATION, stagger: { each: SLAT_STAGGER, from: 'end' } }, 'open')
.fromTo(revealsOf(next), { y: riseY, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.9, ease: 'power3.out', stagger: 0.07, clearProps: 'all' }, 'open+=0.25');
return tl;
}
function scheduleAuto() {
if (userDrove || !K('autoCycle')) return;
idle = gsap.delayedCall(AUTO_CYCLE, () => {
const next = order[(order.indexOf(current) + 1) % order.length];
transition(next);
});
}
links.forEach((a) => a.addEventListener('click', (e) => {
e.preventDefault();
userDrove = true;
idle?.kill();
const next = a.dataset.page;
if (next === current || tl) return;
transition(next);
}, { signal }));
// Intro: the CSS initial state is "closed" (slats scaleY 1, curtain visible) — draw the logo, open onto the current page.
// A Playground rebuild replays this preloader with the new values.
transition(current, { intro: true });
}, root);
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { tl?.kill(); idle?.kill(); ac?.abort(); ctx?.revert(); });
```
### EFFECT (single HTML file): Curtain + Logo Page Transition
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Curtain + Logo Page Transition** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Curtain + Logo Page Transition</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### EFFECT: Curtain + Logo Page Transition
**What it does (visual):** A small fake site has a fixed top bar (brand name on the left, three mono-spaced nav links on the right — "Studio", "Work", "Journal" — the active one underlined by a 1px `#d4ff3a` line) and a page area with a large light-weight serif headline, a paragraph and a gradient image block. When a nav link is clicked, six full-height vertical slats (each 1/6 of the viewport wide, filled with the text colour so the curtain is an inverted panel) grow downward from the top edge of the screen, left to right, 0.05s apart, each taking 0.6s with the `expo.inOut` ease (slow start, fast middle, slow stop). About half a second in, while the slats are still closing, a thin-stroke monogram (an "H" with a circle through the crossbar) draws itself in the centre of the curtain as four stroked SVG paths, a serif wordmark "Halden" rises out of a mask below it, and a tiny mono counter below that counts from 00 to 100 over 0.9s. Once the counter has finished, the page content behind the curtain is swapped (the user cannot see this), the logo holds for 0.3s, then the logo group drifts up 20px and fades out over 0.4s. The slats then shrink **toward the bottom edge**, right to left, 0.05s apart, revealing the new page, whose eyebrow, headline, copy and media rise 40px into place with a soft deceleration, staggered 70ms. On first load the same sequence plays from the closed state (a preloader): logo draws, counter runs, curtain opens onto the first page. Left idle (and with `true` enabled), the demo navigates itself to the next page every 3.2s until the user clicks a link.
**Tech stack:**
- GSAP 3.13+ core only (no plugins). SVG stroke drawing is done with `strokeDasharray` / `strokeDashoffset` and `path.getTotalLength()`.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`
- npm: `npm i gsap` → `import gsap from "gsap";`
- Fonts: a light variable serif for headlines and wordmark (demo uses Fraunces 300), a monospace for eyebrows/counter, a sans for body.
**HTML structure:**
```html
<div class="clt">
<header class="clt__nav">
<a class="clt__brand" href="#studio" data-page="studio">Halden</a>
<nav class="clt__links">
<a href="#studio" data-page="studio" class="is-active" aria-current="page">Studio</a>
<a href="#work" data-page="work">Work</a>
<a href="#journal" data-page="journal">Journal</a>
</nav>
</header>
<main class="clt__stage">
<section class="clt__page is-active" data-page="studio">
<p class="clt__eyebrow" data-reveal>Studio · Est. 2019</p>
<h1 class="clt__title" data-reveal>Quiet interfaces for loud brands.</h1>
<p class="clt__copy" data-reveal>…</p>
<div class="clt__media" data-reveal></div>
</section>
<section class="clt__page" data-page="work"><!-- eyebrow, title, 3 tiles, each data-reveal --></section>
<section class="clt__page" data-page="journal"><!-- eyebrow, title, 3 list rows, each data-reveal --></section>
</main>
<div class="clt__curtain" aria-hidden="true">
<div class="clt__slats">
<span class="clt__slat"></span><!-- ×6 total -->
</div>
<div class="clt__logo">
<svg class="clt__mark" viewBox="0 0 120 120" fill="none" stroke-width="1.5" stroke-linecap="round">
<path d="M22 18 V102"/><path d="M98 18 V102"/><path d="M22 60 H98"/><path d="M60 42 a18 18 0 1 1 -0.01 0"/>
</svg>
<span class="clt__wordmark-mask"><span class="clt__wordmark">Halden</span></span>
<span class="clt__counter">00</span>
</div>
</div>
</div>
```
Every element that should rise in after the curtain opens carries `data-reveal`.
**CSS requirements:**
- `.clt`: `position: relative; min-height: 100vh; background: var(--bg); color: var(--text);`
- `.clt__nav`: `position: fixed; inset: 0 0 auto 0; z-index: 20; display: flex; justify-content: space-between; align-items: center; padding: 1.4rem 6vw; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px);`
- `.clt__links a`: mono 11px uppercase, `letter-spacing: .12em; color: var(--muted); position: relative;` with `::after { height: 1px; background: var(--accent); /* --accent: #d4ff3a */ transform: scaleX(0); transform-origin: left; transition: transform .4s cubic-bezier(.65,0,.35,1); }` and `.is-active::after { transform: scaleX(1) }`.
- `.clt__stage`: `min-height: 100vh; padding: calc(6rem + 4vw) 6vw 8vw;` — `.clt__page { display: none; max-width: 1200px; margin-inline: auto; } .clt__page.is-active { display: block; }`
- `.clt__title`: display serif, `font-weight: 300; font-size: clamp(2.5rem, 7vw, 6.5rem); line-height: .95; letter-spacing: -0.04em; max-width: 14ch;`
- `.clt__copy`: `max-width: 46ch; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.55; margin-top: 2rem;`
- `.clt__media`: `overflow: hidden; border-radius: 10px;` painted with two radial gradients + a linear gradient from per-tile `--c1/--c2` colours (no image assets); `aspect-ratio: 16/7` on the studio page, `4/5` for the three work tiles (3-column grid, gap `clamp(1rem, 2.5vw, 2.5rem)`).
- `.clt__curtain`: `position: fixed; inset: 0; z-index: 30; pointer-events: none;` — visible on first paint so the page loads covered (preloader state); GSAP toggles `autoAlpha` afterwards.
- `.clt__slats`: `position: absolute; inset: 0; display: grid; grid-template-columns: repeat(6, 1fr);`
- `.clt__slat`: `background: var(--text); transform: scaleY(1); transform-origin: top center; will-change: transform;` (closed by default; the intro timeline opens it) — adjacent slats get `box-shadow: -1px 0 0 color-mix(in srgb, var(--bg) 18%, transparent)` so the seams read as hairlines.
- `.clt__logo`: `position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 1.25rem; color: var(--bg); opacity: 0; visibility: hidden;`
- `.clt__mark`: `width: clamp(72px, 9vw, 112px); stroke: currentColor;` — `.clt__wordmark-mask { overflow: hidden }`, `.clt__wordmark { font-weight: 300; font-size: clamp(2rem, 4vw, 3.25rem); letter-spacing: -0.04em; line-height: 1; }`, `.clt__counter { mono 11px; letter-spacing: .2em; opacity: .55; font-variant-numeric: tabular-nums; }`
- Light theme is automatic: slats use `var(--text)` and the logo uses `var(--bg)`, so the curtain is always the inverse of the page.
**Animation spec (exact values):**
Constants: `SLAT_DURATION = 0.6`, `SLAT_STAGGER = 0.05`, `SLAT_EASE = 'expo.inOut'`, `HOLD = 0.3`, `AUTO_CYCLE = 3.2` (only when `true` is true), `riseY = 40` (24 under 768px), accent colour `#d4ff3a`.
`transition(next, { intro })` builds one timeline (`defaults: { ease: 'expo.inOut' }`), killing any previous one:
1. Setup sets (0s): `curtain { autoAlpha: 1 }`; `slats { transformOrigin: 'top center', scaleY: intro ? 1 : 0 }`; `logo { autoAlpha: 0, y: 0 }`; `wordmark { yPercent: 110 }`; each mark path `{ strokeDasharray: L, strokeDashoffset: L }` where `L = path.getTotalLength()`. Add label `'close'`.
2. **Close** (skipped when `intro`): `tl.to(slats, { scaleY: 1, duration: 0.6, stagger: 0.05 }, 'close')` — six slats, scaleY 0 → 1 from the top, left → right; the last slat finishes at `0.6 + 5 × 0.05` s.
3. Label `'logo'` at `'close+=0.55'` (or `'close'` when intro):
- `tl.to(logo, { autoAlpha: 1, duration: 0.3, ease: 'power2.out' }, 'logo')`
- `tl.to(markPaths, { strokeDashoffset: 0, duration: 0.8, ease: 'power3.inOut', stagger: 0.04 }, 'logo')` — four paths draw from L → 0.
- `tl.to(wordmark, { yPercent: 0, duration: 0.7, ease: 'power3.out' }, 'logo+=0.15')` — rises through the mask.
- `tl.to(counter, { value: 100, duration: 0.9, ease: 'power2.inOut', onUpdate: setCounter }, 'logo')` — `counter = { value: 0 }`; `setCounter` writes `String(Math.round(value)).padStart(2, '0')`.
4. Label `'swap'` at `'logo+=0.9'`: `tl.call(swapPage, [next], 'swap')` — toggles `.is-active` on pages and nav links, sets `aria-current`, scrolls to top. The screen is fully covered at this moment.
5. Label `'exit'` at `'swap+=0.3'` (the HOLD): `tl.to(logo, { y: -20, autoAlpha: 0, duration: 0.4, ease: 'power2.in' }, 'exit')`.
6. Label `'open'` at `'exit+=0.2'`: `tl.set(slats, { transformOrigin: 'bottom center' }, 'open')` then `tl.to(slats, { scaleY: 0, duration: 0.6, stagger: { each: 0.05, from: 'end' } }, 'open')` — scaleY 1 → 0 toward the bottom, right → left.
7. New content: `tl.fromTo(next page's [data-reveal], { y: riseY, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.9, ease: 'power3.out', stagger: 0.07, clearProps: 'all' }, 'open+=0.25')` — use `fromTo`, not `from`, so a pre-hidden element never records `0` as its end value.
8. `onComplete`: `gsap.set(curtain, { autoAlpha: 0 })`, then — if `true` is true — `gsap.delayedCall(3.2, () => transition(nextInOrder))` unless the user has clicked a link.
Intro: the CSS ships the curtain closed (slats `scaleY(1)`), so on load call `transition('studio', { intro: true })` immediately — total ≈ 2.1s + 0.3; a normal navigation adds the close (0.6 + 5 × 0.05 s, overlapped by 0.55s).
**Interaction:** Clicking any nav link (or the brand) calls `e.preventDefault()`, marks the demo as user-driven (stops the auto cycle) and runs `transition(page)` unless that page is already active or a transition is running. Nav links underline on hover/active via the `::after` scaleX transition (0.4s). No scroll dependency.
**Responsive behavior:** `gsap.matchMedia()` with `(max-width: 767px)` reduces the content rise from 40px to 24px; the six slats simply become narrower (grid keeps 6 columns). Under 768px the work grid becomes 2 columns (third tile spans full width at 16/9), the hint text in the nav is hidden, and the journal rows collapse to two columns. Touch works identically — the effect is click-driven.
**Accessibility:** With `prefers-reduced-motion: reduce` the curtain is `display: none` and links swap pages instantly (`swapPage` only — no counter, no slats, no rise). The curtain is `aria-hidden="true"`; the active link carries `aria-current="page"`. Links keep a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 4px`). Content is swapped while covered, so there is no half-rendered state exposed to screen readers mid-transition.
**Performance rules:** Only `transform`, `opacity`/`visibility` and SVG `stroke-dashoffset` are animated. `will-change: transform` lives on the six slats only. The curtain is `pointer-events: none` and `visibility: hidden` when idle so it never intercepts input or paints. Everything is created inside `gsap.context(…, root)` and reverted on unmount (`ctx.revert()`), and the pending `delayedCall` is killed. Because the auto cycle uses `gsap.delayedCall`, it pauses with `gsap.globalTimeline` when the host pauses the demo.
**Customization knobs:**
- `SLAT_DURATION` (0.6) / `SLAT_STAGGER` (0.05) — curtain speed and the diagonal of the wipe; 8–10 slats with 0.035 stagger gives a finer blind.
- `SLAT_EASE` (`expo.inOut`) — `power4.inOut` is softer, `expo.in` for a slam.
- `HOLD` (0.3) — the pause with the logo fully drawn.
- `--accent` (#d4ff3a) — the nav underline and focus ring.
- Slat colour (`var(--text)`) — swap for `var(--accent)` for a brand-coloured curtain and set `.clt__logo { color: var(--bg) }` accordingly.
- `AUTO_CYCLE` (true, every 3.2s) — disable / remove `scheduleAuto()` in production.
- Replace `swapPage` with your router's navigation (await the fetch before the `'exit'` label by pausing the timeline at `'swap'` and resuming when the new HTML is ready).
**Acceptance criteria:**
- [ ] Clicking a nav link closes six slats from the top, left → right, over ≈0.6 + 5 × 0.05 s with `expo.inOut`.
- [ ] Monogram paths draw in (no pop-in), wordmark rises through its mask, counter reads 00 → 100 in 0.9s.
- [ ] Page content changes only while fully covered; the logo holds 0.3s before exiting (y −20, fade 0.4s).
- [ ] Slats open toward the bottom edge, right → left; new page content rises 40px with a 70ms stagger.
- [ ] A second click during a transition is ignored; the idle auto-cycle (`true`) stops after the first user click.
- [ ] Reduced motion: no curtain, instant swap. No console errors; curtain never blocks clicks when idle.
---
**Reference implementation (use these exact values and class names):**
```html
<div class="clt">
<header class="clt__nav">
<a class="clt__brand" href="#studio" data-page="studio">Halden</a>
<nav class="clt__links" aria-label="Site">
<a href="#studio" data-page="studio" class="is-active" aria-current="page">Studio</a>
<a href="#work" data-page="work">Work</a>
<a href="#journal" data-page="journal">Journal</a>
</nav>
<p class="clt__hint">Click a link ↗</p>
</header>
<main class="clt__stage">
<section class="clt__page is-active" data-page="studio">
<p class="clt__eyebrow" data-reveal>Studio · Est. 2019</p>
<h1 class="clt__title" data-reveal>Quiet interfaces for loud brands.</h1>
<p class="clt__copy" data-reveal>Halden is a nine-person design practice in Oslo. We shape products, identities and the motion that holds them together.</p>
<div class="clt__media clt__media--studio" data-reveal>
<span class="clt__media-label">The workshop, Grünerløkka</span>
</div>
</section>
<section class="clt__page" data-page="work">
<p class="clt__eyebrow" data-reveal>Selected work · 2024—26</p>
<h1 class="clt__title" data-reveal>Three projects, one obsession.</h1>
<div class="clt__grid">
<article class="clt__tile" data-reveal>
<div class="clt__media clt__media--ember"></div>
<h2>Ember Bank</h2><p>Mobile banking · 2026</p>
</article>
<article class="clt__tile" data-reveal>
<div class="clt__media clt__media--moss"></div>
<h2>Moss Botanica</h2><p>Brand system · 2025</p>
</article>
<article class="clt__tile" data-reveal>
<div class="clt__media clt__media--violet"></div>
<h2>Violet Hour</h2><p>Campaign site · 2024</p>
</article>
</div>
</section>
<section class="clt__page" data-page="journal">
<p class="clt__eyebrow" data-reveal>Journal · Notes on motion</p>
<h1 class="clt__title" data-reveal>Why every transition needs a pause.</h1>
<ol class="clt__list">
<li data-reveal><span class="clt__list-num">01</span><span class="clt__list-title">The 300ms hold</span><span class="clt__list-meta">Essay · 6 min</span></li>
<li data-reveal><span class="clt__list-num">02</span><span class="clt__list-title">Slats, wipes and other curtains</span><span class="clt__list-meta">Essay · 9 min</span></li>
<li data-reveal><span class="clt__list-num">03</span><span class="clt__list-title">Counting to one hundred</span><span class="clt__list-meta">Note · 3 min</span></li>
</ol>
</section>
</main>
<div class="clt__curtain" aria-hidden="true">
<div class="clt__slats">
<span class="clt__slat"></span><span class="clt__slat"></span><span class="clt__slat"></span>
<span class="clt__slat"></span><span class="clt__slat"></span><span class="clt__slat"></span>
</div>
<div class="clt__logo">
<svg class="clt__mark" viewBox="0 0 120 120" fill="none" stroke-width="1.5" stroke-linecap="round">
<path d="M22 18 V102" />
<path d="M98 18 V102" />
<path d="M22 60 H98" />
<path d="M60 42 a18 18 0 1 1 -0.01 0" />
</svg>
<span class="clt__wordmark-mask"><span class="clt__wordmark">Halden</span></span>
<span class="clt__counter">00</span>
</div>
</div>
</div>
```
```css
.clt { position: relative; min-height: 100vh; background: var(--bg); color: var(--text); }
/* Nav */
.clt__nav {
position: fixed; inset: 0 0 auto 0; z-index: 20;
display: flex; align-items: center; justify-content: space-between; gap: 1.5rem;
padding: 1.4rem 6vw; border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px);
}
.clt__brand { font-family: var(--font-display); font-weight: 400; font-size: 1.25rem; letter-spacing: -0.03em; text-decoration: none; }
.clt__links { display: flex; gap: clamp(1rem, 3vw, 2.5rem); }
.clt__links a {
position: relative; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em;
color: var(--muted); text-decoration: none; padding-block: .3rem; transition: color .3s;
}
.clt__links a::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--knob-accent, var(--accent)); transform: scaleX(0); transform-origin: left; transition: transform .4s cubic-bezier(.65, 0, .35, 1); }
.clt__links a:hover, .clt__links a.is-active { color: var(--text); }
.clt__links a.is-active::after { transform: scaleX(1); }
.clt__links a:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 4px; }
.clt__hint { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; color: var(--muted); }
/* Pages */
.clt__stage { position: relative; min-height: 100vh; padding: calc(6rem + 4vw) 6vw 8vw; }
.clt__page { display: none; max-width: 1200px; margin-inline: auto; }
.clt__page.is-active { display: block; }
.clt__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.clt__title {
font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 20;
font-size: clamp(2.5rem, 7vw, 6.5rem); line-height: .95; letter-spacing: -0.04em; max-width: 14ch; text-wrap: balance;
}
.clt__copy { max-width: 46ch; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.55; margin-top: 2rem; }
.clt__media {
position: relative; overflow: hidden; border-radius: 10px; background: var(--surface);
background-image:
radial-gradient(90% 70% at 20% 15%, var(--c1), transparent 72%),
radial-gradient(60% 60% at 85% 85%, color-mix(in srgb, var(--c2) 70%, transparent), transparent 72%),
linear-gradient(165deg, color-mix(in srgb, var(--c1) 55%, var(--bg)) 0%, color-mix(in srgb, var(--c2) 18%, var(--bg)) 100%);
}
.clt__media::after {
content: ''; position: absolute; inset: 0; opacity: .35; mix-blend-mode: overlay;
background: repeating-linear-gradient(90deg, rgba(255,255,255,.05) 0 1px, transparent 1px 3px), repeating-linear-gradient(0deg, rgba(255,255,255,.04) 0 1px, transparent 1px 3px);
}
.clt__media--studio { --c1: #3a4a6b; --c2: #6b5a2a; aspect-ratio: 16 / 7; margin-top: 3.5rem; }
.clt__media--ember { --c1: #7a3a2a; --c2: #d9a066; }
.clt__media--moss { --c1: #2a5c48; --c2: #a7c957; }
.clt__media--violet { --c1: #4a2a6b; --c2: #c77dff; }
.clt__media-label { position: absolute; left: 1.25rem; bottom: 1.1rem; font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: rgba(255,255,255,.75); }
.clt__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(1rem, 2.5vw, 2.5rem); margin-top: 3.5rem; }
.clt__tile .clt__media { aspect-ratio: 4 / 5; }
.clt__tile h2 { font-family: var(--font-display); font-weight: 400; font-size: clamp(1.25rem, 1.8vw, 1.75rem); letter-spacing: -0.03em; margin-top: 1rem; }
.clt__tile p { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); margin-top: .35rem; }
.clt__list { list-style: none; margin-top: 3.5rem; border-top: 1px solid var(--line); }
.clt__list li { display: grid; grid-template-columns: 3rem 1fr auto; align-items: baseline; gap: 1.5rem; padding: 1.6rem 0; border-bottom: 1px solid var(--line); }
.clt__list-num { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.clt__list-title { font-family: var(--font-display); font-weight: 300; font-size: clamp(1.4rem, 2.6vw, 2.5rem); letter-spacing: -0.03em; line-height: 1.05; }
.clt__list-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); }
/* Curtain */
.clt__curtain { position: fixed; inset: 0; z-index: 30; pointer-events: none; } /* starts closed; JS opens it */
.clt__slats { position: absolute; inset: 0; display: grid; grid-template-columns: repeat(6, 1fr); }
.clt__slat { display: block; background: var(--text); transform: scaleY(1); transform-origin: top center; will-change: transform; }
.clt__slat + .clt__slat { box-shadow: -1px 0 0 color-mix(in srgb, var(--bg) 18%, transparent); }
.clt__logo { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 1.25rem; color: var(--bg); opacity: 0; visibility: hidden; }
.clt__mark { width: clamp(72px, 9vw, 112px); height: auto; stroke: currentColor; overflow: visible; }
.clt__wordmark-mask { display: block; overflow: hidden; padding-bottom: .1em; }
.clt__wordmark { display: block; font-family: var(--font-display); font-weight: 300; font-size: clamp(2rem, 4vw, 3.25rem); letter-spacing: -0.04em; line-height: 1; }
.clt__counter { font-family: var(--font-mono); font-size: 11px; letter-spacing: .2em; opacity: .55; font-variant-numeric: tabular-nums; }
@media (max-width: 767px) {
.clt__nav { padding: 1.1rem 5vw; }
.clt__hint { display: none; }
.clt__stage { padding: 6.5rem 5vw 12vw; }
.clt__grid { grid-template-columns: 1fr 1fr; }
.clt__tile:last-child { grid-column: 1 / -1; }
.clt__tile:last-child .clt__media { aspect-ratio: 16 / 9; }
.clt__media--studio { aspect-ratio: 4 / 3; }
.clt__list li { grid-template-columns: 2rem 1fr; }
.clt__list-meta { grid-column: 2; }
}
@media (prefers-reduced-motion: reduce) {
.clt__curtain { display: none; }
.clt__links a::after { transition: none; }
}
```
```js
// Curtain + Logo Transition — six slats close over the page, a wordmark draws in, content swaps, slats open from the bottom.
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { slatDuration: 0.6, slatStagger: 0.05, slatEase: 'expo.inOut', hold: 0.3, accent: '#d4ff3a', autoCycle: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const AUTO_CYCLE = 3.2; // seconds of idle before the demo navigates itself
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.clt');
const links = gsap.utils.toArray('.clt__nav a[data-page]');
const pages = gsap.utils.toArray('.clt__page');
const curtain = root.querySelector('.clt__curtain');
const slats = gsap.utils.toArray('.clt__slat');
const logo = root.querySelector('.clt__logo');
const markPaths = gsap.utils.toArray('.clt__mark path');
const wordmark = root.querySelector('.clt__wordmark');
const counterEl = root.querySelector('.clt__counter');
const order = pages.map((p) => p.dataset.page);
let current = 'studio';
let tl = null;
let idle = null;
let userDrove = false;
let ctx = null;
let ac = null; // AbortController: listeners of the current build
const pageEl = (name) => pages.find((p) => p.dataset.page === name);
const revealsOf = (name) => gsap.utils.toArray(pageEl(name).querySelectorAll('[data-reveal]'));
function swapPage(next) {
pages.forEach((p) => p.classList.toggle('is-active', p.dataset.page === next));
links.forEach((a) => {
const active = a.dataset.page === next && a.closest('.clt__links');
a.classList.toggle('is-active', !!active);
if (active) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
});
current = next;
window.scrollTo({ top: 0, behavior: 'instant' });
}
function build() {
// Tear down the previous build: in-flight timeline, pending auto-cycle, listeners, recorded tweens.
tl?.kill(); tl = null;
idle?.kill(); idle = null;
ac?.abort();
ctx?.revert();
ac = new AbortController();
const { signal } = ac;
root.style.setProperty('--knob-accent', K('accent'));
const SLAT_DURATION = K('slatDuration'); // per-slat scaleY travel
const SLAT_STAGGER = K('slatStagger'); // between slats (reversed on open)
const SLAT_EASE = K('slatEase');
const HOLD = K('hold'); // pause with the logo fully drawn
ctx = gsap.context(() => {
if (reduced) {
// Reduced motion: instant swap, no curtain, no counter.
links.forEach((a) => a.addEventListener('click', (e) => { e.preventDefault(); swapPage(a.dataset.page); }, { signal }));
return;
}
const mm = gsap.matchMedia();
let riseY = 40;
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (c) => { riseY = c.conditions.mobile ? 24 : 40; });
const counter = { value: 0 };
const setCounter = () => { counterEl.textContent = String(Math.round(counter.value)).padStart(2, '0'); };
function transition(next, { intro = false } = {}) {
if (tl) tl.kill();
idle?.kill();
counter.value = 0; setCounter();
tl = gsap.timeline({
defaults: { ease: SLAT_EASE },
onComplete: () => { tl = null; gsap.set(curtain, { autoAlpha: 0 }); scheduleAuto(); },
});
// 1. Close: slats grow from the top edge, left → right.
tl.set(curtain, { autoAlpha: 1 })
.set(slats, { transformOrigin: 'top center', scaleY: intro ? 1 : 0 })
.set(logo, { autoAlpha: 0, y: 0 })
.set(wordmark, { yPercent: 110 })
.set(markPaths, { strokeDasharray: (i, el) => el.getTotalLength(), strokeDashoffset: (i, el) => el.getTotalLength() })
.add('close');
if (!intro) tl.to(slats, { scaleY: 1, duration: SLAT_DURATION, stagger: SLAT_STAGGER }, 'close');
// 2. Logo: fade, stroke draw, masked wordmark rise, counter 00 → 100.
tl.add('logo', intro ? 'close' : 'close+=0.55')
.to(logo, { autoAlpha: 1, duration: 0.3, ease: 'power2.out' }, 'logo')
.to(markPaths, { strokeDashoffset: 0, duration: 0.8, ease: 'power3.inOut', stagger: 0.04 }, 'logo')
.to(wordmark, { yPercent: 0, duration: 0.7, ease: 'power3.out' }, 'logo+=0.15')
.to(counter, { value: 100, duration: 0.9, ease: 'power2.inOut', onUpdate: setCounter }, 'logo');
// 3. Swap content underneath while hidden, hold, then the logo exits.
tl.add('swap', 'logo+=0.9').call(swapPage, [next], 'swap')
.add('exit', `swap+=${HOLD}`)
.to(logo, { y: -20, autoAlpha: 0, duration: 0.4, ease: 'power2.in' }, 'exit');
// 4. Open: slats shrink toward the bottom edge, right → left; new content rises.
tl.add('open', 'exit+=0.2')
.set(slats, { transformOrigin: 'bottom center' }, 'open')
.to(slats, { scaleY: 0, duration: SLAT_DURATION, stagger: { each: SLAT_STAGGER, from: 'end' } }, 'open')
.fromTo(revealsOf(next), { y: riseY, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.9, ease: 'power3.out', stagger: 0.07, clearProps: 'all' }, 'open+=0.25');
return tl;
}
function scheduleAuto() {
if (userDrove || !K('autoCycle')) return;
idle = gsap.delayedCall(AUTO_CYCLE, () => {
const next = order[(order.indexOf(current) + 1) % order.length];
transition(next);
});
}
links.forEach((a) => a.addEventListener('click', (e) => {
e.preventDefault();
userDrove = true;
idle?.kill();
const next = a.dataset.page;
if (next === current || tl) return;
transition(next);
}, { signal }));
// Intro: the CSS initial state is "closed" (slats scaleY 1, curtain visible) — draw the logo, open onto the current page.
// A Playground rebuild replays this preloader with the new values.
transition(current, { intro: true });
}, root);
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { tl?.kill(); idle?.kill(); ac?.abort(); ctx?.revert(); });
```
### EFFECT (Framer & Webflow notes): Curtain + Logo Page Transition
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withCurtainLogoPageTransition(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `clt`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### EFFECT: Curtain + Logo Page Transition
**What it does (visual):** A small fake site has a fixed top bar (brand name on the left, three mono-spaced nav links on the right — "Studio", "Work", "Journal" — the active one underlined by a 1px `#d4ff3a` line) and a page area with a large light-weight serif headline, a paragraph and a gradient image block. When a nav link is clicked, six full-height vertical slats (each 1/6 of the viewport wide, filled with the text colour so the curtain is an inverted panel) grow downward from the top edge of the screen, left to right, 0.05s apart, each taking 0.6s with the `expo.inOut` ease (slow start, fast middle, slow stop). About half a second in, while the slats are still closing, a thin-stroke monogram (an "H" with a circle through the crossbar) draws itself in the centre of the curtain as four stroked SVG paths, a serif wordmark "Halden" rises out of a mask below it, and a tiny mono counter below that counts from 00 to 100 over 0.9s. Once the counter has finished, the page content behind the curtain is swapped (the user cannot see this), the logo holds for 0.3s, then the logo group drifts up 20px and fades out over 0.4s. The slats then shrink **toward the bottom edge**, right to left, 0.05s apart, revealing the new page, whose eyebrow, headline, copy and media rise 40px into place with a soft deceleration, staggered 70ms. On first load the same sequence plays from the closed state (a preloader): logo draws, counter runs, curtain opens onto the first page. Left idle (and with `true` enabled), the demo navigates itself to the next page every 3.2s until the user clicks a link.
**Tech stack:**
- GSAP 3.13+ core only (no plugins). SVG stroke drawing is done with `strokeDasharray` / `strokeDashoffset` and `path.getTotalLength()`.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`
- npm: `npm i gsap` → `import gsap from "gsap";`
- Fonts: a light variable serif for headlines and wordmark (demo uses Fraunces 300), a monospace for eyebrows/counter, a sans for body.
**HTML structure:**
```html
<div class="clt">
<header class="clt__nav">
<a class="clt__brand" href="#studio" data-page="studio">Halden</a>
<nav class="clt__links">
<a href="#studio" data-page="studio" class="is-active" aria-current="page">Studio</a>
<a href="#work" data-page="work">Work</a>
<a href="#journal" data-page="journal">Journal</a>
</nav>
</header>
<main class="clt__stage">
<section class="clt__page is-active" data-page="studio">
<p class="clt__eyebrow" data-reveal>Studio · Est. 2019</p>
<h1 class="clt__title" data-reveal>Quiet interfaces for loud brands.</h1>
<p class="clt__copy" data-reveal>…</p>
<div class="clt__media" data-reveal></div>
</section>
<section class="clt__page" data-page="work"><!-- eyebrow, title, 3 tiles, each data-reveal --></section>
<section class="clt__page" data-page="journal"><!-- eyebrow, title, 3 list rows, each data-reveal --></section>
</main>
<div class="clt__curtain" aria-hidden="true">
<div class="clt__slats">
<span class="clt__slat"></span><!-- ×6 total -->
</div>
<div class="clt__logo">
<svg class="clt__mark" viewBox="0 0 120 120" fill="none" stroke-width="1.5" stroke-linecap="round">
<path d="M22 18 V102"/><path d="M98 18 V102"/><path d="M22 60 H98"/><path d="M60 42 a18 18 0 1 1 -0.01 0"/>
</svg>
<span class="clt__wordmark-mask"><span class="clt__wordmark">Halden</span></span>
<span class="clt__counter">00</span>
</div>
</div>
</div>
```
Every element that should rise in after the curtain opens carries `data-reveal`.
**CSS requirements:**
- `.clt`: `position: relative; min-height: 100vh; background: var(--bg); color: var(--text);`
- `.clt__nav`: `position: fixed; inset: 0 0 auto 0; z-index: 20; display: flex; justify-content: space-between; align-items: center; padding: 1.4rem 6vw; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px);`
- `.clt__links a`: mono 11px uppercase, `letter-spacing: .12em; color: var(--muted); position: relative;` with `::after { height: 1px; background: var(--accent); /* --accent: #d4ff3a */ transform: scaleX(0); transform-origin: left; transition: transform .4s cubic-bezier(.65,0,.35,1); }` and `.is-active::after { transform: scaleX(1) }`.
- `.clt__stage`: `min-height: 100vh; padding: calc(6rem + 4vw) 6vw 8vw;` — `.clt__page { display: none; max-width: 1200px; margin-inline: auto; } .clt__page.is-active { display: block; }`
- `.clt__title`: display serif, `font-weight: 300; font-size: clamp(2.5rem, 7vw, 6.5rem); line-height: .95; letter-spacing: -0.04em; max-width: 14ch;`
- `.clt__copy`: `max-width: 46ch; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.55; margin-top: 2rem;`
- `.clt__media`: `overflow: hidden; border-radius: 10px;` painted with two radial gradients + a linear gradient from per-tile `--c1/--c2` colours (no image assets); `aspect-ratio: 16/7` on the studio page, `4/5` for the three work tiles (3-column grid, gap `clamp(1rem, 2.5vw, 2.5rem)`).
- `.clt__curtain`: `position: fixed; inset: 0; z-index: 30; pointer-events: none;` — visible on first paint so the page loads covered (preloader state); GSAP toggles `autoAlpha` afterwards.
- `.clt__slats`: `position: absolute; inset: 0; display: grid; grid-template-columns: repeat(6, 1fr);`
- `.clt__slat`: `background: var(--text); transform: scaleY(1); transform-origin: top center; will-change: transform;` (closed by default; the intro timeline opens it) — adjacent slats get `box-shadow: -1px 0 0 color-mix(in srgb, var(--bg) 18%, transparent)` so the seams read as hairlines.
- `.clt__logo`: `position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 1.25rem; color: var(--bg); opacity: 0; visibility: hidden;`
- `.clt__mark`: `width: clamp(72px, 9vw, 112px); stroke: currentColor;` — `.clt__wordmark-mask { overflow: hidden }`, `.clt__wordmark { font-weight: 300; font-size: clamp(2rem, 4vw, 3.25rem); letter-spacing: -0.04em; line-height: 1; }`, `.clt__counter { mono 11px; letter-spacing: .2em; opacity: .55; font-variant-numeric: tabular-nums; }`
- Light theme is automatic: slats use `var(--text)` and the logo uses `var(--bg)`, so the curtain is always the inverse of the page.
**Animation spec (exact values):**
Constants: `SLAT_DURATION = 0.6`, `SLAT_STAGGER = 0.05`, `SLAT_EASE = 'expo.inOut'`, `HOLD = 0.3`, `AUTO_CYCLE = 3.2` (only when `true` is true), `riseY = 40` (24 under 768px), accent colour `#d4ff3a`.
`transition(next, { intro })` builds one timeline (`defaults: { ease: 'expo.inOut' }`), killing any previous one:
1. Setup sets (0s): `curtain { autoAlpha: 1 }`; `slats { transformOrigin: 'top center', scaleY: intro ? 1 : 0 }`; `logo { autoAlpha: 0, y: 0 }`; `wordmark { yPercent: 110 }`; each mark path `{ strokeDasharray: L, strokeDashoffset: L }` where `L = path.getTotalLength()`. Add label `'close'`.
2. **Close** (skipped when `intro`): `tl.to(slats, { scaleY: 1, duration: 0.6, stagger: 0.05 }, 'close')` — six slats, scaleY 0 → 1 from the top, left → right; the last slat finishes at `0.6 + 5 × 0.05` s.
3. Label `'logo'` at `'close+=0.55'` (or `'close'` when intro):
- `tl.to(logo, { autoAlpha: 1, duration: 0.3, ease: 'power2.out' }, 'logo')`
- `tl.to(markPaths, { strokeDashoffset: 0, duration: 0.8, ease: 'power3.inOut', stagger: 0.04 }, 'logo')` — four paths draw from L → 0.
- `tl.to(wordmark, { yPercent: 0, duration: 0.7, ease: 'power3.out' }, 'logo+=0.15')` — rises through the mask.
- `tl.to(counter, { value: 100, duration: 0.9, ease: 'power2.inOut', onUpdate: setCounter }, 'logo')` — `counter = { value: 0 }`; `setCounter` writes `String(Math.round(value)).padStart(2, '0')`.
4. Label `'swap'` at `'logo+=0.9'`: `tl.call(swapPage, [next], 'swap')` — toggles `.is-active` on pages and nav links, sets `aria-current`, scrolls to top. The screen is fully covered at this moment.
5. Label `'exit'` at `'swap+=0.3'` (the HOLD): `tl.to(logo, { y: -20, autoAlpha: 0, duration: 0.4, ease: 'power2.in' }, 'exit')`.
6. Label `'open'` at `'exit+=0.2'`: `tl.set(slats, { transformOrigin: 'bottom center' }, 'open')` then `tl.to(slats, { scaleY: 0, duration: 0.6, stagger: { each: 0.05, from: 'end' } }, 'open')` — scaleY 1 → 0 toward the bottom, right → left.
7. New content: `tl.fromTo(next page's [data-reveal], { y: riseY, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.9, ease: 'power3.out', stagger: 0.07, clearProps: 'all' }, 'open+=0.25')` — use `fromTo`, not `from`, so a pre-hidden element never records `0` as its end value.
8. `onComplete`: `gsap.set(curtain, { autoAlpha: 0 })`, then — if `true` is true — `gsap.delayedCall(3.2, () => transition(nextInOrder))` unless the user has clicked a link.
Intro: the CSS ships the curtain closed (slats `scaleY(1)`), so on load call `transition('studio', { intro: true })` immediately — total ≈ 2.1s + 0.3; a normal navigation adds the close (0.6 + 5 × 0.05 s, overlapped by 0.55s).
**Interaction:** Clicking any nav link (or the brand) calls `e.preventDefault()`, marks the demo as user-driven (stops the auto cycle) and runs `transition(page)` unless that page is already active or a transition is running. Nav links underline on hover/active via the `::after` scaleX transition (0.4s). No scroll dependency.
**Responsive behavior:** `gsap.matchMedia()` with `(max-width: 767px)` reduces the content rise from 40px to 24px; the six slats simply become narrower (grid keeps 6 columns). Under 768px the work grid becomes 2 columns (third tile spans full width at 16/9), the hint text in the nav is hidden, and the journal rows collapse to two columns. Touch works identically — the effect is click-driven.
**Accessibility:** With `prefers-reduced-motion: reduce` the curtain is `display: none` and links swap pages instantly (`swapPage` only — no counter, no slats, no rise). The curtain is `aria-hidden="true"`; the active link carries `aria-current="page"`. Links keep a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 4px`). Content is swapped while covered, so there is no half-rendered state exposed to screen readers mid-transition.
**Performance rules:** Only `transform`, `opacity`/`visibility` and SVG `stroke-dashoffset` are animated. `will-change: transform` lives on the six slats only. The curtain is `pointer-events: none` and `visibility: hidden` when idle so it never intercepts input or paints. Everything is created inside `gsap.context(…, root)` and reverted on unmount (`ctx.revert()`), and the pending `delayedCall` is killed. Because the auto cycle uses `gsap.delayedCall`, it pauses with `gsap.globalTimeline` when the host pauses the demo.
**Customization knobs:**
- `SLAT_DURATION` (0.6) / `SLAT_STAGGER` (0.05) — curtain speed and the diagonal of the wipe; 8–10 slats with 0.035 stagger gives a finer blind.
- `SLAT_EASE` (`expo.inOut`) — `power4.inOut` is softer, `expo.in` for a slam.
- `HOLD` (0.3) — the pause with the logo fully drawn.
- `--accent` (#d4ff3a) — the nav underline and focus ring.
- Slat colour (`var(--text)`) — swap for `var(--accent)` for a brand-coloured curtain and set `.clt__logo { color: var(--bg) }` accordingly.
- `AUTO_CYCLE` (true, every 3.2s) — disable / remove `scheduleAuto()` in production.
- Replace `swapPage` with your router's navigation (await the fetch before the `'exit'` label by pausing the timeline at `'swap'` and resuming when the new HTML is ready).
**Acceptance criteria:**
- [ ] Clicking a nav link closes six slats from the top, left → right, over ≈0.6 + 5 × 0.05 s with `expo.inOut`.
- [ ] Monogram paths draw in (no pop-in), wordmark rises through its mask, counter reads 00 → 100 in 0.9s.
- [ ] Page content changes only while fully covered; the logo holds 0.3s before exiting (y −20, fade 0.4s).
- [ ] Slats open toward the bottom edge, right → left; new page content rises 40px with a 70ms stagger.
- [ ] A second click during a transition is ignored; the idle auto-cycle (`true`) stops after the first user click.
- [ ] Reduced motion: no curtain, instant swap. No console errors; curtain never blocks clicks when idle.
Source
Full source code
<div class="clt">
<header class="clt__nav">
<a class="clt__brand" href="#studio" data-page="studio">Halden</a>
<nav class="clt__links" aria-label="Site">
<a href="#studio" data-page="studio" class="is-active" aria-current="page">Studio</a>
<a href="#work" data-page="work">Work</a>
<a href="#journal" data-page="journal">Journal</a>
</nav>
<p class="clt__hint">Click a link ↗</p>
</header>
<main class="clt__stage">
<section class="clt__page is-active" data-page="studio">
<p class="clt__eyebrow" data-reveal>Studio · Est. 2019</p>
<h1 class="clt__title" data-reveal>Quiet interfaces for loud brands.</h1>
<p class="clt__copy" data-reveal>Halden is a nine-person design practice in Oslo. We shape products, identities and the motion that holds them together.</p>
<div class="clt__media clt__media--studio" data-reveal>
<span class="clt__media-label">The workshop, Grünerløkka</span>
</div>
</section>
<section class="clt__page" data-page="work">
<p class="clt__eyebrow" data-reveal>Selected work · 2024—26</p>
<h1 class="clt__title" data-reveal>Three projects, one obsession.</h1>
<div class="clt__grid">
<article class="clt__tile" data-reveal>
<div class="clt__media clt__media--ember"></div>
<h2>Ember Bank</h2><p>Mobile banking · 2026</p>
</article>
<article class="clt__tile" data-reveal>
<div class="clt__media clt__media--moss"></div>
<h2>Moss Botanica</h2><p>Brand system · 2025</p>
</article>
<article class="clt__tile" data-reveal>
<div class="clt__media clt__media--violet"></div>
<h2>Violet Hour</h2><p>Campaign site · 2024</p>
</article>
</div>
</section>
<section class="clt__page" data-page="journal">
<p class="clt__eyebrow" data-reveal>Journal · Notes on motion</p>
<h1 class="clt__title" data-reveal>Why every transition needs a pause.</h1>
<ol class="clt__list">
<li data-reveal><span class="clt__list-num">01</span><span class="clt__list-title">The 300ms hold</span><span class="clt__list-meta">Essay · 6 min</span></li>
<li data-reveal><span class="clt__list-num">02</span><span class="clt__list-title">Slats, wipes and other curtains</span><span class="clt__list-meta">Essay · 9 min</span></li>
<li data-reveal><span class="clt__list-num">03</span><span class="clt__list-title">Counting to one hundred</span><span class="clt__list-meta">Note · 3 min</span></li>
</ol>
</section>
</main>
<div class="clt__curtain" aria-hidden="true">
<div class="clt__slats">
<span class="clt__slat"></span><span class="clt__slat"></span><span class="clt__slat"></span>
<span class="clt__slat"></span><span class="clt__slat"></span><span class="clt__slat"></span>
</div>
<div class="clt__logo">
<svg class="clt__mark" viewBox="0 0 120 120" fill="none" stroke-width="1.5" stroke-linecap="round">
<path d="M22 18 V102" />
<path d="M98 18 V102" />
<path d="M22 60 H98" />
<path d="M60 42 a18 18 0 1 1 -0.01 0" />
</svg>
<span class="clt__wordmark-mask"><span class="clt__wordmark">Halden</span></span>
<span class="clt__counter">00</span>
</div>
</div>
</div>
.clt { position: relative; min-height: 100vh; background: var(--bg); color: var(--text); }
/* Nav */
.clt__nav {
position: fixed; inset: 0 0 auto 0; z-index: 20;
display: flex; align-items: center; justify-content: space-between; gap: 1.5rem;
padding: 1.4rem 6vw; border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(10px);
}
.clt__brand { font-family: var(--font-display); font-weight: 400; font-size: 1.25rem; letter-spacing: -0.03em; text-decoration: none; }
.clt__links { display: flex; gap: clamp(1rem, 3vw, 2.5rem); }
.clt__links a {
position: relative; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em;
color: var(--muted); text-decoration: none; padding-block: .3rem; transition: color .3s;
}
.clt__links a::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--knob-accent, var(--accent)); transform: scaleX(0); transform-origin: left; transition: transform .4s cubic-bezier(.65, 0, .35, 1); }
.clt__links a:hover, .clt__links a.is-active { color: var(--text); }
.clt__links a.is-active::after { transform: scaleX(1); }
.clt__links a:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 4px; }
.clt__hint { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; color: var(--muted); }
/* Pages */
.clt__stage { position: relative; min-height: 100vh; padding: calc(6rem + 4vw) 6vw 8vw; }
.clt__page { display: none; max-width: 1200px; margin-inline: auto; }
.clt__page.is-active { display: block; }
.clt__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.clt__title {
font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 20;
font-size: clamp(2.5rem, 7vw, 6.5rem); line-height: .95; letter-spacing: -0.04em; max-width: 14ch; text-wrap: balance;
}
.clt__copy { max-width: 46ch; color: var(--muted); font-size: clamp(1rem, 1.25vw, 1.2rem); line-height: 1.55; margin-top: 2rem; }
.clt__media {
position: relative; overflow: hidden; border-radius: 10px; background: var(--surface);
background-image:
radial-gradient(90% 70% at 20% 15%, var(--c1), transparent 72%),
radial-gradient(60% 60% at 85% 85%, color-mix(in srgb, var(--c2) 70%, transparent), transparent 72%),
linear-gradient(165deg, color-mix(in srgb, var(--c1) 55%, var(--bg)) 0%, color-mix(in srgb, var(--c2) 18%, var(--bg)) 100%);
}
.clt__media::after {
content: ''; position: absolute; inset: 0; opacity: .35; mix-blend-mode: overlay;
background: repeating-linear-gradient(90deg, rgba(255,255,255,.05) 0 1px, transparent 1px 3px), repeating-linear-gradient(0deg, rgba(255,255,255,.04) 0 1px, transparent 1px 3px);
}
.clt__media--studio { --c1: #3a4a6b; --c2: #6b5a2a; aspect-ratio: 16 / 7; margin-top: 3.5rem; }
.clt__media--ember { --c1: #7a3a2a; --c2: #d9a066; }
.clt__media--moss { --c1: #2a5c48; --c2: #a7c957; }
.clt__media--violet { --c1: #4a2a6b; --c2: #c77dff; }
.clt__media-label { position: absolute; left: 1.25rem; bottom: 1.1rem; font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: rgba(255,255,255,.75); }
.clt__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(1rem, 2.5vw, 2.5rem); margin-top: 3.5rem; }
.clt__tile .clt__media { aspect-ratio: 4 / 5; }
.clt__tile h2 { font-family: var(--font-display); font-weight: 400; font-size: clamp(1.25rem, 1.8vw, 1.75rem); letter-spacing: -0.03em; margin-top: 1rem; }
.clt__tile p { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); margin-top: .35rem; }
.clt__list { list-style: none; margin-top: 3.5rem; border-top: 1px solid var(--line); }
.clt__list li { display: grid; grid-template-columns: 3rem 1fr auto; align-items: baseline; gap: 1.5rem; padding: 1.6rem 0; border-bottom: 1px solid var(--line); }
.clt__list-num { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.clt__list-title { font-family: var(--font-display); font-weight: 300; font-size: clamp(1.4rem, 2.6vw, 2.5rem); letter-spacing: -0.03em; line-height: 1.05; }
.clt__list-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); }
/* Curtain */
.clt__curtain { position: fixed; inset: 0; z-index: 30; pointer-events: none; } /* starts closed; JS opens it */
.clt__slats { position: absolute; inset: 0; display: grid; grid-template-columns: repeat(6, 1fr); }
.clt__slat { display: block; background: var(--text); transform: scaleY(1); transform-origin: top center; will-change: transform; }
.clt__slat + .clt__slat { box-shadow: -1px 0 0 color-mix(in srgb, var(--bg) 18%, transparent); }
.clt__logo { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 1.25rem; color: var(--bg); opacity: 0; visibility: hidden; }
.clt__mark { width: clamp(72px, 9vw, 112px); height: auto; stroke: currentColor; overflow: visible; }
.clt__wordmark-mask { display: block; overflow: hidden; padding-bottom: .1em; }
.clt__wordmark { display: block; font-family: var(--font-display); font-weight: 300; font-size: clamp(2rem, 4vw, 3.25rem); letter-spacing: -0.04em; line-height: 1; }
.clt__counter { font-family: var(--font-mono); font-size: 11px; letter-spacing: .2em; opacity: .55; font-variant-numeric: tabular-nums; }
@media (max-width: 767px) {
.clt__nav { padding: 1.1rem 5vw; }
.clt__hint { display: none; }
.clt__stage { padding: 6.5rem 5vw 12vw; }
.clt__grid { grid-template-columns: 1fr 1fr; }
.clt__tile:last-child { grid-column: 1 / -1; }
.clt__tile:last-child .clt__media { aspect-ratio: 16 / 9; }
.clt__media--studio { aspect-ratio: 4 / 3; }
.clt__list li { grid-template-columns: 2rem 1fr; }
.clt__list-meta { grid-column: 2; }
}
@media (prefers-reduced-motion: reduce) {
.clt__curtain { display: none; }
.clt__links a::after { transition: none; }
}
// Curtain + Logo Transition — six slats close over the page, a wordmark draws in, content swaps, slats open from the bottom.
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { slatDuration: 0.6, slatStagger: 0.05, slatEase: 'expo.inOut', hold: 0.3, accent: '#d4ff3a', autoCycle: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const AUTO_CYCLE = 3.2; // seconds of idle before the demo navigates itself
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.clt');
const links = gsap.utils.toArray('.clt__nav a[data-page]');
const pages = gsap.utils.toArray('.clt__page');
const curtain = root.querySelector('.clt__curtain');
const slats = gsap.utils.toArray('.clt__slat');
const logo = root.querySelector('.clt__logo');
const markPaths = gsap.utils.toArray('.clt__mark path');
const wordmark = root.querySelector('.clt__wordmark');
const counterEl = root.querySelector('.clt__counter');
const order = pages.map((p) => p.dataset.page);
let current = 'studio';
let tl = null;
let idle = null;
let userDrove = false;
let ctx = null;
let ac = null; // AbortController: listeners of the current build
const pageEl = (name) => pages.find((p) => p.dataset.page === name);
const revealsOf = (name) => gsap.utils.toArray(pageEl(name).querySelectorAll('[data-reveal]'));
function swapPage(next) {
pages.forEach((p) => p.classList.toggle('is-active', p.dataset.page === next));
links.forEach((a) => {
const active = a.dataset.page === next && a.closest('.clt__links');
a.classList.toggle('is-active', !!active);
if (active) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
});
current = next;
window.scrollTo({ top: 0, behavior: 'instant' });
}
function build() {
// Tear down the previous build: in-flight timeline, pending auto-cycle, listeners, recorded tweens.
tl?.kill(); tl = null;
idle?.kill(); idle = null;
ac?.abort();
ctx?.revert();
ac = new AbortController();
const { signal } = ac;
root.style.setProperty('--knob-accent', K('accent'));
const SLAT_DURATION = K('slatDuration'); // per-slat scaleY travel
const SLAT_STAGGER = K('slatStagger'); // between slats (reversed on open)
const SLAT_EASE = K('slatEase');
const HOLD = K('hold'); // pause with the logo fully drawn
ctx = gsap.context(() => {
if (reduced) {
// Reduced motion: instant swap, no curtain, no counter.
links.forEach((a) => a.addEventListener('click', (e) => { e.preventDefault(); swapPage(a.dataset.page); }, { signal }));
return;
}
const mm = gsap.matchMedia();
let riseY = 40;
mm.add({ desktop: '(min-width: 768px)', mobile: '(max-width: 767px)' }, (c) => { riseY = c.conditions.mobile ? 24 : 40; });
const counter = { value: 0 };
const setCounter = () => { counterEl.textContent = String(Math.round(counter.value)).padStart(2, '0'); };
function transition(next, { intro = false } = {}) {
if (tl) tl.kill();
idle?.kill();
counter.value = 0; setCounter();
tl = gsap.timeline({
defaults: { ease: SLAT_EASE },
onComplete: () => { tl = null; gsap.set(curtain, { autoAlpha: 0 }); scheduleAuto(); },
});
// 1. Close: slats grow from the top edge, left → right.
tl.set(curtain, { autoAlpha: 1 })
.set(slats, { transformOrigin: 'top center', scaleY: intro ? 1 : 0 })
.set(logo, { autoAlpha: 0, y: 0 })
.set(wordmark, { yPercent: 110 })
.set(markPaths, { strokeDasharray: (i, el) => el.getTotalLength(), strokeDashoffset: (i, el) => el.getTotalLength() })
.add('close');
if (!intro) tl.to(slats, { scaleY: 1, duration: SLAT_DURATION, stagger: SLAT_STAGGER }, 'close');
// 2. Logo: fade, stroke draw, masked wordmark rise, counter 00 → 100.
tl.add('logo', intro ? 'close' : 'close+=0.55')
.to(logo, { autoAlpha: 1, duration: 0.3, ease: 'power2.out' }, 'logo')
.to(markPaths, { strokeDashoffset: 0, duration: 0.8, ease: 'power3.inOut', stagger: 0.04 }, 'logo')
.to(wordmark, { yPercent: 0, duration: 0.7, ease: 'power3.out' }, 'logo+=0.15')
.to(counter, { value: 100, duration: 0.9, ease: 'power2.inOut', onUpdate: setCounter }, 'logo');
// 3. Swap content underneath while hidden, hold, then the logo exits.
tl.add('swap', 'logo+=0.9').call(swapPage, [next], 'swap')
.add('exit', `swap+=${HOLD}`)
.to(logo, { y: -20, autoAlpha: 0, duration: 0.4, ease: 'power2.in' }, 'exit');
// 4. Open: slats shrink toward the bottom edge, right → left; new content rises.
tl.add('open', 'exit+=0.2')
.set(slats, { transformOrigin: 'bottom center' }, 'open')
.to(slats, { scaleY: 0, duration: SLAT_DURATION, stagger: { each: SLAT_STAGGER, from: 'end' } }, 'open')
.fromTo(revealsOf(next), { y: riseY, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.9, ease: 'power3.out', stagger: 0.07, clearProps: 'all' }, 'open+=0.25');
return tl;
}
function scheduleAuto() {
if (userDrove || !K('autoCycle')) return;
idle = gsap.delayedCall(AUTO_CYCLE, () => {
const next = order[(order.indexOf(current) + 1) % order.length];
transition(next);
});
}
links.forEach((a) => a.addEventListener('click', (e) => {
e.preventDefault();
userDrove = true;
idle?.kill();
const next = a.dataset.page;
if (next === current || tl) return;
transition(next);
}, { signal }));
// Intro: the CSS initial state is "closed" (slats scaleY 1, curtain visible) — draw the logo, open onto the current page.
// A Playground rebuild replays this preloader with the new values.
transition(current, { intro: true });
}, root);
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { tl?.kill(); idle?.kill(); ac?.abort(); ctx?.revert(); });