All components

Unfolding Words Menu

GSAP animation component · Published 2026-09-05 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Overlay menu where each link unfolds letter by letter: a frosted veil blurs the hero behind it (the photo also scales and softens), the index and first letter rise out of a mask, a thin light divider grows from its centre and the remaining letters slide in from the right while their container widens. Hovering a link shifts it sideways and dims the others, and the toggle flickers between Menu and Close.

How it's built

Categorymenu
Techgsap
GSAP pluginsSplitText
Complexitysection
Performance costlight
Mobile-safeyes

overlay menu splittext backdrop-blur glassmorphism typography hover

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Unfolding Words Menu — Frosted Veil & Letter-by-Letter Link Reveal

Goal

Build a fullscreen overlay navigation where every link unfolds letter by letter. Pressing "Menu": a frosted glass veil fades over the hero photo (which also zooms and blurs slightly); then, link by link, the small index number and the first letter rise out of a mask, a thin light divider grows from its centre with a 20° lean, and the remaining letters slide in from the right while the box that holds them widens from 0 to its natural width. Serif and grotesk links alternate. Hovering a link nudges it to the right and dims the others; the toggle text flickers between "Menu" and "Close". Reverse on close. GSAP + SplitText.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) and the plugin SplitText (gsap/SplitText, included with GSAP 3.13+). Fonts: Instrument Serif (400) and Space Grotesk (400–600).

Layout / HTML

<div class="uw">
  <nav class="uw-nav">
    <a class="uw-brand" href="#">Obscura</a>
    <button class="uw-toggle" aria-expanded="false" aria-controls="uw-menu">Menu</button>
  </nav>

  <nav class="uw-menu" id="uw-menu" aria-hidden="true">
    <div class="uw-menu__veil"></div>
    <!-- six links; add class uw-item--sans on 02, 03 and 06 -->
    <a class="uw-item" href="#">
      <span class="uw-item__index">01</span>
      <span class="uw-item__label">Work</span>
      <span class="uw-item__divider"></span>
    </a>
    …Portfolio (sans), Retrospective (sans), Lens, Selected, Enquire (sans)…
    <p class="uw-menu__foot"><span>Studio for photography and film</span><span>Lisbon · Oslo</span></p>
  </nav>

  <section class="uw-hero">
    <img class="uw-hero__bg" src="…" alt="" />
    <div class="uw-hero__copy">
      <p class="uw-hero__eyebrow">Obscura — 2026 reel</p>
      <h1>Light, held still</h1>
    </div>
  </section>
</div>

Styling

Prefixed variables on :root: --uw-ink:#ece9e2, --uw-tint:rgba(10,12,16,.42), --uw-blur:26px, --uw-line:rgba(255,255,255,.18), --uw-glass:rgba(255,255,255,.08), --uw-size:5.4vw.

  • .uwmin-height:100svh; background:#0b0d10; color:var(--uw-ink); font-family:"Space Grotesk".
  • .uw-nav — fixed top bar, z-index:100, padding:1.25rem 1.5rem, brand in Instrument Serif 1.5rem; .uw-toggle is a glass pill (border:1px solid var(--uw-line); background:var(--uw-glass); backdrop-filter:blur(14px) saturate(1.3); border-radius:999px; padding:.75rem 1.1rem; font-size:.72rem; font-weight:600; letter-spacing:.12em; uppercase; inset 0 1px 0 rgba(255,255,255,.18)).
  • .uw-menuposition:fixed; inset:0; z-index:10; display:flex; flex-direction:column; align-items:flex-start; justify-content:center; gap:.2em; padding:8rem 2rem; font-size:var(--uw-size); pointer-events:noneauto with .is-open.
  • .uw-menu__veilposition:absolute; inset:0; z-index:-1; background:linear-gradient(180deg, var(--uw-tint), rgba(10,12,16,.62)); backdrop-filter:blur(var(--uw-blur)) saturate(1.25); opacity:0.
  • .uw-itemdisplay:inline-flex; align-items:center; white-space:nowrap; font-family:"Instrument Serif"; font-size:1em; line-height:1.2; letter-spacing:-.02em; .uw-item--sans uses Space Grotesk 600 at .9em, letter-spacing:-.03em.
  • .uw-item__indexalign-self:flex-start; margin:.35em .45em 0 0; font:600 .16em "Space Grotesk"; letter-spacing:.1em; opacity:.75.
  • .uw-item__dividerwidth:2px; height:1em; margin:0 .3em; background:linear-gradient(180deg,#fff, rgba(178,196,255,.95) 45%, rgba(255,184,226,.9)); box-shadow:0 0 12px rgba(200,210,255,.35); transform:rotate(20deg) scaleY(0); transform-origin:center.
  • .uw-item__body (created by JS) — width:0; display:inline-block; white-space:nowrap; overflow:hidden.
  • .uw-menu__foot — bottom row, font-size:.7rem; letter-spacing:.08em; uppercase; opacity:0.
  • .uw-heroheight:100svh; overflow:hidden; display:flex; align-items:flex-end; padding:2rem 1.5rem; the img is absolute, object-fit:cover; eyebrow .72rem uppercase, h1 Instrument Serif clamp(2.6rem,8vw,8rem), line-height:.95.
  • @media (max-width:1000px): --uw-size:2.4rem, menu padding 6rem 1.25rem, gap .35em, foot stacks.

GSAP effect (be exact)

gsap.registerPlugin(SplitText). Build in mount(config) → returns destroy().

const DEFAULTS = { itemStagger: .14, revealDuration: .85, unfoldDuration: 1, charStagger: .045, hoverShift: 12, dimOthers: .4, veilDuration: .8, veilBlur: 26 };

Set --uw-blur to ${veilBlur}px on the root at mount.

Preparation, per link:

  1. chars = SplitText.create(label, { type: "chars", mask: "chars" })[firstChar, ...trailing].
  2. Create <span class="uw-item__body">; move each trailing char's mask wrapper (char.parentElement) into it; insert the body right after the label (label.after(body)). Now the label holds only the first letter.
  3. idx = SplitText.create(index, { type: "words", mask: "words" }).
  4. gsap.set([idx.words, firstChar], { yPercent: 110 }), gsap.set(trailing, { xPercent: 130 }), gsap.set(body, { width: 0 }).

Also gsap.set(veil, { opacity: 0 }), gsap.set(foot, { opacity: 0 }), gsap.set(heroBg, { scale: 1, filter: "blur(0px)" }).

Timeline (paused, defaults ease: "power3.out"):

  • 0 — veil opacity: 1, veilDuration.
  • 0 — hero image { scale: 1.08, filter: "blur(6px)" }, veilDuration * 1.6, power2.out.
  • For link i, t = 0.45 + i * itemStagger:
  • t[idxWords, firstChar]yPercent: 0, revealDuration.
  • t + .05 — divider scaleY: 1, 1s.
  • t + .25 — body width: () => body.scrollWidth (function-based, so it re-measures on invalidate()), unfoldDuration, ease: "power4.inOut".
  • t + .5 — trailing chars xPercent: 0, .75s, stagger: charStagger.
  • ">-0.4" — foot opacity: 1, .6s.

ToggleisOpen ? tl.invalidate().play() : tl.reverse(); toggle .is-open on the menu, aria attributes; Escape closes.

Toggle label flicker — revert any previous split, set textContent to "Close"/"Menu", SplitText.create(toggle, { type: "chars" }), then gsap.fromTo(chars, { opacity: 0 }, { opacity: 1, duration: .05, ease: "power2.inOut", overwrite: true, stagger: { amount: .3, from: "random" } }).

Hoverpointerenter on a link: gsap.to(link, { x: hoverShift, duration: .6, ease: "power3.out", overwrite: "auto" }) and the other links { opacity: dimOthers, duration: .5, ease: "power2.out" }; pointerleave: x: 0 and all links opacity: 1.

destroy() — remove listeners, kill the timeline and tweens, remove the created .uw-item__body spans, revert() every SplitText (including the toggle's), clearProps:"all", reset the toggle text and aria state.

Assets / images

One atmospheric landscape photo for the hero (≈16:9, ≥1600px wide): a misty forest, dusk street or textured landscape. It is blurred behind the menu, so texture matters more than detail.

Behavior notes

  • The menu is position:fixed; the nav stays above it. Links get pointer-events only when open.
  • Because the trailing letters live in a width:0; overflow:hidden box, the reveal never reflows the line: the first letter and index are stable anchors.
  • Mobile: font size drops to 2.4rem; everything else is identical (touch toggles the menu).
  • Keep the prefixed variables on :root; nothing else is global.

Images

This component ships with 1 reference asset, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/unfold-word-menu/hero.jpg

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--uw-ink, --uw-tint, --uw-blur, --uw-line, --uw-glass, --uw-size. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • Rules on html, body — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.