All components

Meridian — Flowing Menu Curtain

GSAP animation component · Published 2026-08-04 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A vertical list of large rows where hovering pulls an inverted sheet across the row, carrying the row's own name on an endless marquee with a photographic still between each repetition. The sheet enters from whichever horizontal edge the pointer actually crossed and leaves towards the edge it exits by, measured on both events with a squared-distance test against the top and bottom midpoints. Panel and strip travel in opposite directions — the panel from ∓101%, its content from ±101% — so the marquee holds almost still against the window while the sheet wipes over it, which is what makes it read as a physical sheet rather than a div sliding up. The loop is seamless because exactly one measured copy of the content is animated, and the copy count is derived from that width against the viewport and recomputed on resize.

How it's built

Categoryhover
Techgsap
Complexitysection
Performance costlight
Mobile-safeyes

hover marquee curtain closest-edge direction-aware menu navigation infinite-loop editorial gsap

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

Flowing Menu Curtain (closest-edge marquee sheet)

Goal

Build a contents page whose rows answer the hand. Hovering a row pulls an inverted sheet across it — ink where the page is paper — carrying that row's own name on an endless marquee with a photographic still between each repetition. The sheet does not simply rise: it enters from whichever horizontal edge the pointer actually crossed, and leaves towards the edge it exits by. Come down onto a row and the sheet comes down with you; come up from below and it rises to meet you.

The demo dress is Meridian, a printed quarterly cut into five sections.

Tech

Vanilla HTML/CSS/JS with ES module imports. gsap (npm), no plugins:

import gsap from "gsap";

No ScrollTrigger, no Lenis, no SplitText. Everything runs inside a DOMContentLoaded listener.

Write the engine so it takes every hook as a data- attribute selector rather than hard-coded class names — [data-flow-item], [data-flow-panel], [data-flow-strip], [data-flow-part]. It costs four lines and it means the same engine drops into a page with a completely different naming scheme without an edit.

That does mean each hook is written twice in the markup: a class for painting and a data- attribute for moving. That is the deal, and it is worth it — the alternative is an engine that can only ever be used by a page that agreed to its class names.

One note on the wrapper: type="module" scripts are deferred, so the DOM is already parsed by the time the module runs. The DOMContentLoaded listener below is a house habit, not a requirement.

The mechanic, in three pieces

1 · Closest edge, measured on BOTH events
const dist2 = (x, y, x2, y2) => (x - x2) * (x - x2) + (y - y2) * (y - y2);

const closestEdge = (x, y, width, height) =>
  dist2(x, y, width / 2, 0) < dist2(x, y, width / 2, height) ? "top" : "bottom";

Squared distance from the pointer to the midpoint of the top edge and the midpoint of the bottom edge. No square root — comparing squares gives the same answer for a third of the work. Only the horizontal edges are tested: on a row far wider than it is tall, the left and right edges are almost never the nearest, and testing them would make the sheet fly in sideways on the rare occasion the pointer clipped a corner.

The measurement runs again on mouseleave, from that event's own coordinates. Do not remember the entry edge and reuse it. Enter from the top, drift down, leave through the bottom, and the sheet should follow you out through the bottom — a sheet that retreats the way it came reads as a spring, not as something a hand pushed.

2 · Counter-movement, which is the whole illusion
gsap.timeline({ defaults: { duration: 0.6, ease: "expo" } })
  .set(panel, { yPercent: edge === "top" ? -101 : 101 }, 0)
  .set(strip, { yPercent: edge === "top" ?  101 : -101 }, 0)
  .to([panel, strip], { yPercent: 0 }, 0);

The panel comes in from one side while its content comes from the other. Because they cancel, the marquee text stays almost still relative to the window while the sheet wipes across it — it reads as a physical sheet being drawn over a fixed picture, not as a div sliding up with its cargo. Animate them in the same direction and the effect collapses into an ordinary reveal; it will still look fine, and it will not look like this.

101, not 100. At 100 sub-pixel rounding leaves a one-pixel sliver of the panel showing along the row's edge, and on a dark sheet over a light page that sliver is the first thing you see.

Leaving is the same shape, without the set:

gsap.timeline({ defaults: { duration: 0.6, ease: "expo" } })
  .to(panel, { yPercent: edge === "top" ? -101 : 101 }, 0)
  .to(strip, { yPercent: edge === "top" ?  101 : -101 }, 0);
3 · A seamless loop, which means measuring
// `template` is the ONE part authored in the markup, still in the DOM. Measure a detached clone
// instead and offsetWidth is 0, so the loop never starts and nothing tells you why.
const partWidth = template.offsetWidth;
// Measured against the ROW, not the window: the row is what has to stay covered. A component in a
// narrow column would otherwise build several copies it can never show.
const span = item.offsetWidth || window.innerWidth;
const wanted = Math.max(4, Math.ceil(span / partWidth) + 2);
while (strip.children.length > wanted) strip.lastElementChild.remove();
while (strip.children.length < wanted) strip.append(template.cloneNode(true));

loop?.kill();
gsap.set(strip, { x: 0 });
loop = gsap.to(strip, { x: -partWidth, duration: 18, ease: "none", repeat: -1 });

Animate exactly one measured copy width and the wrap is invisible, because the strip at -partWidth is pixel-identical to the strip at 0. Any other number — a round -2000, a percentage, two copies — and the jump shows on every cycle.

The copy count is measured, not fixed: one narrow copy on a wide row leaves a gap at the end of the travel and the seam appears. Two spare copies as headroom, a floor of four, and a rebuild on a 120 ms debounced resize, because a drag fires resize dozens of times and each pass rebuilds every row.

Re-measure after document.fonts.ready as well. Web fonts change the measured width after first paint, and the first measurement is otherwise the fallback typeface's.

The trap that will cost you an hour

GSAP has to be told to own the transform.

The stylesheet parks the panel offstage so it cannot flash before the script runs:

.curtain { transform: translate3d(0, 101%, 0); }

getComputedStyle hands that back already resolved to pixels. GSAP reads it as y: 132px, yPercent: 0 and from then on it *adds*: asking for yPercent: -101 lands at 132 − 132 = 0 — open — and animating to yPercent: 0 returns it to 132 — closed. The effect runs exactly backwards, opening on leave and closing on enter, and not one line appears in the console.

Pin it once per element, before any timeline touches it:

gsap.set(panel, { y: 0, yPercent: 101 });
gsap.set(strip, { y: 0, yPercent: 0 });

Layout / HTML

<section class="index">
  <p class="index-head"><span>Contents</span><span>Hover a strand</span></p>

  <a class="row" href="#order" data-flow-item>
    <span class="row-no">01</span>
    <span class="row-name">Portrait</span>
    <span class="row-count">18 plates</span>
    <span class="curtain" data-flow-panel aria-hidden="true">
      <span class="curtain-strip" data-flow-strip>
        <span class="curtain-part" data-flow-part>
          <span class="curtain-word">Portrait</span>
          <i class="curtain-still" style="--still: url(/c/flowing-menu-curtain/portrait.jpg)"></i>
        </span>
      </span>
    </span>
  </a>
  …four more: Object, Landscape, Interior, Archive…
</section>

Exactly one [data-flow-part] is authored; the script clones it to fill. The curtain is aria-hidden because it repeats the row's own name — a screen reader should hear "Portrait, 18 plates", not "Portrait Portrait Portrait Portrait".

The rest of the page is a cover with a masked plate and masthead copy, a stock-and-binding spec grid, and a footer with a pill CTA.

Styling

.row {
  position: relative;      /* the panel's frame */
  overflow: hidden;        /* or the sheet paints over the rows above and below on the way past */
  display: grid;
  grid-template-columns: 5rem 1fr auto;
  height: var(--row);      /* a definite height: the curtain's stage */
}

.curtain {
  position: absolute; inset: 0; z-index: 1;
  display: block; overflow: hidden;
  pointer-events: none;    /* NOT optional — see below */
  background: var(--ink);
  transform: translate3d(0, 101%, 0);
}

.curtain-strip { display: flex; align-items: center; height: 100%; width: max-content; }
.curtain-part  { display: flex; align-items: center; flex-shrink: 0; height: 100%; }

.curtain-still {
  width: clamp(120px, 15vw, 210px);
  height: 62%;
  border-radius: 999px;
  background-image: var(--still);
  background-size: cover;
}

Three of those lines are load-bearing and easy to lose:

  • pointer-events: none on the panel. It covers the whole row, so without it the sheet swallows the click on the very link it decorates — and worse, the pointer entering the panel counts as leaving the row, so the curtain flickers open and shut for ever.
  • width: max-content on the strip. A flex row inside a clipped box will otherwise shrink to the box and every copy will squash.
  • height: 100% on the part. The still is sized as a *percentage*, and a percentage height against an auto-height parent does not resolve — the still comes out full width and zero tall, taking up its space in the rhythm and showing nothing at all. This one is quiet: the spacing looks right and the pictures are simply absent.

Dress

A printed quarterly's contents page. Warm uncoated paper, near-black ink, one hot orange lifted out of the plates.

  • Ground #f4f1ea paper, #eae5da for the footer
  • Ink #14120f, #7c766c for labels
  • Accent #e0521f, and it earns its keep on almost nothing: the row numerals, the selection colour, and a focus ring. On a page this quiet one 0.68rem numeral per row is enough to key the whole thing; spend it anywhere else and the contents page starts competing with the plates
  • Type Funnel Display for the masthead and the row names (700 on the display at -0.035em, 600 on the rows at -0.03em) and Overpass Mono at 0.68rem uppercase with 0.08em tracking for every label, count and spec
  • Rows at clamp(3.4rem, 8vw, 6.6rem) tall — large enough that the sheet is an event, short enough that five of them fit on a screen

The curtain is the negative of the page: ink ground, paper type. A coloured panel reads as a highlight; an inverted one reads as a different sheet of paper being pulled across. If you adapt this to a brand colour, check it — a mid-tone panel makes the marquee text fight the ground and the whole thing turns muddy.

Behaviour worth building deliberately

No hover, no work. Bail out of the whole per-row setup when (hover: hover) and (pointer: fine) does not match. The panel never opens on a touchscreen, so cloning its copies and leaving an infinite tween spinning behind it is one wasted tween per row, for ever, on every phone.

mouseenter/mouseleave, not their pointer equivalents. On a touchscreen a tap fires pointerenter and very often never fires pointerleave, so the curtain stays open on top of the link.

Reduced motion. The marquee runs on its own for ever, so it is autonomous motion: under prefers-reduced-motion: reduce, do not start the loop, and shorten the curtain to a 0.2s power1.out move. The rows stay links either way.

Cleanup. Return a destroy() that kills each loop, clears the debounce timer and removes the resize and hover listeners.

Definition of done

Run the pointer down the list from above: each row's sheet drops in from the top as you arrive and drops out through the bottom as you leave, one after another, with the marquee text sitting almost still behind the wipe. Come back up from below and every sheet rises instead. Rest on one row and the ink band scrolls its name and stills past for ever with no visible seam. Resize the window and the seam still never shows. On a phone there is no sheet at all and the contents page is just a contents page.