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.
.uw—min-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-toggleis 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-menu—position: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:none→autowith.is-open..uw-menu__veil—position: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-item—display:inline-flex; align-items:center; white-space:nowrap; font-family:"Instrument Serif"; font-size:1em; line-height:1.2; letter-spacing:-.02em;.uw-item--sansuses Space Grotesk 600 at.9em,letter-spacing:-.03em..uw-item__index—align-self:flex-start; margin:.35em .45em 0 0; font:600 .16em "Space Grotesk"; letter-spacing:.1em; opacity:.75..uw-item__divider—width: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-hero—height:100svh; overflow:hidden; display:flex; align-items:flex-end; padding:2rem 1.5rem; theimgis absolute,object-fit:cover; eyebrow.72remuppercase,h1Instrument Serifclamp(2.6rem,8vw,8rem),line-height:.95.@media (max-width:1000px):--uw-size:2.4rem, menu padding6rem 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:
chars = SplitText.create(label, { type: "chars", mask: "chars" })→[firstChar, ...trailing].- 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. idx = SplitText.create(index, { type: "words", mask: "words" }).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— veilopacity: 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— dividerscaleY: 1, 1s.t + .25— bodywidth: () => body.scrollWidth(function-based, so it re-measures oninvalidate()),unfoldDuration,ease: "power4.inOut".t + .5— trailing charsxPercent: 0, .75s,stagger: charStagger.">-0.4"— footopacity: 1, .6s.
Toggle — isOpen ? 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" } }).
Hover — pointerenter 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 getpointer-eventsonly when open. - Because the trailing letters live in a
width:0; overflow:hiddenbox, 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:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - Rules on
html, body— the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it. - Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.