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.

Loading

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.

prompt.md · Curtain + Logo Page Transition
### 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>