Skip to content
animorio
IT
All components

The funnel context menu

Menus, Transitions, SVG, HoverGSAPAdvancedCompressed weight: Demo 16 KBGSAP 28 KBFreeDownload the zip

The right-click menu of a photo, coming out of the tip of the pointer as if through a funnel: the items stream out of the narrow point and widen, and the funnel straightens into the menu with a little spring. A highlight glides from item to item and the words change colour right at its edge; once an item is chosen, the menu is drawn back into the point it came from while the photo turns, takes a star or changes filter.

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 context menu that pours from the pointer</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource-variable/funnel-sans@5.3.0/index.css">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main class="stage">
    <!-- a photo in a library. Right-click it (touch and hold on a phone, Shift F10 or the Menu key from the keyboard)
         and its menu pours out of the tip of the pointer as if through a funnel; chosen, or sent away, it is drawn
         back into the same point -->
    <figure class="shot" data-shot>
      <!-- the name and the hint sit above the photo: the menu opens downwards from the pointer and never covers them -->
      <figcaption class="shot__cap">
        <span class="shot__name">harbour-at-dusk.jpg</span>
        <span class="shot__hint" id="shot-hint"><span class="shot__hintword" data-hint>Right-click the photo</span></span>
      </figcaption>
      <div class="shot__frame">
        <button class="shot__photo" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="ctx-main" aria-label="Photo, harbour-at-dusk.jpg" aria-describedby="shot-hint" data-photo data-autoclic>
          <span class="shot__lift" data-lift>
            <span class="shot__turn" data-turn>
              <svg class="shot__img" viewBox="0 0 300 300" aria-hidden="true" data-img>
                <defs>
                  <linearGradient id="shot-sky" x1="0" y1="0" x2="0" y2="1">
                    <stop offset="0" stop-color="#232857"/>
                    <stop offset="0.34" stop-color="#5f4888"/>
                    <stop offset="0.6" stop-color="#c96c84"/>
                    <stop offset="0.8" stop-color="#f39d7b"/>
                    <stop offset="1" stop-color="#ffd09c"/>
                  </linearGradient>
                  <linearGradient id="shot-sea" x1="0" y1="0" x2="0" y2="1">
                    <stop offset="0" stop-color="#b9708a"/>
                    <stop offset="0.12" stop-color="#5a5a96"/>
                    <stop offset="0.55" stop-color="#2c3770"/>
                    <stop offset="1" stop-color="#161d44"/>
                  </linearGradient>
                  <radialGradient id="shot-glow" cx="0.5" cy="0.5" r="0.5">
                    <stop offset="0" stop-color="#ffe2b3" stop-opacity="0.9"/>
                    <stop offset="0.35" stop-color="#ffc58f" stop-opacity="0.45"/>
                    <stop offset="1" stop-color="#ffb07c" stop-opacity="0"/>
                  </radialGradient>
                  <clipPath id="shot-tower"><path d="M77.5 230 81 166h8l3.5 64z"/></clipPath>
                </defs>
                <rect width="300" height="300" fill="url(#shot-sky)"/>
                <ellipse cx="204" cy="190" rx="92" ry="70" fill="url(#shot-glow)"/>
                <circle cx="204" cy="190" r="25" fill="#ffe6bd"/>
                <path d="M0 177c22-7 44-9 66-4 15 3 27 9 40 15H0z" fill="#4c3a72"/>
                <path d="M226 188c16-8 34-11 52-9 8 1 15 3 22 5v4z" fill="#5d4479"/>
                <rect y="188" width="300" height="112" fill="url(#shot-sea)"/>
                <g fill="#ffd7a3">
                  <rect x="182" y="193" width="44" height="2.6" rx="1.3" opacity="0.95"/>
                  <rect x="188" y="200" width="32" height="2.4" rx="1.2" opacity="0.85"/>
                  <rect x="179" y="207" width="50" height="2.2" rx="1.1" opacity="0.7"/>
                  <rect x="192" y="215" width="24" height="2.2" rx="1.1" opacity="0.62"/>
                  <rect x="184" y="224" width="40" height="2" rx="1" opacity="0.48"/>
                  <rect x="196" y="234" width="16" height="2" rx="1" opacity="0.4"/>
                  <rect x="187" y="246" width="34" height="1.8" rx="0.9" opacity="0.28"/>
                  <rect x="198" y="260" width="12" height="1.8" rx="0.9" opacity="0.2"/>
                </g>
                <g stroke="#a7a3d6" stroke-width="1.2" stroke-linecap="round" opacity="0.32">
                  <path d="M118 204h26M138 218h34M236 210h30M248 236h22M110 248h30M150 270h28M232 282h40M34 262h24"/>
                </g>
                <path d="M0 196 C40 192 90 194 120 199 L120 202 C90 198 40 197 0 200z" fill="#e8a283" opacity="0.18"/>
                <path d="M84 160 0 128v58z" fill="#ffe3a6" opacity="0.13"/>
                <g fill="#191632">
                  <rect x="0" y="229" width="104" height="7" rx="1"/>
                  <rect x="12" y="236" width="4" height="22"/>
                  <rect x="40" y="236" width="4" height="22"/>
                  <rect x="68" y="236" width="4" height="22"/>
                  <rect x="94" y="236" width="4" height="22"/>
                </g>
                <path d="M77.5 230 81 166h8l3.5 64z" fill="#f4ece1"/>
                <g clip-path="url(#shot-tower)" fill="#d9483f">
                  <rect x="70" y="174" width="30" height="9"/>
                  <rect x="70" y="194" width="30" height="10"/>
                  <rect x="70" y="215" width="30" height="10"/>
                </g>
                <rect x="78" y="161" width="14" height="5" rx="1" fill="#191632"/>
                <rect x="80.5" y="150" width="9" height="11" fill="#ffe4a4"/>
                <path d="M79 150.5 85 142l6 8.5z" fill="#191632"/>
                <path d="M244 216v-28l17 26z" fill="#f6eee4"/>
                <path d="M241.5 214.5v-21l-10 19z" fill="#e9dccd"/>
                <path d="M232 216h34l-5 6h-24z" fill="#191632"/>
                <g fill="none" stroke="#2a2350" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
                  <path d="M122 96q5-5 10 0 5-5 10 0"/>
                  <path d="M150 82q4-4 8 0 4-4 8 0"/>
                </g>
              </svg>
              <span class="shot__flash" aria-hidden="true" data-flash></span>
            </span>
          </span>
        </button>
        <!-- added to favourites: a star in the corner, which does not turn with the photo -->
        <svg class="shot__star" viewBox="0 0 24 24" aria-hidden="true" data-star><path d="M12 3.4l2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L12 16.8l-5.3 2.8 1.1-5.9-4.3-4.1 5.9-.8z"/></svg>
        <!-- deleted: an empty place and the way back -->
        <div class="shot__gone" data-gone hidden>
          <span class="shot__gonetext">Photo deleted</span>
          <button class="shot__undo" type="button" data-undo>Undo</button>
        </div>
      </div>
    </figure>
  </main>

  <!-- the menu. The script draws its outline (the funnel) in the svg and moves the rows; each row carries a second,
       coloured copy of itself that the highlight uncovers -->
  <div class="ctx" id="ctx-main" role="menu" aria-label="Photo actions" tabindex="-1" data-menu="main">
    <svg class="ctx__sheet" aria-hidden="true" data-sheet>
      <defs><linearGradient id="ctx-fill-main" x1="0" y1="0" x2="0" y2="1"><stop offset="0" class="ctx__top"/><stop offset="0.4" class="ctx__body"/></linearGradient></defs>
      <path fill="url(#ctx-fill-main)" data-path/>
    </svg>
    <div class="ctx__list">
      <div class="ctx__item" role="menuitem" tabindex="-1" data-item="copy">
        <span class="ctx__face">
          <svg class="ctx__icon" viewBox="0 0 24 24" aria-hidden="true"><path data-back d="M15 6.5V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h.5"/><rect data-front x="9" y="9" width="11" height="11" rx="2.5"/></svg>
          <span class="ctx__label" data-label>Copy</span>
          <span class="ctx__keys" data-keys="C"></span>
        </span>
      </div>
      <div class="ctx__item" role="menuitem" tabindex="-1" data-item="rotate">
        <span class="ctx__face">
          <svg class="ctx__icon" viewBox="0 0 24 24" aria-hidden="true"><g data-spin><rect x="8" y="9.5" width="9.5" height="9.5" rx="2"/><path d="M4.5 12.5A7.5 7.5 0 0 1 16.4 5.4"/><path d="M16.8 2.4l.2 3.3-3.3.4"/></g></svg>
          <span class="ctx__label" data-label>Rotate</span>
          <span class="ctx__keys" data-keys="R"></span>
        </span>
      </div>
      <div class="ctx__item" role="menuitem" tabindex="-1" data-item="favourite">
        <span class="ctx__face">
          <svg class="ctx__icon" viewBox="0 0 24 24" aria-hidden="true"><path data-twinkle d="M12 3.4l2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L12 16.8l-5.3 2.8 1.1-5.9-4.3-4.1 5.9-.8z"/></svg>
          <span class="ctx__label" data-label>Add to favourites</span>
          <span class="ctx__keys"></span>
        </span>
      </div>
      <div class="ctx__item" role="menuitem" tabindex="-1" aria-haspopup="menu" aria-expanded="false" aria-controls="ctx-filter" data-item="filter">
        <span class="ctx__face">
          <svg class="ctx__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/><rect class="ctx__knob" data-knob x="6" y="4.5" width="4" height="5" rx="1.2"/><rect class="ctx__knob" data-knob x="13" y="9.5" width="4" height="5" rx="1.2"/><rect class="ctx__knob" data-knob x="8" y="14.5" width="4" height="5" rx="1.2"/></svg>
          <span class="ctx__label" data-label>Filter</span>
          <span class="ctx__keys"><svg class="ctx__chevron" viewBox="0 0 24 24" aria-hidden="true" data-chevron><path d="M9.5 6l6 6-6 6"/></svg></span>
        </span>
      </div>
      <div class="ctx__sep" role="separator"></div>
      <div class="ctx__item ctx__item--danger" role="menuitem" tabindex="-1" data-item="delete">
        <span class="ctx__face">
          <svg class="ctx__icon" viewBox="0 0 24 24" aria-hidden="true"><g data-lid><path d="M4 6.8h16"/><path d="M9.6 6.8V5.2A1.2 1.2 0 0 1 10.8 4h2.4a1.2 1.2 0 0 1 1.2 1.2v1.6"/></g><path d="M6.2 6.8 7.1 19a2 2 0 0 0 2 1.9h5.8a2 2 0 0 0 2-1.9l.9-12.2M10 10.8v6.2M14 10.8v6.2"/></svg>
          <span class="ctx__label" data-label>Delete</span>
          <span class="ctx__keys" data-keys="Delete"></span>
        </span>
      </div>
    </div>
  </div>

  <!-- the submenu of Filter: it pours out of the end of the Filter row -->
  <div class="ctx ctx--sub" id="ctx-filter" role="menu" aria-label="Filter" tabindex="-1" data-menu="sub">
    <svg class="ctx__sheet" aria-hidden="true" data-sheet>
      <defs><linearGradient id="ctx-fill-sub" x1="0" y1="0" x2="0" y2="1"><stop offset="0" class="ctx__top"/><stop offset="0.4" class="ctx__body"/></linearGradient></defs>
      <path fill="url(#ctx-fill-sub)" data-path/>
    </svg>
    <div class="ctx__list">
      <div class="ctx__item" role="menuitemradio" aria-checked="true" tabindex="-1" data-filter="original">
        <span class="ctx__face">
          <span class="ctx__swatch" aria-hidden="true"></span>
          <span class="ctx__label" data-label>Original</span>
          <span class="ctx__keys"><svg class="ctx__check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.6l4.3 4.3L19 7.2"/></svg></span>
        </span>
      </div>
      <div class="ctx__item" role="menuitemradio" aria-checked="false" tabindex="-1" data-filter="vivid">
        <span class="ctx__face">
          <span class="ctx__swatch" aria-hidden="true"></span>
          <span class="ctx__label" data-label>Vivid</span>
          <span class="ctx__keys"><svg class="ctx__check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.6l4.3 4.3L19 7.2"/></svg></span>
        </span>
      </div>
      <div class="ctx__item" role="menuitemradio" aria-checked="false" tabindex="-1" data-filter="mono">
        <span class="ctx__face">
          <span class="ctx__swatch" aria-hidden="true"></span>
          <span class="ctx__label" data-label>Mono</span>
          <span class="ctx__keys"><svg class="ctx__check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.6l4.3 4.3L19 7.2"/></svg></span>
        </span>
      </div>
      <div class="ctx__item" role="menuitemradio" aria-checked="false" tabindex="-1" data-filter="faded">
        <span class="ctx__face">
          <span class="ctx__swatch" aria-hidden="true"></span>
          <span class="ctx__label" data-label>Faded</span>
          <span class="ctx__keys"><svg class="ctx__check" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.6l4.3 4.3L19 7.2"/></svg></span>
        </span>
      </div>
    </div>
  </div>

  <p class="sr" aria-live="polite" data-say></p>

  <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.

39 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 funnel context menu” from Animorio and fit it into my site

What it does

On a white background there is a square photo, a harbour at sunset with a lighthouse, drawn in SVG, and above it its name, harbour-at-dusk.jpg, with an invitation to right-click it. The right click opens the menu right under the tip of the pointer: first a narrow dark tongue comes down from the pointer, wide at the bottom and thin at the top like a funnel, with the items streaming out one after the other, narrow and then wider and wider, starting from Delete; then the funnel straightens, the menu goes a little past its place, comes back up and widens for a moment before it stops. The items are Copy, Rotate, Add to favourites, Filter with its little arrow and, after a rule, Delete in red, each with its icon and its shortcut. Moving over the items an apricot highlight glides from one to the next with a little bounce; where it passes, the words and the icon turn dark right at its edge, even halfway through a letter, and on Delete the highlight is red. Each icon makes its own move: the two sheets of Copy part, Rotate turns, the star spins and swells, the knobs of Filter slide, the lid of the bin lifts. Resting on Filter, a second menu comes out of the end of its row in the same way, with four filters, Original, Vivid, Mono and Faded, each with a small picture of the photo in that filter; pointing at them, the photo tries them on at once. Choosing an item the highlight blinks and the menu is drawn back into the point it came from: the top narrows into the funnel and the items slide into it. Meanwhile the photo does what was asked: it turns a quarter on a spring, takes a star in its corner, changes filter or disappears leaving its empty place with Undo, and above the photo a line says what happened. Near the edges of the window the menu opens to the left or upwards, always from the tip of the pointer. On a phone it opens by holding the photo, which squeezes a little while it is held; from the keyboard with Shift F10, the Menu key or Enter.

How it works

The point and the place

The menu opens with the right button (pointerdown with button 2, so it starts at once and a press dragged to an item and released chooses it), with a long press of HOLD seconds on a phone, with Shift F10, the Menu key or Enter. It goes right of the point and below it; if there is no room it goes left or above, and either way it stays 8 pixels from the edges. The point it comes out of, the neck, is where the pointer was, brought onto the nearest edge of the menu: all the rows converge there.

The funnel

GSAP moves three numbers: o, how much of the menu is still inside the funnel (from 1 to 0, with back.out for the overshoot), fk, how long the funnel is (from 1 to 0, when the menu is nearly out), and s, the final swell (up to SWELL and back with elastic.out). At p pixels from the neck the menu is NECK pixels wide plus the rest times smoothstep(p / length), with the length being FUNNEL times the height of the menu times fk: the S curve gives the sides the shape of the genie effect.

The rows in the funnel

Every row slides towards the neck by o times the height of the menu and narrows with scaleX to the width of the funnel where its centre is, with the transform-origin on the neck; under 30% of the width it fades away. So the farthest row comes out first, and when closing the top narrows and the rows slide into it. With the menu at rest the transforms are removed, and the words stay sharp.

The outline drawn again

The background of the menu is not a box: it is an SVG path written again at every frame, 26 points down each side worked out with the same formula as the rows, the corners rounded with quadratic curves and a radius that shrinks when the funnel is thin. The shadow is a drop-shadow on the svg, so it follows the shape. The submenu is the same object, with its neck on the corner near the end of the Filter row.

The highlight that uncovers

Every row carries a second copy of itself, apricot with dark words (red with white words for Delete), laid on top and cut by an inset clip-path. The highlight is only a number, its height, which GSAP glides in GLIDE seconds: at every step each row shows the part of its copy that falls under it, with the corners rounded only at the true ends, so the colour of the words changes exactly at the edge.

The submenu, the aim and the keyboard

Filter opens its menu after SUB_WAIT seconds; heading for the submenu diagonally the pointer crosses the other items, but while it stays in the triangle between where it was and the edge of the submenu the change waits 0.16 seconds. The menu has role menu, the items menuitem, the filters menuitemradio; arrows, Home, End, the first letter of an item, right and left arrow for the submenu, Enter, Esc and Tab. The focus goes back to the photo only for keyboard users.

Values to change

NameFileDefaultWhat it changes
POURscript.js0.46The seconds of the menu pouring out of the pointer.
SUCKscript.js0.3The seconds of the menu being drawn back into the pointer.
FUNNELscript.js0.9How long the funnel is as the menu comes out, in heights of the menu: 0 slides it out with no funnel.
NECKscript.js6The pixels of the neck, the narrowest point, at the tip of the pointer.
OVERSHOOTscript.js1.7How far the menu runs past its place before it settles (the strength of back.out); 0 stops with no overshoot.
SWELLscript.js0.045How much wider the menu gets for a moment as it lands (0.045 = 4.5%).
GLIDEscript.js0.26The seconds of the highlight going from one item to the next.
HOLDscript.js0.45The seconds of the press that opens the menu on a phone.
SUB_WAITscript.js0.12The seconds the pointer rests on Filter before its menu opens.

Try them in the Playground

When to use it

For an object with more actions than fit in sight: a photo in a gallery, a file, a row of a table, a card on a board. It opens where you clicked, so the actions are under your hand; the pour lasts less than half a second and the items can be chosen halfway through. For an app used all day long, shorten POUR and SUCK.

On phones

It opens by holding the photo for HOLD seconds: the photo squeezes a little while it is held and springs back when the menu comes out under the finger. You can choose with a tap, or without lifting the finger, sliding onto the item and letting go. The items are 44 pixels tall and without shortcuts; the submenu, which does not fit at the side, opens over the menu, which stays behind, dimmed.

With reduced motion

No funnel and no overshoot: the menu fades in at its place and fades out the same way, the highlight jumps from item to item and the icons keep still. The photo does not turn: it fades out, turns and fades back in; the star and the empty place fade in.

Pitfalls

  • The submenu's check marks, with «visibility: visible» on the chosen item, showed up in the top corner of the page with the menu closed: a visible child wins over a hidden parent. The check marks now turn on with opacity.
  • With the corner radius in the CSS, the highlight across two rows showed a notch in the middle: each piece was rounded on its own. Now the corners live only in the clip-path, and only at the true ends (inset with a radius for each corner).
  • While being drawn back in, the menu lost its clicks, and Chrome sent it a pointerleave even with the mouse still: the highlight of the chosen item went off halfway. Pointer events count only on an open menu.
  • With the right button the menu took the focus, but right after it the mousedown gave it to the photo and the arrow keys did not work: the right button's pointerdown calls preventDefault.
  • An icon's move out (0.6 seconds) lasted longer than its move back (0.35) and, going fast over the items, left the icon turned: before every move the running ones are stopped.
  • The contextmenu event comes when it likes: on Windows when the button is released, on macOS and Linux when it is pressed, on Android at the end of the long press, from the keyboard with button 0. A flag says whether the press has already opened the menu, so it does not open twice.
  • The menu, coming down from the pointer, covered the photo's name written underneath: the name and the hint now sit above the photo.

Libraries

GSAP 3.15.0, Funnel Sans (font variabile) 5.3.0.

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

Testing

Tested on 6 October 2026

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

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