Experiments

iOS Menu

Recreating the iOS context menu submenu expansion with Motion

July 1, 2026

Click a row to open it

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

"use client"
import {
ArrowSquareOutIcon,
CopyIcon,
EyeIcon,
FileIcon,
GearIcon,
InfoIcon,
PencilCircleIcon,
PencilSimpleIcon,
TagIcon,
TrashIcon,
} from "@phosphor-icons/react"
import { AnimatePresence, MotionConfig, motion } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { useOnClickOutside } from "usehooks-ts"
import styles from "./IosMenu.module.css"
const ROW_HEIGHT = 44
const CARD_PADDING = 12
interface SubmenuItem {
label: string
icon?: React.ReactNode
}
interface MenuItem {
label: string
icon: React.ReactNode
submenu?: SubmenuItem[]
danger?: boolean
}
const GROUPS: MenuItem[][] = [
[
{
label: "Quick Look",
icon: <EyeIcon size={20} />,
},
{
label: "Open With",
icon: <ArrowSquareOutIcon size={20} />,
submenu: [
{ icon: null, label: "App 1" },
{ icon: null, label: "App 2" },
{ icon: null, label: "App 3" },
{ label: "Preview with Quick Look" },
],
},
],
[
{ label: "Get Info", icon: <InfoIcon size={20} /> },
{ label: "Rename", icon: <PencilSimpleIcon size={20} /> },
{ label: "Duplicate", icon: <CopyIcon size={20} /> },
],
[
{ label: "Tags...", icon: <TagIcon size={20} /> },
{
label: "Quick Actions",
icon: <GearIcon size={20} />,
submenu: [
{ icon: <PencilCircleIcon size={20} />, label: "Markup" },
{ icon: <FileIcon size={20} />, label: "Optimise File Size" },
],
},
],
[{ label: "Delete", icon: <TrashIcon size={20} />, danger: true }],
]
function Chevron({ down }: { down?: boolean }) {
return (
<motion.svg
role={"presentation"}
viewBox={"0 0 12 12"}
className={styles.chevron}
initial={down ? { rotate: 0 } : false}
animate={{ rotate: down ? 90 : 0 }}
exit={down ? { rotate: 0 } : undefined}
>
<path
d={"M4.5 2.5 8 6l-3.5 3.5"}
fill={"none"}
stroke={"currentColor"}
strokeWidth={1.8}
strokeLinecap={"round"}
strokeLinejoin={"round"}
/>
</motion.svg>
)
}
// A missing `icon` key means no icon at all; an explicit `null` marks an app entry, which gets the app tile
function SubmenuIcon({ icon }: { icon?: React.ReactNode }) {
if (icon === undefined) return null
if (icon === null) return <span className={styles.appIcon} />
return <span className={styles.icon}>{icon}</span>
}
export function IosMenu() {
const [openItem, setOpenItem] = useState<MenuItem | null>(null)
const [anchorTop, setAnchorTop] = useState(0)
const menuRef = useRef<HTMLDivElement>(null)
useOnClickOutside(menuRef as React.RefObject<HTMLDivElement>, () => setOpenItem(null))
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpenItem(null)
}
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [])
function openSubmenu(event: React.MouseEvent<HTMLButtonElement>, item: MenuItem) {
if (!menuRef.current) return
const row = event.currentTarget.getBoundingClientRect()
const wrapper = menuRef.current.getBoundingClientRect()
setAnchorTop(row.top - wrapper.top)
setOpenItem(item)
}
return (
<div className={styles.demo}>
<MotionConfig
reducedMotion={"user"}
transition={{ type: "spring", duration: 0.45, bounce: 0.15 }}
>
<div ref={menuRef} className={styles.wrapper}>
<motion.div
className={styles.menu}
initial={false}
style={{ originX: 0, originY: `${anchorTop + ROW_HEIGHT / 2}px` }}
animate={{
opacity: openItem ? 0.5 : 1,
scale: openItem ? 0.97 : 1,
}}
>
{GROUPS.map((group, groupIndex) => (
<div key={group[0].label} className={styles.group}>
{group.map((item) => {
const danger = !!item?.danger
return (
<button
key={item.label}
type={"button"}
data-danger={danger}
className={styles.row}
onClick={item.submenu ? (event) => openSubmenu(event, item) : undefined}
>
<span data-danger={danger} className={styles.icon}>
{item.icon}
</span>
<span className={styles.label}>{item.label}</span>
{item.submenu && <Chevron />}
</button>
)
})}
{groupIndex < GROUPS.length - 1 && <div className={styles.groupGap} />}
</div>
))}
</motion.div>
{openItem && (
<button
type={"button"}
aria-label={"Close submenu"}
className={styles.scrim}
onClick={() => setOpenItem(null)}
/>
)}
<AnimatePresence>
{openItem && (
<motion.div
key={openItem.label}
className={styles.submenu}
style={{ top: anchorTop - CARD_PADDING }}
initial={{ height: ROW_HEIGHT + CARD_PADDING * 2 }}
animate={{ height: "auto" }}
exit={{
height: ROW_HEIGHT + CARD_PADDING * 2,
opacity: 0,
transition: {
height: { type: "spring", duration: 0.4, bounce: 0 },
opacity: { duration: 0.15, delay: 0.25 },
},
}}
>
<button type={"button"} className={styles.row} onClick={() => setOpenItem(null)}>
<span className={styles.icon}>{openItem.icon}</span>
<motion.span
className={styles.label}
initial={{ fontWeight: 400 }}
animate={{ fontWeight: 500 }}
exit={{ fontWeight: 400 }}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
>
{openItem.label}
</motion.span>
<Chevron down />
</button>
<div className={styles.groupGap} />
<motion.div
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0, transition: { delay: 0.06 } }}
>
{openItem.submenu?.map((subItem) => (
<button key={subItem.label} type={"button"} className={styles.row}>
<SubmenuIcon icon={subItem.icon} />
<span className={styles.label}>{subItem.label}</span>
</button>
))}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</MotionConfig>
</div>
)
}

IosMenu.module.css

.demo {
padding-block: 68px;
background:
radial-gradient(circle at 25% 20%, oklch(0.92 0.06 280 / 0.7), transparent 45%),
radial-gradient(circle at 75% 70%, oklch(0.92 0.06 20 / 0.6), transparent 45%), #ffffff;
}
.wrapper {
position: relative;
width: 250px;
user-select: none;
}
.menu {
border-radius: 20px;
background: oklch(0.98 0 0 / 0.55);
backdrop-filter: blur(40px) saturate(180%);
overflow: hidden;
padding: 12px;
box-shadow:
inset 0 0 0 1px oklch(1 0 0 / 0.4),
0 10px 32px oklch(0 0 0 / 0.16);
}
.row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
height: 44px;
padding-inline: 16px;
border: none;
background: transparent;
color: oklch(0.15 0 0);
font: inherit;
font-size: 15px;
text-align: left;
cursor: pointer;
outline: none;
border-radius: 999px;
}
.row:active {
background: oklch(0 0 0 / 0.06);
}
/* gated, so a tap on a touch screen does not leave the row stuck in its hover state */
@media (hover: hover) and (pointer: fine) {
.row:hover {
background: oklch(0 0 0 / 0.06);
}
}
/* the rows drop the default outline for their pill shape, so keyboard focus needs its own ring */
.row:focus-visible {
outline: 2px solid currentColor;
outline-offset: -2px;
}
.icon {
display: flex;
flex-shrink: 0;
color: oklch(0.25 0 0);
}
.row[data-danger="true"],
.icon[data-danger="true"] {
color: #ff6c6c;
}
.label {
flex: 1;
}
.chevron {
width: 14px;
height: 14px;
color: oklch(0.45 0 0);
}
.groupGap {
height: 1px;
margin-inline: 14px;
margin-block: 8px;
background: oklch(0 0 0 / 0.08);
}
.scrim {
position: absolute;
inset: 0;
padding: 0;
border: none;
background: transparent;
cursor: default;
}
.submenu {
position: absolute;
padding: 12px;
left: 0;
right: 0;
overflow: hidden;
border-radius: 20px;
background: oklch(0.99 0 0 / 0.7);
backdrop-filter: blur(40px) saturate(180%);
box-shadow:
inset 0 0 0 1px oklch(1 0 0 / 0.5),
0 16px 48px oklch(0 0 0 / 0.25);
}
.appIcon {
flex-shrink: 0;
width: 24px;
height: 24px;
margin-inline: -2px;
border-radius: 6px;
background: linear-gradient(135deg, #699dea, #5b48ee);
}
@media (prefers-color-scheme: dark) {
.demo {
background:
radial-gradient(circle at 25% 20%, oklch(0.35 0.08 280 / 0.5), transparent 45%),
radial-gradient(circle at 75% 70%, oklch(0.32 0.08 20 / 0.5), transparent 45%), #000000;
}
.menu {
background: oklch(0.25 0 0 / 0.55);
box-shadow:
inset 0 0 0 1px oklch(1 0 0 / 0.1),
0 10px 32px oklch(0 0 0 / 0.5);
}
.row {
color: oklch(0.98 0 0);
}
.row:active {
background: oklch(1 0 0 / 0.08);
}
.icon {
color: oklch(0.9 0 0);
}
.chevron {
color: oklch(0.7 0 0);
}
.groupGap {
background: oklch(1 0 0 / 0.08);
}
.submenu {
background: oklch(0.29 0 0 / 0.72);
box-shadow:
inset 0 0 0 1px oklch(1 0 0 / 0.14),
0 16px 48px oklch(0 0 0 / 0.6);
}
}
@media (prefers-color-scheme: dark) and (hover: hover) and (pointer: fine) {
.row:hover {
background: oklch(1 0 0 / 0.08);
}
}

References

  1. 1.Layout animations · Motion
  2. 2.Geist · Vercel
  3. 3.backdrop-filter · CSS · MDN
  4. 4.Lightning CSS · lightningcss.dev

Would you like to see more of this?

One email each time a new experiment is published.

Next experiment

Liquid Glass

Liquid Glass rebuilt as real refractive geometry with Three.js