Copied!
Hero

Flight path hero

A photo hero with a gradient headline that drops in letter by letter, a plane flying a soft contrail, and a menu button that wipes a full-screen menu down while the page swings away.

← Library

Element · Vanilla JS + CSS + SVGUpdated November 6, 2026

HeroElementVanilla JS + CSS + SVG

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Flight path hero" for my site using the Enrichr Library element at https://enrichr.dev/elements/flight-path-hero.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/flight-path-hero.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- title: Take your next build somewhere new
- links: Home, Library, Elements, Pricing, Contact
- cta: Open library
- flight: 14
- speed: 1
- photo: corgi-4
- accent: #e6ff55
- ink: #0c1e18
- cream: #fbf8f1
- tint: #050d0a

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/flight-path-hero.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-fph">
  <header class="ef-fph__nav">
    <div class="ef-fph__bar">
      <a class="ef-fph__logo" href="#" aria-label="Enrichr home"><svg viewBox="0 0 50 50" aria-hidden="true" focusable="false"><path fill="currentColor" d="m34.213 25.361 c0 2.655 1.381 4.808 3.086 4.808 0.781 0 1.494 -0.452 2.038 -1.198 0.267 -0.366 0.688 -0.589 1.142 -0.589 h0.054 c0.774 0 1.401 0.627 1.401 1.401 v10.65 h-10.651 c-0.774 0 -1.401 0.627 -1.401 1.401 v0.054 c0 0.453 0.222 0.874 0.589 1.141 0.746 0.544 1.198 1.257 1.198 2.038 0 1.705 -2.152 3.086 -4.808 3.086 -2.655 0 -4.808 -1.381 -4.808 -3.086 0 -0.781 0.452 -1.494 1.198 -2.038 0.366 -0.267 0.589 -0.688 0.589 -1.142 v-0.054 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-10.65 v-10.651 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-0.054 c-0.453 0 -0.875 0.222 -1.142 0.589 -0.543 0.746 -1.257 1.198 -2.038 1.198 -1.705 0 -3.086 -2.152 -3.086 -4.808 0 -2.655 1.381 -4.808 3.086 -4.808 0.781 0 1.495 0.452 2.038 1.198 0.267 0.366 0.688 0.589 1.141 0.589 h0.054 c0.774 0 1.401 -0.627 1.401 -1.401 v-10.65 h10.65 c0.774 0 1.401 -0.627 1.401 -1.401 v-0.054 c0 -0.453 -0.222 -0.875 -0.589 -1.142 -0.746 -0.543 -1.198 -1.257 -1.198 -2.038 0 -1.705 2.152 -3.086 4.808 -3.086 2.655 0 4.808 1.381 4.808 3.086 0 0.781 -0.452 1.495 -1.198 2.038 -0.366 0.267 -0.589 0.688 -0.589 1.141 v0.054 c0 0.774 0.627 1.401 1.401 1.401 h10.65 v10.65 c0 0.774 -0.627 1.401 -1.401 1.401 h-0.053 c-0.453 0 -0.874 -0.222 -1.141 -0.589 -0.544 -0.746 -1.257 -1.198 -2.038 -1.198 -1.705 0 -3.086 2.152 -3.086 4.808 z"/></svg><span>Enrichr</span></a>
      <button type="button" class="ef-fph__toggle" aria-expanded="false" aria-label="Open menu"><span class="ef-fph__line ef-fph__line--top"></span><span class="ef-fph__line ef-fph__line--bot"></span></button>
      <a class="ef-fph__btn ef-fph__navbtn" href="#"><span class="ef-fph__btn-txt">Open library</span><svg width="18" height="14" viewBox="0 0 18 14" aria-hidden="true" focusable="false"><path fill="currentColor" d="m17.76 6.857-5.727-5.688a.821.821 0 0 0-1.147.01.81.81 0 0 0-.01 1.139l4.33 4.3H.819a.821.821 0 0 0-.578.238.81.81 0 0 0 .578 1.388h14.389l-4.33 4.3a.813.813 0 0 0-.19.892.813.813 0 0 0 .765.505.824.824 0 0 0 .581-.248l5.727-5.688a.81.81 0 0 0 0-1.148Z"/></svg></a>
    </div>
  </header>
  <div class="ef-fph__overlay">
    <div class="ef-fph__bgs" aria-hidden="true"></div>
    <nav class="ef-fph__links" aria-label="Menu">
      <ul class="ef-fph__main"></ul>
      <ul class="ef-fph__socials">
        <li><a href="#">Instagram</a></li>
        <li><a href="#">YouTube</a></li>
        <li><a href="#">GitHub</a></li>
      </ul>
    </nav>
  </div>
  <div class="ef-fph__page">
    <section class="ef-fph__hero">
      <img class="ef-fph__photo" src="/img/library/corgi-4.jpg" alt="Two dogs running down a dirt road" decoding="async">
      <svg class="ef-fph__sky" viewBox="0 0 1280 720" preserveAspectRatio="xMidYMid slice" aria-hidden="true" focusable="false">
        <path class="ef-fph__trail ef-fph__trail--glow" d="M-80 470 C 240 330, 560 70, 1360 -60"/>
        <path class="ef-fph__trail ef-fph__trail--core" d="M-80 470 C 240 330, 560 70, 1360 -60"/>
        <g class="ef-fph__plane">
          <g transform="scale(1.35)">
            <path class="ef-fph__plane-body" d="M46 0 C42 -4 32 -5.5 18 -5.5 L-28 -5.5 L-38 -18 L-45 -18 L-41 -4.5 L-46 -2 L-46 2 L-30 5.5 L18 5.5 C32 5.5 42 4 46 0 Z"/>
            <path class="ef-fph__plane-wing" d="M10 1 L-8 15 L-15 15 L-6 1 Z"/>
            <path class="ef-fph__plane-wing" d="M-36 -2 L-46 -9 L-49 -9 L-44 -2 Z"/>
            <path class="ef-fph__plane-win" d="M30 -3 L36 -3 C38 -2.4 39 -1.6 39.6 -1 L30 -1 Z"/>
          </g>
        </g>
      </svg>
      <div class="ef-fph__wrap">
        <h2 class="ef-fph__title"></h2>
        <div class="ef-fph__cta">
          <span class="ef-fph__accent" aria-hidden="true"><i></i><i></i><svg viewBox="0 0 50 50" focusable="false"><path fill="currentColor" d="m34.213 25.361 c0 2.655 1.381 4.808 3.086 4.808 0.781 0 1.494 -0.452 2.038 -1.198 0.267 -0.366 0.688 -0.589 1.142 -0.589 h0.054 c0.774 0 1.401 0.627 1.401 1.401 v10.65 h-10.651 c-0.774 0 -1.401 0.627 -1.401 1.401 v0.054 c0 0.453 0.222 0.874 0.589 1.141 0.746 0.544 1.198 1.257 1.198 2.038 0 1.705 -2.152 3.086 -4.808 3.086 -2.655 0 -4.808 -1.381 -4.808 -3.086 0 -0.781 0.452 -1.494 1.198 -2.038 0.366 -0.267 0.589 -0.688 0.589 -1.142 v-0.054 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-10.65 v-10.651 c0 -0.774 -0.627 -1.401 -1.401 -1.401 h-0.054 c-0.453 0 -0.875 0.222 -1.142 0.589 -0.543 0.746 -1.257 1.198 -2.038 1.198 -1.705 0 -3.086 -2.152 -3.086 -4.808 0 -2.655 1.381 -4.808 3.086 -4.808 0.781 0 1.495 0.452 2.038 1.198 0.267 0.366 0.688 0.589 1.141 0.589 h0.054 c0.774 0 1.401 -0.627 1.401 -1.401 v-10.65 h10.65 c0.774 0 1.401 -0.627 1.401 -1.401 v-0.054 c0 -0.453 -0.222 -0.875 -0.589 -1.142 -0.746 -0.543 -1.198 -1.257 -1.198 -2.038 0 -1.705 2.152 -3.086 4.808 -3.086 2.655 0 4.808 1.381 4.808 3.086 0 0.781 -0.452 1.495 -1.198 2.038 -0.366 0.267 -0.589 0.688 -0.589 1.141 v0.054 c0 0.774 0.627 1.401 1.401 1.401 h10.65 v10.65 c0 0.774 -0.627 1.401 -1.401 1.401 h-0.053 c-0.453 0 -0.874 -0.222 -1.141 -0.589 -0.544 -0.746 -1.257 -1.198 -2.038 -1.198 -1.705 0 -3.086 2.152 -3.086 4.808 z"/></svg></span>
          <p class="ef-fph__txt">From capture to rebuild, we map how a page moves. You get the details, so you can focus on making something of your own.</p>
          <a class="ef-fph__btn ef-fph__ctabtn" href="#"><span class="ef-fph__btn-txt">Start building</span><svg width="18" height="14" viewBox="0 0 18 14" aria-hidden="true" focusable="false"><path fill="currentColor" d="m17.76 6.857-5.727-5.688a.821.821 0 0 0-1.147.01.81.81 0 0 0-.01 1.139l4.33 4.3H.819a.821.821 0 0 0-.578.238.81.81 0 0 0 .578 1.388h14.389l-4.33 4.3a.813.813 0 0 0-.19.892.813.813 0 0 0 .765.505.824.824 0 0 0 .581-.248l5.727-5.688a.81.81 0 0 0 0-1.148Z"/></svg></a>
        </div>
      </div>
    </section>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-fph { --ef-fph-acc: #e6ff55; --ef-fph-ink: #0c1e18; --ef-fph-cream: #fbf8f1; --ef-fph-tint: #050d0a;
  position: relative; flex: 1 0 auto; width: 100%; height: 100%; min-height: 36rem; box-sizing: border-box; overflow: hidden; border-radius: 1rem; isolation: isolate;
  background: var(--ef-fph-ink); color: color-mix(in srgb, var(--ef-fph-cream) 85%, transparent); font-family: "Instrument Sans", system-ui, sans-serif; font-size: 18px; font-weight: 400; line-height: 23px; letter-spacing: -0.36px; container-type: inline-size; }
.ef-fph *, .ef-fph *::before, .ef-fph *::after { box-sizing: border-box; }
.ef-fph a { color: inherit; text-decoration: none; }
.ef-fph ul { margin: 0; padding: 0; list-style: none; }
.ef-fph li { margin: 0; padding: 0; opacity: 1; list-style: none; }

/* nav bar */
.ef-fph__nav { position: absolute; top: 18px; left: 0; z-index: 40; width: 100%; }
.ef-fph__bar { max-width: 1440px; margin: 0 auto; padding: 0 2rem; display: flex; justify-content: space-between; align-items: center; }
.ef-fph__logo { display: flex; align-items: center; gap: 8px; color: var(--ef-fph-cream) !important; font-family: "Parkinsans", "Space Grotesk", sans-serif; font-weight: 700; font-size: 22px; letter-spacing: 0; text-shadow: 0 1px 12px rgba(5, 13, 10, 0.35); }
.ef-fph__logo svg { width: 30px; height: 30px; display: block; color: var(--ef-fph-acc); filter: drop-shadow(0 1px 8px rgba(5, 13, 10, 0.35)); }
.ef-fph__toggle { position: relative; width: 44px; height: 44px; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; align-items: center; justify-content: center; border: 0; border-radius: 10px; background: var(--ef-fph-cream); cursor: pointer; }
.ef-fph__line { display: block; width: 29px; height: 2px; background: var(--ef-fph-ink); transform-origin: 50% 50%; }
.ef-fph__toggle:focus-visible, .ef-fph__btn:focus-visible, .ef-fph__logo:focus-visible { outline: 3px solid var(--ef-fph-acc); outline-offset: 3px; }
.ef-fph__btn { display: flex; gap: 10px; padding: 14px; border-radius: 10px; background: var(--ef-fph-cream); color: var(--ef-fph-ink) !important; font-family: "Bebas Neue", "Parkinsans", sans-serif; font-size: 20px; line-height: 1; letter-spacing: 0.02em; align-items: center; justify-content: center; text-transform: uppercase; }
.ef-fph__btn svg { display: block; flex: none; }

/* overlay menu */
.ef-fph__overlay { position: absolute; inset: 0; z-index: 30; clip-path: polygon(0 0, 100% 0, 100% 0, 0 0); visibility: hidden; pointer-events: none; }
.ef-fph__bgs { position: absolute; inset: 0; z-index: -1; background: var(--ef-fph-cream); pointer-events: none; }
.ef-fph__bg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; display: block; }
.ef-fph__links { width: 50%; height: 100%; padding: 44px 30px; display: flex; gap: 10px; flex-direction: column; align-items: center; justify-content: center; text-align: center; background: color-mix(in srgb, var(--ef-fph-tint) 24%, transparent); -webkit-backdrop-filter: blur(67px); backdrop-filter: blur(67px); }
.ef-fph__main { color: var(--ef-fph-cream); font-family: "Bebas Neue", "Parkinsans", sans-serif; font-size: clamp(2rem, 4.862cqi + 0.936rem, 5.313rem); line-height: 90%; letter-spacing: -0.5px; }
.ef-fph__main li { width: fit-content; margin: 0 auto 10px; transition: opacity 0.3s ease; }
.ef-fph__main a { display: block; line-height: 93%; color: var(--ef-fph-cream); }
.ef-fph__main a:focus-visible { outline: 3px solid var(--ef-fph-acc); outline-offset: 4px; }
.ef-fph__main:has(li:hover) li:not(:hover), .ef-fph__main:has(li:focus-within) li:not(:focus-within) { opacity: 0.5; }
.ef-fph__w { display: inline-block; white-space: nowrap; }
.ef-fph__m { display: inline-block; overflow: clip; vertical-align: top; }
.ef-fph__c { display: inline-block; will-change: transform; }
.ef-fph__socials ul, .ef-fph__socials { display: flex; gap: 14px; color: var(--ef-fph-cream); font-size: clamp(1rem, 0.55cqi + 0.88rem, 1.375rem); font-weight: 600; line-height: normal; letter-spacing: -0.44px; }
.ef-fph__socials a:hover { text-decoration: underline; }
.ef-fph__socials a:focus-visible { outline: 3px solid var(--ef-fph-acc); outline-offset: 3px; }

/* page content */
.ef-fph__page { position: absolute; inset: 0; z-index: 1; will-change: transform; transform-origin: 0 0; }
.ef-fph__hero { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: flex-end; padding: 36px 0; overflow: hidden; background: var(--ef-fph-ink); }
.ef-fph__photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 50% 60%; display: block; }
.ef-fph__hero::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(0deg, rgba(5, 13, 10, 0.62) 0%, rgba(5, 13, 10, 0.18) 48%, rgba(5, 13, 10, 0) 70%), linear-gradient(180deg, rgba(5, 13, 10, 0.32) 0%, rgba(5, 13, 10, 0) 22%); }
.ef-fph__sky { position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; pointer-events: none; will-change: transform; }
.ef-fph__trail { fill: none; stroke: var(--ef-fph-cream); stroke-linecap: round; }
.ef-fph__trail--glow { stroke-width: 34; opacity: 0.13; }
.ef-fph__trail--core { stroke-width: 4; opacity: 0.34; }
.ef-fph__plane-body { fill: var(--ef-fph-cream); stroke: color-mix(in srgb, var(--ef-fph-ink) 55%, transparent); stroke-width: 0.8; }
.ef-fph__plane-wing { fill: color-mix(in srgb, var(--ef-fph-cream) 82%, var(--ef-fph-ink)); stroke: color-mix(in srgb, var(--ef-fph-ink) 55%, transparent); stroke-width: 0.8; }
.ef-fph__plane-win { fill: var(--ef-fph-ink); opacity: 0.75; }
.ef-fph__wrap { position: relative; z-index: 2; width: 100%; max-width: 1440px; margin: 0 auto; padding: 0 2rem; display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; }
.ef-fph .ef-fph__title { position: relative; margin: 0; max-width: 751px; font-family: "Bebas Neue", "Parkinsans", sans-serif; font-size: clamp(2.75rem, 7.89cqi + 1rem, 8.125rem); font-weight: 400; line-height: 0.99; letter-spacing: -0.01em; text-transform: uppercase; color: var(--ef-fph-cream); opacity: 1; }
.ef-fph__title .ef-fph__c { color: transparent; -webkit-background-clip: text; background-clip: text; background-image: linear-gradient(0deg, var(--ef-fph-acc) 0%, var(--ef-fph-cream) 100%); background-repeat: no-repeat; }
.ef-fph__cta { z-index: 0; flex: none; max-width: 385px; padding: 22px 18px; border-radius: 20px; background: linear-gradient(180deg, color-mix(in srgb, var(--ef-fph-tint) 59%, transparent) 0%, color-mix(in srgb, #005236 59%, transparent) 80.77%); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); }
.ef-fph__accent { display: inline-flex; align-items: center; gap: 4px; height: 32px; padding: 0 4px 0 10px; border-radius: 999px; background: color-mix(in srgb, var(--ef-fph-cream) 92%, transparent); color: var(--ef-fph-ink); }
.ef-fph__accent i { width: 4px; height: 4px; border-radius: 50%; background: currentColor; opacity: 0.45; }
.ef-fph__accent svg { width: 24px; height: 24px; margin-left: 4px; display: block; }
.ef-fph .ef-fph__txt { margin: 12px 0 14px; font-size: 18px; line-height: 23px; color: color-mix(in srgb, var(--ef-fph-cream) 85%, transparent); opacity: 1; max-width: none; }
.ef-fph__ctabtn { background: var(--ef-fph-acc); font-size: 22px; }
@container (max-width: 760px) {
  .ef-fph__wrap { flex-direction: column; align-items: flex-start; }
  .ef-fph__links { width: 100%; }
  .ef-fph__navbtn { display: none; }
}
@media (prefers-reduced-motion: reduce) { .ef-fph__main li { transition: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Flight path hero: a photo hero with a gradient headline, a glass call-to-action card and a small plane that flies along
// a soft contrail arc. The menu button plays one timeline: the menu sheet wipes down from the top with a slanted clip
// path (0.8 s, power3 in-out) while the page underneath swings away, turning 18 degrees and growing 30% from its top left
// corner; the menu photos zoom to 110% over 1 s; each link's letters drop into place from two lines above, 0.01 s apart
// (0.5 s, power2 in-out, starting at 0.2 s); and the two button bars fold into a cross on a back-out curve. Pressing the
// button again plays the same timeline backwards. Hovering or focusing a link cross-fades the menu photo to that link's
// photo and zooms it to 118%; the other links dim to half. The headline letters drop in the same way when the hero is
// first seen. Reduced motion opens and closes the menu instantly, skips the letter drops and parks the plane mid-arc.
// The plane loop pauses off screen and in hidden tabs. Escape closes the menu.
// mount(root, { title, links (comma separated), cta (nav button text), flight (seconds per plane pass, 0 = parked),
//               speed (menu timeline speed), photo (corgi-1 to corgi-6), accent, ink, cream, tint (menu glass) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-fph");
  var overlay = root.querySelector(".ef-fph__overlay");
  var bgs = root.querySelector(".ef-fph__bgs");
  var list = root.querySelector(".ef-fph__main");
  var page = root.querySelector(".ef-fph__page");
  var toggle = root.querySelector(".ef-fph__toggle");
  var lineTop = root.querySelector(".ef-fph__line--top");
  var lineBot = root.querySelector(".ef-fph__line--bot");
  var title = root.querySelector(".ef-fph__title");
  var photo = root.querySelector(".ef-fph__photo");
  var navTxt = root.querySelector(".ef-fph__navbtn .ef-fph__btn-txt");
  var plane = root.querySelector(".ef-fph__plane");
  var core = root.querySelector(".ef-fph__trail--core");
  var glow = root.querySelector(".ef-fph__trail--glow");
  var MENU_PHOTOS = ["corgi-3", "corgi-1", "corgi-2", "corgi-6", "corgi-5", "corgi-4"];
  var ALT = { "corgi-1": "A corgi puppy against an orange wall", "corgi-2": "A corgi smiling in a field of flowers", "corgi-3": "A corgi standing on a rock outdoors", "corgi-4": "Two dogs running down a dirt road", "corgi-5": "A corgi lying on a white background", "corgi-6": "A corgi standing on a white background" };
  var opts = {}, imgs = [], links = [], tweens = [], raf = 0, last = 0, destroyed = false, visible = true, io = null, ro = null, offs = [];
  var t = 0, dir = 0, T = 1, H = 1, flightClock = 0.32, titleChars = [], introT = -1, trailLen = 1;

  // GSAP eases
  function inOut(n) { return function (p) { return p < 0.5 ? Math.pow(2 * p, n) / 2 : 1 - Math.pow(2 * (1 - p), n) / 2; }; }
  var P3IO = inOut(4), P2IO = inOut(3);
  function backOut(c) { return function (p) { var q = 1 - p; return 1 - q * q * ((c + 1) * q - c); }; }
  var BACK = backOut(1.5);
  function clamp(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]; }); }

  // SplitText with masks: words stay together, each letter sits in its own clipping box
  function split(el, text) {
    el.setAttribute("aria-label", text);
    el.innerHTML = String(text).split(/\s+/).filter(Boolean).map(function (w) {
      return '<span class="ef-fph__w" aria-hidden="true">' + Array.prototype.map.call(w, function (ch) { return '<span class="ef-fph__m"><span class="ef-fph__c">' + esc(ch) + "</span></span>"; }).join("") + "</span>";
    }).join(" ");
    return [].slice.call(el.querySelectorAll(".ef-fph__c"));
  }

  // hover tweens on the menu photos (opacity and scale), keyed so the newest one on a property takes over
  function tween(key, dur, ease, get, set, to) { tweens = tweens.filter(function (x) { return x.key !== key; }); tweens.push({ key: key, s: performance.now(), d: dur * 1000, e: ease, from: get(), set: set, to: to }); kick(); }
  function runTweens(n) {
    for (var i = 0; i < tweens.length; i++) {
      var w = tweens[i], p = w.d <= 0 ? 1 : clamp((n - w.s) / w.d);
      w.set(w.from + (w.to - w.from) * w.e(p));
      if (p >= 1) { tweens.splice(i, 1); i--; }
    }
  }
  function setImg(im) { im.el.style.opacity = im.op.toFixed(3); im.el.style.transform = "scale(" + im.sc.toFixed(4) + ")"; }
  function hoverIn(index) {
    var d = reduce ? 0 : 0.5, target = imgs[(index + 1) % imgs.length];
    imgs.forEach(function (im, k) { if (im === target) return; tween("o" + k, d, P2IO, function () { return im.op; }, function (v) { im.op = v; setImg(im); }, 0); });
    var k = imgs.indexOf(target);
    tween("o" + k, d, P3IO, function () { return target.op; }, function (v) { target.op = v; setImg(target); }, 1);
    tween("s" + k, d, P3IO, function () { return target.sc; }, function (v) { target.sc = v; setImg(target); }, 1.18);
  }
  function hoverOut() {
    var d = reduce ? 0 : 0.5;
    imgs.forEach(function (im, k) {
      if (k) tween("o" + k, d, P2IO, function () { return im.op; }, function (v) { im.op = v; setImg(im); }, 0);
      tween("s" + k, d, P2IO, function () { return im.sc; }, function (v) { im.sc = v; setImg(im); }, 1);
    });
    var first = imgs[0];
    tween("o0", d, P3IO, function () { return first.op; }, function (v) { first.op = v; setImg(first); }, 1);
  }

  // The menu timeline as a pure function of its time t (seconds), so it plays the same forwards and backwards.
  function render() {
    var c = P3IO(clamp(t / 0.8)), z = P3IO(clamp(t / 1));
    if (t <= 0) { overlay.style.clipPath = ""; }
    else if (t >= 0.8) overlay.style.clipPath = "none";
    else overlay.style.clipPath = "polygon(0% 0%, 100% 0%, 100% " + (120 * c).toFixed(3) + "%, 0% " + (100 * c).toFixed(3) + "%)";
    page.style.transform = c ? "translateY(" + (0.4 * H * c).toFixed(2) + "px) rotate(" + (18 * c).toFixed(3) + "deg) scale(" + (1 + 0.3 * c).toFixed(4) + ")" : "";
    if (dir) imgs.forEach(function (im) { im.sc = 1 + 0.1 * z; setImg(im); });
    links.forEach(function (chars) {
      chars.forEach(function (ch, i) {
        var e = P2IO(clamp((t - 0.2 - i * 0.01) / 0.5));
        ch.style.transform = "translateY(" + (-200 + 200 * e).toFixed(2) + "%)";
      });
    });
    var b = BACK(clamp((t - 0.2) / 0.4));
    lineTop.style.transform = b ? "translateY(" + (4 * b).toFixed(3) + "px) rotate(" + (45 * b).toFixed(3) + "deg) scaleX(" + (1 - 0.2 * b).toFixed(4) + ")" : "";
    lineBot.style.transform = b ? "translateY(" + (-4 * b).toFixed(3) + "px) rotate(" + (-45 * b).toFixed(3) + "deg) scaleX(" + (1 - 0.2 * b).toFixed(4) + ")" : "";
  }
  function setOpen(open) {
    overlay.style.visibility = t > 0 ? "visible" : "hidden";
    overlay.style.pointerEvents = t >= T && open ? "auto" : "none";
    toggle.setAttribute("aria-expanded", open ? "true" : "false");
    toggle.setAttribute("aria-label", open ? "Close menu" : "Open menu");
  }
  function play(d) {
    dir = d;
    if (reduce) { t = d > 0 ? T : 0; render(); imgs.forEach(function (im) { im.sc = d > 0 ? 1.1 : 1; setImg(im); }); dir = 0; setOpen(d > 0); return; }
    overlay.style.visibility = "visible"; overlay.style.pointerEvents = "none";
    toggle.setAttribute("aria-expanded", d > 0 ? "true" : "false");
    toggle.setAttribute("aria-label", d > 0 ? "Close menu" : "Open menu");
    kick();
  }
  function onToggle() { play(t >= T ? -1 : 1); }

  // plane along the arc: position and heading from the path, contrail drawn up to the plane
  function flight(p) {
    var L = trailLen, pt = core.getPointAtLength(L * p), ahead = core.getPointAtLength(Math.min(L, L * p + 2)), back = core.getPointAtLength(Math.max(0, L * p - 2));
    var a = Math.atan2(ahead.y - back.y, ahead.x - back.x) * 180 / Math.PI;
    plane.setAttribute("transform", "translate(" + pt.x.toFixed(1) + "," + pt.y.toFixed(1) + ") rotate(" + a.toFixed(2) + ")");
    var fade = p > 0.85 ? 1 - (p - 0.85) / 0.15 : p < 0.04 ? p / 0.04 : 1;
    core.style.strokeDashoffset = (L * (1 - p)).toFixed(1); glow.style.strokeDashoffset = (L * (1 - p)).toFixed(1);
    core.style.opacity = (0.34 * fade).toFixed(3); glow.style.opacity = (0.13 * fade).toFixed(3);
    plane.style.opacity = p > 0.96 ? ((1 - p) / 0.04).toFixed(3) : p < 0.02 ? (p / 0.02).toFixed(3) : "1";
  }
  function titleIntro(n) {
    var e = (n - introT) / 1000, done = true;
    titleChars.forEach(function (ch, i) {
      var p = clamp((e - i * 0.01) / 0.5); if (p < 1) done = false;
      ch.style.transform = p >= 1 ? "" : "translateY(" + (-200 + 200 * P2IO(p)).toFixed(2) + "%)";
    });
    if (done) introT = -2;
  }

  function kick() { if (!raf && !destroyed) { last = performance.now(); raf = requestAnimationFrame(loop); } }
  function loop(n) {
    raf = 0;
    var dt = Math.min(0.05, (n - last) / 1000 || 0); last = n;
    var more = false;
    if (dir) {
      t += dt * dir * opts.speed;
      if (t >= T) { t = T; render(); dir = 0; setOpen(true); }
      else if (t <= 0) { t = 0; render(); dir = 0; setOpen(false); }
      else { render(); more = true; }
    }
    if (tweens.length) { runTweens(n); more = more || tweens.length > 0; }
    if (introT >= 0) { titleIntro(n); more = more || introT >= 0; }
    if (!reduce && opts.flight > 0 && visible && !document.hidden) { flightClock = (flightClock + dt / opts.flight) % 1; flight(flightClock); more = true; }
    if (more) raf = requestAnimationFrame(loop);
  }

  function on(el, type, f) { el.addEventListener(type, f); offs.push(function () { el.removeEventListener(type, f); }); }
  var linkOffs = [];
  function buildLinks() {
    linkOffs.forEach(function (f) { f(); }); linkOffs = [];
    list.textContent = ""; links = [];
    var names = String(opts.links).split(",").map(function (s) { return s.trim(); }).filter(Boolean).slice(0, 8);
    names.forEach(function (name, i) {
      var li = document.createElement("li"), a = document.createElement("a"); a.href = "#";
      links.push(split(a, name.toUpperCase()));
      li.appendChild(a); list.appendChild(li);
      var enter = function () { hoverIn(i); }, leave = function () { hoverOut(); };
      li.addEventListener("mouseenter", enter); li.addEventListener("mouseleave", leave);
      a.addEventListener("focus", enter); a.addEventListener("blur", leave);
      linkOffs.push(function () { li.removeEventListener("mouseenter", enter); li.removeEventListener("mouseleave", leave); a.removeEventListener("focus", enter); a.removeEventListener("blur", leave); });
    });
    var longest = links.reduce(function (m, c) { return Math.max(m, c.length); }, 1);
    T = Math.max(1, 0.2 + 0.5 + 0.01 * (longest - 1));
    bgs.textContent = ""; imgs = [];
    for (var k = 0; k < names.length + 1; k++) {
      var im = document.createElement("img"); im.className = "ef-fph__bg"; im.alt = ""; im.decoding = "async";
      im.src = "/img/library/" + MENU_PHOTOS[k % MENU_PHOTOS.length] + ".jpg";
      bgs.appendChild(im);
      var rec = { el: im, op: k ? 0 : 1, sc: t > 0 ? 1 + 0.1 * P3IO(clamp(t)) : 1 }; setImg(rec); imgs.push(rec);
    }
    render();
  }
  function shadeTitle() {
    var h = title.offsetHeight || 1;
    titleChars.forEach(function (ch) {
      var top = ch.parentNode.offsetTop;
      ch.style.backgroundSize = "100% " + h + "px";
      ch.style.backgroundPosition = "0 " + (-top) + "px";
    });
  }
  function update(next) {
    var prev = opts;
    opts = Object.assign({ title: "Take your next build somewhere new", links: "Home, Library, Elements, Pricing, Contact", cta: "Open library", flight: 14, speed: 1, photo: "corgi-4", accent: "#e6ff55", ink: "#0c1e18", cream: "#fbf8f1", tint: "#050d0a" }, opts, next || {});
    opts.flight = Math.max(0, num(opts.flight, 14)); opts.speed = Math.max(0.2, num(opts.speed, 1));
    ["accent", "ink", "cream", "tint"].forEach(function (k) { box.style.setProperty("--ef-fph-" + (k === "accent" ? "acc" : k), opts[k]); });
    navTxt.textContent = opts.cta;
    var ph = /^corgi-[1-6]$/.test(opts.photo) ? opts.photo : "corgi-4";
    if (photo.getAttribute("src") !== "/img/library/" + ph + ".jpg") { photo.src = "/img/library/" + ph + ".jpg"; photo.alt = ALT[ph]; }
    if (opts.title !== prev.title) { titleChars = split(title, opts.title); shadeTitle(); }
    if (opts.links !== prev.links) buildLinks();
    if (reduce || !opts.flight) flight(0.5);
    kick();
  }

  trailLen = core.getTotalLength() || 1;
  core.style.strokeDasharray = glow.style.strokeDasharray = trailLen.toFixed(1);
  H = Math.max(1, box.clientHeight);
  update(o);
  on(toggle, "click", onToggle);
  on(box, "keydown", function (e) { if (e.key === "Escape" && t > 0 && dir >= 0) { play(-1); toggle.focus(); } });
  if (!reduce) { titleChars.forEach(function (ch) { ch.style.transform = "translateY(-200%)"; }); }
  if ("IntersectionObserver" in window) {
    io = new IntersectionObserver(function (e) {
      visible = e[0].isIntersecting;
      if (visible && introT === -1 && !reduce) introT = performance.now();
      if (visible) kick();
    }); io.observe(box);
  } else if (!reduce) introT = performance.now();
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (destroyed) return; H = Math.max(1, box.clientHeight); shadeTitle(); render(); }); ro.observe(box); ro.observe(title); }
  if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { if (!destroyed) shadeTitle(); });
  kick();
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf); tweens = [];
      offs.forEach(function (f) { f(); }); offs = [];
      linkOffs.forEach(function (f) { f(); }); linkOffs = [];
      if (io) io.disconnect(); if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"title":"Take your next build somewhere new","links":"Home, Library, Elements, Pricing, Contact","cta":"Open library","flight":14,"speed":1,"photo":"corgi-4","accent":"#e6ff55","ink":"#0c1e18","cream":"#fbf8f1","tint":"#050d0a"});

How it works

The menu is one timeline written as a function of its time, so pressing the button again simply runs the clock backwards, as GSAP's reverse does. Over 0.8 seconds on a power3 in-out curve the menu's clip path grows from a flat line at the top to a slanted sheet (bottom right at 120%, bottom left at 100%) while the page underneath turns 18 degrees, grows 30% and drops from its top left corner. The menu photos zoom to 110% over 1 second. From 0.2 seconds each link's letters, wrapped one by one in clipping boxes, drop from two lines above into place over 0.5 seconds, 0.01 seconds apart, on a power2 in-out curve, and the two button bars fold into a cross over 0.4 seconds on a back-out curve. Hovering or focusing a link fades every photo out over 0.5 seconds and fades that link's photo in while zooming it to 118%; leaving brings back the first photo. The headline uses the same letter drop when the hero is first seen, and each letter carries a slice of one lime-to-cream gradient so the whole headline reads as one fill. The plane follows an SVG path with getPointAtLength, turning with the curve, and the contrail is the same path drawn up to the plane with a dash offset.

Accessibility and performance

The menu button is a real button with aria-expanded and a label that switches between open and close. Closed, the menu is visibility: hidden, so its links are out of the tab order. Links and the headline keep their full text as an aria-label while the letter spans are hidden from screen readers. Escape closes the menu and returns focus to the button. With prefers-reduced-motion the menu opens and closes instantly, the letters do not drop, and the plane stays parked mid-arc.

Only transforms, opacity and the clip path change. The plane and contrail sit in their own layer, so the photo underneath is never repainted. The menu glass uses a large backdrop blur, which costs most on slow phones, so it only exists while the menu is open. The plane loop pauses off screen and in hidden tabs; the menu only animates while it moves.

SectionHero
TechniquesText animation, Stagger, Reveal and intro, Pointer interaction
StackVanilla JS, CSS, SVG
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byGSAP navigation menu animation by Enrichr on CodePen. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Hover lift typeBig uppercase type that rises into place letter by letter, then lifts and changes colour under the pointer, word by word or letter by letter. Serif motion study heroA starlit green hero whose serif headline blurs into place with hand-drawn underlines, then opens onto an endless curved wall of cards you can drag through. Stagger letter swapClick to swap two screens of big type: every character slides up and out in a stagger, the background shifts colour, and the next text rises into place, with a soft cursor circle following the mouse.

FAQ

Does it need GSAP and SplitText?

No. The timeline is a function of its time with the same power3, power2 and back eases written out, and the letters are split into small spans by a few lines of JavaScript. Reversing is running the same clock backwards.

Why does the page turn when the menu opens?

That is the original design: the page content swings away under the menu, turning 18 degrees and growing 30% around its top left corner, which makes the wipe feel physical. Set Menu speed lower to watch it slowly.

Does the plane move in the original?

In the original the plane and its trail are part of the background photo. Here they are drawn on top of the photo so any picture can carry them, and the plane flies the arc once every 14 seconds. Set the plane pass to 0 to keep it still.

Where do the photos come from?

Photos: Alvan Nee and fatty corgi on Unsplash. Every menu link gets its own photo; with more links than photos they repeat.