Vai al contenuto
animorio
EN
Tutti i componenti

Il menu contestuale a imbuto

Menu, Transizioni, SVG, HoverGSAPImpegnativaPeso compresso: Demo 16 KBGSAP 28 KBGratisScarica lo zip

Il menu del tasto destro su una foto, che esce dalla punta del puntatore come da un imbuto: le voci scorrono fuori dalla strozzatura e si allargano, l'imbuto si raddrizza nel menu con un piccolo slancio. Un evidenziatore scivola da una voce all'altra e la scritta cambia colore proprio sul suo bordo; scelta una voce, il menu viene risucchiato nel punto da cui è uscito mentre la foto gira, prende una stella o cambia filtro.

Il codice

Una cartella che funziona da sola: index.html, style.css, script.js e le librerie da CDN. Nelle schede React e Vue la stessa demo è già un componente, che spegne le sue animazioni quando esce dalla pagina.

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

Cambia valori, colori e testi con le manopole e guarda l’animazione rispondere dal vivo. Oppure scrivi tu nel codice. Poi scarica la cartella con le tue modifiche.

39 valori da cambiare

Oppure chiedila a Claude Code

Col connettore di Animorio, Claude Code la prende da qui con tutta la sua guida e la adatta al tuo progetto. Incolla questa richiesta:

Prendi «Il menu contestuale a imbuto» da Animorio e adattala al mio sito

Cosa fa

Su un fondo bianco c'è una foto quadrata, un porto al tramonto col faro, disegnata in SVG, e sopra il suo nome, harbour-at-dusk.jpg, con l'invito a cliccarla col tasto destro. Il clic destro apre il menu proprio sotto la punta del puntatore: prima esce una lingua scura e stretta che scende dal puntatore, larga in fondo e sottile in cima come un imbuto, con le voci che scorrono fuori una dopo l'altra, strette e poi sempre più larghe, a cominciare da Delete; poi l'imbuto si raddrizza, il menu scende un poco oltre il suo posto, torna su e si allarga per un attimo prima di fermarsi. Le voci sono Copy, Rotate, Add to favourites, Filter con la sua freccetta e, dopo una riga, Delete in rosso, ognuna con la sua icona e la sua scorciatoia. Passando sulle voci un evidenziatore color albicocca scivola dall'una all'altra con un piccolo rimbalzo; dove passa, la scritta e l'icona diventano scure proprio sul suo bordo, anche a metà di una lettera, e su Delete l'evidenziatore è rosso. Ogni icona fa la sua mossa: i due fogli di Copy si separano, Rotate gira, la stella fa un giro e si gonfia, le manopole di Filter scorrono, il coperchio del cestino si alza. Fermandosi su Filter, dalla fine della sua riga esce allo stesso modo un secondo menu con quattro filtri, Original, Vivid, Mono e Faded, ognuno con una piccola immagine della foto in quel filtro; passandoci sopra la foto li prova subito. Scegliendo una voce l'evidenziatore lampeggia e il menu viene risucchiato nel punto da cui è uscito: la cima si stringe nell'imbuto e le voci ci scivolano dentro. Intanto la foto fa quello che si è chiesto: gira di un quarto con una molla, prende una stella nell'angolo, cambia filtro o sparisce lasciando il suo posto vuoto con Undo, e sopra la foto una riga dice che cosa è successo. Vicino ai bordi della finestra il menu si apre a sinistra o in su, sempre dalla punta del puntatore. Al telefono si apre tenendo premuta la foto, che si stringe un poco mentre la si tiene; da tastiera con Shift F10, il tasto Menu o Invio.

Come funziona

Il punto e il posto

Il menu si apre col tasto destro (pointerdown con button 2, così parte subito e una pressione trascinata fino a una voce e lasciata la sceglie), con la pressione lunga di HOLD secondi al telefono, con Shift F10, il tasto Menu o Invio. Va a destra e sotto il punto; se non c'è posto va a sinistra o sopra, e comunque resta a 8 pixel dai bordi. Il punto da cui esce, la strozzatura, è dove stava il puntatore, riportato sul bordo del menu più vicino: lì convergono tutte le righe.

L'imbuto

GSAP muove tre numeri: o, quanto del menu è ancora dentro l'imbuto (da 1 a 0, con back.out per lo slancio), fk, quanto è lungo l'imbuto (da 1 a 0, quando il menu è quasi fuori), e s, il gonfiore finale (fino a SWELL e ritorno con elastic.out). A p pixel dalla strozzatura il menu è largo NECK pixel più il resto moltiplicato per smoothstep(p / lunghezza), con la lunghezza pari a FUNNEL volte l'altezza del menu per fk: la curva a S dà ai fianchi la forma dell'effetto genio.

Le righe dentro l'imbuto

Ogni riga scivola verso la strozzatura di o volte l'altezza del menu e si stringe con scaleX alla larghezza dell'imbuto nel punto dove si trova il suo centro, con transform-origin sulla strozzatura; sotto il 30% di larghezza sparisce. Così esce per prima la riga più lontana, e alla chiusura la cima si stringe e le righe ci scivolano dentro. A menu fermo le trasformazioni si tolgono, e le parole restano nitide.

Il contorno ridisegnato

Il fondo del menu non è un riquadro: è un path SVG riscritto a ogni fotogramma, 26 punti per fianco calcolati con la stessa formula delle righe, gli angoli arrotondati con curve quadratiche e un raggio che si stringe quando l'imbuto è sottile. L'ombra è un drop-shadow sull'svg, quindi segue la forma. Il sottomenu è lo stesso oggetto, con la strozzatura sull'angolo vicino alla fine della riga Filter.

L'evidenziatore che scopre

Ogni riga porta una seconda copia di sé, color albicocca con le parole scure (rossa con le parole bianche per Delete), sovrapposta e tagliata da un clip-path inset. L'evidenziatore è solo un numero, la sua altezza, che GSAP fa scivolare in GLIDE secondi: a ogni passo ogni riga mostra la parte della sua copia che ci cade sotto, con gli angoli arrotondati solo sulle estremità vere, così il colore delle parole cambia esattamente sul bordo.

Il sottomenu, la mira e la tastiera

Filter apre il suo menu dopo SUB_WAIT secondi; andando verso il sottomenu in diagonale il puntatore attraversa le altre voci, ma finché resta nel triangolo fra dove era e il bordo del sottomenu il cambio aspetta 0,16 secondi. Il menu ha role menu, le voci menuitem, i filtri menuitemradio; frecce, Home, End, la prima lettera di una voce, freccia destra e sinistra per il sottomenu, Invio, Esc e Tab. Il fuoco torna sulla foto solo a chi usa la tastiera.

Valori da cambiare

NomeFileDi partenzaCosa cambia
POURscript.js0.46I secondi del menu che esce dal puntatore.
SUCKscript.js0.3I secondi del menu risucchiato nel puntatore.
FUNNELscript.js0.9Quanto è lungo l'imbuto mentre il menu esce, in altezze del menu: 0 lo fa scivolare fuori senza imbuto.
NECKscript.js6I pixel della strozzatura, il punto più stretto, sulla punta del puntatore.
OVERSHOOTscript.js1.7Quanto il menu va oltre il suo posto prima di fermarsi (la forza di back.out); 0 si ferma senza slancio.
SWELLscript.js0.045Quanto il menu si allarga per un attimo arrivando (0,045 = 4,5%).
GLIDEscript.js0.26I secondi dell'evidenziatore che passa da una voce all'altra.
HOLDscript.js0.45I secondi della pressione che apre il menu al telefono.
SUB_WAITscript.js0.12I secondi che il puntatore resta su Filter prima che si apra il suo menu.

Provali nel Playground

Quando usarla

Per un oggetto che ha più azioni di quante ne stiano in vista: una foto in una galleria, un file, una riga di una tabella, una scheda in una bacheca. Si apre dove si è cliccato, quindi le azioni sono sotto la mano; il versamento dura meno di mezzo secondo e le voci si possono già scegliere a metà. Per un'app usata tutto il giorno conviene accorciare POUR e SUCK.

Sul telefono

Si apre tenendo premuta la foto per HOLD secondi: la foto si stringe un poco mentre la si tiene e torna su con una molla quando il menu esce sotto il dito. Si può scegliere con un tocco, oppure senza staccare il dito, scivolando sulla voce e lasciando. Le voci sono alte 44 pixel e senza scorciatoie; il sottomenu, che di lato non ci sta, si apre sopra il menu, che resta dietro attenuato.

Con il movimento ridotto

Niente imbuto e niente slancio: il menu compare al suo posto in dissolvenza e se ne va allo stesso modo, l'evidenziatore salta da una voce all'altra e le icone stanno ferme. La foto non gira: si spegne, si gira e si riaccende; la stella e il posto vuoto compaiono in dissolvenza.

Trappole

  • Le spunte del sottomenu, con «visibility: visible» sulla voce scelta, comparivano nell'angolo in alto della pagina col menu chiuso: un figlio visibile vince sul genitore nascosto. Le spunte ora si accendono con l'opacità.
  • Con il bordo arrotondato nel CSS, l'evidenziatore a cavallo fra due righe mostrava una tacca nel mezzo: ogni pezzo era arrotondato per conto suo. Ora gli angoli stanno solo nel clip-path, e solo sulle estremità vere (inset con un raggio per angolo).
  • Mentre il menu veniva risucchiato perdeva i clic, e Chrome gli mandava un pointerleave anche col mouse fermo: l'evidenziatore della voce scelta si spegneva a metà. Gli eventi del puntatore contano solo su un menu aperto.
  • Col tasto destro il menu prendeva il fuoco, ma subito dopo il mousedown lo dava alla foto e le frecce non funzionavano: sul pointerdown del tasto destro c'è preventDefault.
  • Il movimento d'andata di un'icona (0,6 secondi) durava più di quello di ritorno (0,35) e, passando veloci sulle voci, lasciava l'icona girata: prima di ogni movimento si fermano quelli in corso.
  • L'evento contextmenu arriva quando vuole: su Windows al rilascio del tasto, su macOS e Linux alla pressione, su Android alla fine della pressione lunga, dalla tastiera con button 0. Un segnale dice se il menu è già stato aperto dalla pressione, così non si apre due volte.
  • Il menu, che scende dal puntatore, copriva il nome della foto scritto sotto: il nome e il suggerimento ora stanno sopra la foto.

Librerie

GSAP 3.15.0, Funnel Sans (font variabile) 5.3.0.

Compressi con gzip, i tre file della demo pesano 16 KB; le librerie: GSAP 28 KB.

Collaudo

Provata il 6 ottobre 2026

  • Computer: Fatto
  • Telefono: Fatto
  • Movimento ridotto: Fatto
  • Safari su iPhone: Da fare

Licenza d’uso

Puoi usarla nei tuoi progetti e in quelli dei tuoi clienti. Non puoi rivenderla né ridistribuirla così com’è. GSAP resta sotto la sua licenza Standard. Licenza completa.

Tutti i componenti