
The reactions that run a lap
Chat, SVG, Transitions, Hover
NewFree
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.
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>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
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 siteThe 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.
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.
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 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.
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.
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.
| Name | File | Default | What it changes |
|---|---|---|---|
POUR | script.js | 0.46 | The seconds of the menu pouring out of the pointer. |
SUCK | script.js | 0.3 | The seconds of the menu being drawn back into the pointer. |
FUNNEL | script.js | 0.9 | How long the funnel is as the menu comes out, in heights of the menu: 0 slides it out with no funnel. |
NECK | script.js | 6 | The pixels of the neck, the narrowest point, at the tip of the pointer. |
OVERSHOOT | script.js | 1.7 | How far the menu runs past its place before it settles (the strength of back.out); 0 stops with no overshoot. |
SWELL | script.js | 0.045 | How much wider the menu gets for a moment as it lands (0.045 = 4.5%). |
GLIDE | script.js | 0.26 | The seconds of the highlight going from one item to the next. |
HOLD | script.js | 0.45 | The seconds of the press that opens the menu on a phone. |
SUB_WAIT | script.js | 0.12 | The seconds the pointer rests on Filter before its menu opens. |
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.
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.
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.
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.
Tested on 6 October 2026
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.
With your account’s email and password.
or with your email
You’re signed in as .
With your account, you can download the zip of every animation and every pack, for free.