Skip to content
animorio
IT
All components

The curtain menu

Menus, Physics, WebGL, TransitionsWebGL, GSAPAdvancedCompressed weight: Demo 14 KBGSAP 28 KBFreeDownload the zip

A picture house's menu stands on a little stage behind a red velvet curtain: pressing Menu the two drapes run along the rail, are tied back at the sides with their folds swinging, and under a spot the entries appear. The curtain is a truly simulated cloth, points and threads in WebGL, and with the pointer near it its edge parts and ripples.

The code

A folder that runs on its own: index.html, style.css, script.js and the libraries from a CDN. The React and Vue tabs hold the same demo as a ready component, which switches its animations off when it leaves the page.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>The curtain menu</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/bodoni-moda@5.3.0/opsz.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/bodoni-moda@5.3.0/opsz-italic.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/hanken-grotesk@5.3.0/index.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="stage">
    <!-- the curtain menu: the button that opens and closes it, and the stage in its gilded arch, closed by the
         curtain, with the menu standing behind it -->
    <div class="theatre" data-theatre>
      <div class="bar">
        <button class="toggle" type="button" aria-expanded="false" aria-controls="programme" data-toggle data-autoclic>
          <svg class="toggle__icon" viewBox="0 0 24 24" aria-hidden="true">
            <path class="toggle__rail" d="M2.5 3.5h19"/>
            <path class="toggle__drape toggle__drape--left" d="M3.5 4h8.2c-.4 5.6-2.6 11.3-8.2 16.2z"/>
            <path class="toggle__drape toggle__drape--right" d="M20.5 4h-8.2c.4 5.6 2.6 11.3 8.2 16.2z"/>
          </svg>
          <span class="toggle__words" aria-hidden="true"><span class="toggle__word" data-word>Menu</span></span>
          <span class="sr" data-name>Open the menu</span>
        </button>
      </div>

      <!-- the stage: the menu stands on it, behind the curtains. The curtains and the valance over them are a cloth
           simulated in WebGL on the canvas, which lets the pointer through to the menu once they are open -->
      <div class="frame">
        <div class="scene" data-stage>
          <div class="set" aria-hidden="true">
            <span class="set__spot" data-spot></span>
            <span class="set__floor"><svg viewBox="0 0 100 100" preserveAspectRatio="none"><path d="M6.8 0L-2.8 100M10.4 0L1.6 100M14.0 0L6.0 100M17.6 0L10.4 100M21.2 0L14.8 100M24.8 0L19.2 100M28.4 0L23.6 100M32.0 0L28.0 100M35.6 0L32.4 100M39.2 0L36.8 100M42.8 0L41.2 100M46.4 0L45.6 100M50.0 0L50.0 100M53.6 0L54.4 100M57.2 0L58.8 100M60.8 0L63.2 100M64.4 0L67.6 100M68.0 0L72.0 100M71.6 0L76.4 100M75.2 0L80.8 100M78.8 0L85.2 100M82.4 0L89.6 100M86.0 0L94.0 100M89.6 0L98.4 100M93.2 0L102.8 100"/></svg></span>
          </div>

          <nav class="programme" id="programme" aria-label="Main" data-menu inert>
            <ul class="programme__list">
              <li><a class="act" href="#" data-link><span class="act__name">What’s on</span><span class="act__note">Eighteen nights in October</span></a></li>
              <li><a class="act" href="#" data-link><span class="act__name">Films</span><span class="act__note">On 35 and 70 mm</span></a></li>
              <li><a class="act" href="#" data-link><span class="act__name">Supper club</span><span class="act__note">Tables from 6 pm</span></a></li>
              <li><a class="act" href="#" data-link><span class="act__name">Private hire</span><span class="act__note">Up to 240 guests</span></a></li>
              <li><a class="act" href="#" data-link><span class="act__name">Visit</span><span class="act__note">Box office and directions</span></a></li>
            </ul>
            <div class="programme__foot" data-extra>
              <a class="tickets" href="#">Book tickets</a>
            </div>
          </nav>

          <canvas class="cloth" data-cloth aria-hidden="true"></canvas>
          <div class="velvet" data-velvet aria-hidden="true"><span></span><span></span></div>
          <div class="apron" data-apron aria-hidden="true"></div>
        </div>
      </div>
    </div>
  </main>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
  <script src="script.js"></script>
</body>
</html>

Playground

Change values, colours and text with the controls and watch the animation respond live. Or write in the code yourself. Then download the folder with your changes.

30 values to change

Or ask Claude Code for it

With the Animorio connector, Claude Code takes it from here, guide included, and fits it into your project. Paste this request:

Take “The curtain menu” from Animorio and fit it into my site

What it does

On a black background, at the top, a Menu button whose icon is a little curtain; below, a stage in a gilded arch, closed by a red velvet curtain with deep folds, a gold fringe on the hem resting on the boards and at the top a swagged pelmet with gilded rosettes. The velvet breathes a little, as in a draught, and the stage light seeps through the gap in the middle. With the pointer near it the edge of the drapes moves aside, billows and ripples, and gives a glimpse of the stage. Pressing Menu (or the curtain itself) the two drapes run to the sides on their rail, a cord gathers them halfway down and ties them, and the swags of velvet under the knot swing until they settle; meanwhile the spot comes on and the five entries (What’s on, Films, Supper club, Private hire, Visit, each with its line) rise onto the stage one after the other, with the gold Book tickets button. The entry pointed at turns gold. Pressing Close, or choosing an entry, or with Esc, the knots come undone, the velvet falls and the drapes close again in the middle.

How it works

The cloth: points and threads

Each drape is a grid of 40 by 34 points. On every step (fixed, a sixtieth of a second) a point moves as far as it moved in the step before, lightly damped, plus gravity: this is Verlet integration, which keeps no speed but the previous position. Then the threads between neighbouring points (across, down, diagonally, and two apart downwards so it does not crease too much) are pulled back to their length STIFF times in a row. A thread resists stretching a lot and squeezing hardly at all: so, gathered, the velvet folds softly instead of crumpling.

The rail and the pleats

The top row hangs from the hooks of the rail, which go in and out in depth (0, 1, 0, −1, with small differences from one pleat to the next): the threads across are born FULLNESS times longer than the gap between two hooks, and that extra cloth is what makes the pleats. Opening, the hooks close up to GATHER of the stage width and the pleats deepen by themselves, because the thread stays as long as it was.

The tie-back cord

While the curtain opens, a row halfway down is pulled a little on every iteration towards a point that starts at the hooks and travels to the knot, at TIE (across and down): pulled to the knot at once, the cloth below would flip over it. The opening itself (from 0 to 1) advances inside the simulation, one step at a time: on a slow phone that runs fewer steps than it should, the cord never runs ahead of the cloth.

Velvet in WebGL

The drapes are two meshes of triangles with normals worked out again every frame from the neighbouring points. The shader gives the velvet its look: diffuse light, a sheen where the fold turns edge-on (1 − n.z), darkness deep in the folds according to the depth of the point, and a touch of noise. At the bottom the fringe: twisted gold strands drawn with a sine on the cloth coordinate, with ragged ends made by discarding pixels; above it, a braid. The pelmet is the same shader on a still, swagged shape, with the rosettes drawn in the shader.

The pointer and the draught

Points within a sixth of the stage from the pointer are pushed aside (above all along the free edge, which so parts), dragged by its movement and billowed forwards, with a ripple that goes on even with the pointer still. At rest a light draught (BREEZE) moves the velvet near the bottom. The canvas lets the pointer through: underneath is the real menu, with its links.

Values to change

NameFileDefaultWhat it changes
OPENscript.js1.8The seconds of the curtain opening (closing takes a little less).
GATHERscript.js0.3Where the top of each open drape stops, as a fraction of the stage width from its side.
TIEscript.js[0.12, 0.52]Where each drape is tied back: across and down, as fractions of the stage.
FULLNESSscript.js1.8How much wider the velvet is than the half of the stage it covers: more, deeper folds.
SWAYscript.js1How much the curtain moves aside and sways near the pointer.
BREEZEscript.js0.6The draught that keeps the velvet moving at rest.
STIFFscript.js10How many times a step the threads are pulled back to their length: fewer, a stretchier cloth (and lighter to work out).

Try them in the Playground

When to use it

For the site of a theatre, a cinema, a club, a restaurant with a dining room or an event: wherever opening the menu can be a small show and velvet truly belongs. For an everyday site it is too much: the curtain asks for a moment’s attention.

On phones

The stage narrows to the width of the screen and becomes tall; there the drapes gather tighter (70% of GATHER and TIE), with less cloth and the knot a little lower, to leave the menu clear. A tap opens and closes; a finger moving over the curtain makes it sway like the mouse.

With reduced motion

The curtain does not run: it fades and comes back already open and tied (or already shut), with still folds, no draught and no swaying under the pointer; the entries fade in without rising.

Pitfalls

  • With threads also stiff against squeezing, the velvet gathered by the cord crumpled into sharp edges and below the knot spread back as wide as before, covering the menu: a thread must resist stretching and hardly resist squeezing at all (SOFT, 0.07).
  • Pulling the tied row straight to the knot flipped the cloth below over the cord, with the fringe turned upwards: the point it pulls towards starts at the hooks and reaches the knot as the opening goes on.
  • The opening animated with a GSAP tween, on clock time, ran ahead of the cloth on a slow phone: the simulation fell behind and the cord jerked. The opening now advances inside the simulation step.
  • Points kept in a Float64Array for precision must be copied into a Float32Array before going to WebGL: read as 32-bit floats their bytes become a crazed geometry.
  • A floor with transform: perspective under a WebGL canvas forced the browser to recomposite the whole scene every frame, from 60 down to 10 frames a second without a GPU: the boards are now a flat trapezoid with lines in SVG.
  • In WebGL 1 a uniform used by both shaders must have the same precision: with highp in the vertex and mediump in the fragment the program does not link, and the curtain would have stayed the CSS fallback.

Libraries

GSAP 3.15.0, Bodoni Moda (font variabile) 5.3.0, Hanken Grotesk (font variabile) 5.3.0.

Compressed with gzip, the three demo files weigh 14 KB; the libraries: GSAP 28 KB.

Testing

Tested on 4 October 2026

  • Desktop: Done
  • Phone: Done
  • Reduced motion: Done
  • Safari on iPhone: Done

License

Use it in your own projects and in your clients’ projects. Do not resell it or redistribute it as is. GSAP stays under its own Standard license. Full license.

All components