The iOS context menu submenu, the thing that happens when you tap "Open With" in Files, rebuilt for the web with Motion and nothing else. There is no popover library and no second panel sliding in from the right. I built it together with an AI agent, going back and forth until it matched the original. I gave the agent a screen recording of the real menu to work from. The full code is at the end.
What went wrong first
The agent's first version expanded the card inline, in the list. Every row below it got shoved down as it grew, which is exactly what iOS never does. A recording shows what happens, but not how it works, so I had to explain it step by step. I described how the animation should behave, one detail at a time, until it did. The card has to be an absolutely positioned overlay at the row's measured position, floating above the dimmed list, so nothing underneath moves a pixel.
How it works
Apple's trick is that the submenu isn't a new surface. The row you tap becomes the header of a card that expands in place. The chevron rotates from pointing right to pointing down, the header keeps the row's exact height and padding, and the items unfold underneath it. Behind it the menu dims and shrinks slightly, pivoting on the row you tapped, so the two cards stay left-aligned and the header sits exactly where the row was. Motion carries all of it on two springs1: one on height to open, and a bounce-free one to collapse back. The rest is plain CSS.
Two traps with the morph
The header label gains a little weight as the card opens. That has to be an animated number rather than a CSS swap. A swap snaps the text the instant the card mounts, which against a still-expanding card reads as a glitch. It only works because Geist is variable2: the browser renders the fractional weights Motion writes out mid-flight, instead of rounding to the nearest cut.
The second trap is transform-origin. Motion owns it and rebuilds it from its own originX and originY props, so setting it in plain CSS gets silently overwritten. The card then pivots around its center, a few pixels off its row, which is the one thing this effect can't survive.
The glass
The glass is backdrop-filter: blur(40px) saturate(180%)3 under a translucent fill, with a 1px inset rim highlight for the edge. The saturate is what makes the colors behind the menu glow through instead of graying out.
Do not hand-write the -webkit- prefix next to it. This project builds CSS through Lightning CSS4, and pairing the prefixed property with the standard one made it drop both from the output. The rule shipped with no blur at all, in every browser.
If you're about to build one
Escape, clicking outside, and tapping the dimmed parent should all collapse the card. MotionConfig with reducedMotion={'user'} turns the springs off for anyone who has asked their OS for less motion. The submenu then simply appears and disappears in place.
The code
IosMenu.tsx
IosMenu.module.css
References
- 1.Layout animations · Motion
- 2.Geist · Vercel
- 3.backdrop-filter · CSS · MDN
- 4.Lightning CSS · lightningcss.dev