Experiments

Feature Tour

A pill that springs open into a card while the rest of the list makes room

November 3, 2026

Squircle

Pick an item, or use the arrows

The feature list from Apple's iPhone pages: a column of pills with a +, where picking one grows it into a card while the open one shrinks back to a pill, and the picture beside it swaps. It looks like an accordion, but nothing gets pushed down the page to make room. The pill becomes the card in place, and the list just re-centres around it. In the Human Interface Guidelines it is progressive disclosure1. Structurally it is a single-open accordion2: one button per item, aria-expanded on each, and the up and down arrows moving the open item.

I built it together with an AI agent. Apple invented this kind of selector, so I worked with the agent and videos of the original to find out how it works. The full code is at the end.

What went wrong first

The first version used Motion's layout3 for the morph, and it looked wrong. I spotted it and explained to the agent exactly what was happening. layout animates size with a scale transform, so everything inside gets scaled too. On the first frame the closing card's paragraph jumped from 292x96 to 770x264. The text grew before the card shrank, and spilled past the shape while it faded.

The original cards genuinely change size and clip what is inside, so that is what this does now. The button animates its real width and height, and the copy sits absolutely positioned at its natural size underneath, never scaled. The target size is the current copy measured with a ResizeObserver, which also keeps a pill hugging its label if the font loads late.

The choreography

It is three beats. The copy leaves first, fast enough (0.15s) that the shape reads as empty while it moves. The empty shape springs to its new size. Then the new copy fades in 0.22s after the click, once the shape has mostly landed.

The spring

In the videos, the card is halfway at about 100ms, 90% at 200ms, a small overshoot at 330ms, and settled by 550ms. The closest Motion spring to those numbers is { duration: 0.65, bounce: 0.25 }. Side by side with the original it still looked a little calm, so I tweaked the values and it ships at 0.3. The opening card overshoots and settles, and the closing one dips below pill height before coming back. None of the other pills animate on their own. They ride along because the list re-centres around those two.

The icons

The icons are drawn rather than pulled from an icon set. The original + is a hairline ring around a heavier cross, and no icon-set weight draws the two strokes differently. The arrows needed a heavier, flatter chevron than a bold caret for the same reason.

If you're about to build one

Each item stays the same <button> whether it is a pill or a card, so pressing Enter on one keeps focus exactly where it was instead of dropping it when the markup changes. The up and down arrow keys carry focus along with the item they open, so a screen reader lands on the card that just appeared rather than on the pill you left. If you prefer reduced motion, the sizes jump straight to their end state and nothing fades, including the picture.

I would use this on a marketing site, and not in a web app.

The code

FeatureTour.tsx

"use client"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { getImageProps } from "next/image"
import type React from "react"
import { useEffect, useLayoutEffect, useRef, useState } from "react"
import { cn } from "@/src/utils"
import { useFeatureTourTuning } from "./tuning"
const EASE = [0.32, 0.72, 0, 1] as const
const CARD_WIDTH = 340
const PILL_HEIGHT = 48
// the site's own experiments, so the tour carries real copy with real lengths
const ITEMS = [
{
slug: "squircle",
title: "Squircle",
body: "The rounded rectangle iOS uses everywhere, which is not the one border-radius gives you. One SVG filter, no geometry.",
},
{
slug: "liquid-glass",
title: "Liquid Glass",
body: "Real refraction through real geometry in Three.js, not a backdrop blur with a displacement map faking the bend.",
},
{
slug: "dynamic-island",
title: "Dynamic Island",
body: "The morph between idle, compact and expanded, rebuilt with one layout-animated pill and two copies of the view.",
},
{
slug: "ios-menu",
title: "iOS Menu",
body: "The submenu that opens in place when you tap Open With in Files. No popover library, no second panel sliding in.",
},
{
slug: "fluid-input",
title: "Fluid Input",
body: "A search field that widens into a full input on a single spring. No CSS transitions, no timers, no measuring the DOM by hand.",
},
{
slug: "letter-cascade",
title: "Letter Cascade",
body: "A per-letter reveal built entirely in CSS, with one custom property staggering every letter's delay.",
},
]
type Item = (typeof ITEMS)[number]
export function FeatureTour() {
const [index, setIndex] = useState(0)
function select(next: number) {
if (next < 0 || next >= ITEMS.length) return
setIndex(next)
}
return (
<>
<div
className={"flex w-full flex-col-reverse items-center gap-8 md:flex-row md:gap-10"}
>
<div className={"flex w-full items-center gap-4 md:w-[400px] md:shrink-0"}>
<Arrows index={index} onSelect={select} />
<List index={index} onSelect={select} />
</div>
<Media item={ITEMS[index]} />
</div>
</>
)
}
interface NavigationProps {
index: number
onSelect: (index: number) => void
}
function Arrows({ index, onSelect }: NavigationProps) {
const buttons = [
{ label: "Previous", path: "M5 15l7-7 7 7", target: index - 1 },
{ label: "Next", path: "M5 9l7 7 7-7", target: index + 1 },
]
return (
<div className={"flex flex-col gap-3"}>
{buttons.map(({ label, path, target }) => (
// aria-disabled rather than disabled: a disabled button drops focus to the page the moment
// pressing it reaches the end of the list
<button
key={label}
type={"button"}
aria-label={label}
aria-disabled={target < 0 || target >= ITEMS.length}
onClick={() => onSelect(target)}
className={
"relative grid size-9 cursor-pointer place-items-center rounded-full bg-neutral-200/80 text-neutral-900 transition-[opacity,scale] duration-150 ease-out before:absolute before:-inset-1 before:content-[''] focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2 not-aria-disabled:active:scale-[0.96] aria-disabled:cursor-default aria-disabled:opacity-35 motion-reduce:transition-none dark:bg-neutral-800/80 dark:text-white"
}
>
{/* drawn rather than an icon-set caret: Apple's chevron is heavier and flatter than
Phosphor's bold weight */}
<svg
aria-hidden={"true"}
width={18}
height={18}
viewBox={"0 0 24 24"}
fill={"none"}
stroke={"currentColor"}
strokeWidth={3.5}
strokeLinecap={"round"}
strokeLinejoin={"round"}
>
<path d={path} />
</svg>
</button>
))}
</div>
)
}
function List({ index, onSelect }: NavigationProps) {
const [list, setList] = useState<HTMLUListElement | null>(null)
const [listWidth, setListWidth] = useState(CARD_WIDTH)
// the card is as wide as Apple's, or as the list when a phone gives it less
useLayoutEffect(() => {
if (!list) return
const observer = new ResizeObserver(() => setListWidth(list.offsetWidth))
observer.observe(list)
return () => observer.disconnect()
}, [list])
// focus follows the open item, so the card that just opened is the one a screen reader reads
function onKeyDown(event: React.KeyboardEvent<HTMLUListElement>) {
const step = { ArrowDown: 1, ArrowUp: -1 }[event.key]
if (!step) return
event.preventDefault()
// step from the item that has focus, which is not the open one after a Tab
const buttons = [
...event.currentTarget.querySelectorAll<HTMLButtonElement>("button[aria-expanded]"),
]
const focused = buttons.indexOf(event.target as HTMLButtonElement)
const next = (focused === -1 ? index : focused) + step
if (next < 0 || next >= ITEMS.length) return
onSelect(next)
buttons[next].focus()
}
return (
<ul
ref={setList}
className={"flex min-w-0 flex-1 flex-col items-start gap-2"}
onKeyDown={onKeyDown}
>
{ITEMS.map((item, itemIndex) => (
<li key={item.slug} className={"max-w-full"}>
<TourItem
item={item}
open={itemIndex === index}
cardWidth={Math.min(CARD_WIDTH, listWidth)}
onSelect={() => onSelect(itemIndex)}
/>
</li>
))}
</ul>
)
}
interface TourItemProps {
item: Item
open: boolean
cardWidth: number
onSelect: () => void
}
/**
* The item animates its real width and height and clips whatever is inside, the way Apple's does,
* so the copy never scales. Motion's `layout` animates with a scale transform instead, which
* stretched the closing card's paragraph to 2.6x on its first frame: the text visibly grew before
* the card shrank, and spilled past the shape while it faded.
*/
function TourItem({ item, open, cardWidth, onSelect }: TourItemProps) {
const { morph, fadeOut, fadeIn } = useFeatureTourTuning()
const reduced = useReducedMotion()
const [content, setContent] = useState<HTMLElement | null>(null)
const [size, setSize] = useState<{ width: number; height: number } | null>(null)
// the current state's copy is measured at its natural size, and the button springs to it
useLayoutEffect(() => {
if (!content) return
const measure = () => setSize({ width: content.offsetWidth, height: content.offsetHeight })
measure()
const observer = new ResizeObserver(measure)
observer.observe(content)
return () => observer.disconnect()
}, [content])
// the first size is applied, not animated, or every item would grow out of nothing on mount
const settled = useRef(false)
useEffect(() => {
if (size) settled.current = true
}, [size])
const resize =
reduced || !settled.current ? { duration: 0 } : { type: "spring" as const, ...morph }
const enter = reduced ? { duration: 0 } : { ...fadeIn, ease: EASE }
const leave = reduced ? { duration: 0 } : { duration: fadeOut, ease: "linear" as const }
// the exiting copy keeps the ref it was rendered with, and unmounting it would clear the node
const keep = (element: HTMLElement | null) => {
if (element) setContent(element)
}
return (
// one button in both states, so focus stays put when the pill becomes the card
<motion.button
type={"button"}
aria-expanded={open}
// the open card cannot be collapsed, which is what the accordion pattern marks this way
aria-disabled={open}
onClick={onSelect}
initial={false}
animate={size ?? { height: PILL_HEIGHT }}
transition={resize}
className={cn(
"relative block max-w-full cursor-pointer overflow-hidden rounded-[24px] bg-neutral-100 text-left text-neutral-900 outline-none focus-visible:outline-2 focus-visible:outline-current focus-visible:outline-offset-2 dark:bg-neutral-900 dark:text-white",
open
? "cursor-default"
: "transition-colors duration-150 ease-out hover:bg-neutral-200 motion-reduce:transition-none dark:hover:bg-neutral-800",
)}
>
<AnimatePresence initial={false}>
{open ? (
<motion.p
key={"card"}
ref={keep}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: enter }}
exit={{ opacity: 0, transition: leave }}
style={{ width: cardWidth }}
className={"absolute top-0 left-0 px-6 py-5 text-[15px] leading-6"}
>
<strong className={"font-semibold"}>{`${item.title}.`}</strong> {item.body}
</motion.p>
) : (
<motion.span
key={"pill"}
ref={keep}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: enter }}
exit={{ opacity: 0, transition: leave }}
className={
"absolute top-0 left-0 flex h-12 items-center gap-2.5 whitespace-nowrap pr-5 pl-3 font-medium text-[15px]"
}
>
<PlusMark />
{item.title}
</motion.span>
)}
</AnimatePresence>
</motion.button>
)
}
/** drawn to Apple's proportions: a hairline ring around a heavier plus, which no icon-set weight has */
function PlusMark() {
return (
<svg
aria-hidden={"true"}
width={26}
height={26}
viewBox={"0 0 24 24"}
fill={"none"}
stroke={"currentColor"}
strokeLinecap={"round"}
className={"shrink-0"}
>
<circle cx={12} cy={12} r={11} strokeWidth={0.9} />
<path d={"M12 7.2v9.6M7.2 12h9.6"} strokeWidth={1.5} />
</svg>
)
}
/**
* The stills are transparent and come in a light and a dark version, so each one sits straight on
* the demo's own surface in either theme. `getImageProps` with a `<picture>` is how Next switches
* the source by colour scheme while still serving it optimised. The old still leaves before the
* new one arrives, so the swap dips through the surface the way Apple's dips through black.
*/
function Media({ item }: { item: Item }) {
const { media } = useFeatureTourTuning()
const reduced = useReducedMotion()
const common = { alt: item.title, fill: true, sizes: "(min-width: 768px) 420px, 100vw" }
const {
props: { srcSet: dark },
} = getImageProps({ ...common, src: `/experiments/feature-tour/media/${item.slug}-dark.png` })
const { props: light } = getImageProps({
...common,
src: `/experiments/feature-tour/media/${item.slug}-light.png`,
loading: item.slug === ITEMS[0].slug ? "eager" : "lazy",
})
return (
<div className={"relative aspect-[4/3] w-full md:aspect-auto md:h-[420px] md:flex-1"}>
<AnimatePresence initial={false} mode={"wait"}>
<motion.picture
key={item.slug}
initial={reduced ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={reduced ? { duration: 0 } : { duration: media, ease: EASE }}
>
<source media={"(prefers-color-scheme: dark)"} srcSet={dark} />
<img {...light} alt={item.title} draggable={false} className={"object-contain"} />
</motion.picture>
</AnimatePresence>
</div>
)
}

tuning.ts

"use client"
import { createContext, useContext } from "react"
/**
* The numbers the tour runs on, keyed by the move. The published component reads these defaults;
* `/lab` is the only caller that replaces them, with DialKit.
*
* The morph spring was tuned against videos of the original, where the card is halfway at ~100ms,
* 90% at ~200ms, a small overshoot at ~330ms, settled by ~550ms.
*/
export interface FeatureTourTuning {
/** the item growing into the card, and the card shrinking back to a pill */
morph: { duration: number; bounce: number }
/** the copy leaving: quick enough that the shape reads as empty while it moves */
fadeOut: number
/** the copy arriving, once the shape has mostly landed */
fadeIn: { delay: number; duration: number }
/** each half of the media's dip through the surface */
media: number
}
export const FEATURE_TOUR_TUNING: FeatureTourTuning = {
morph: { duration: 0.65, bounce: 0.3 },
fadeOut: 0.15,
fadeIn: { delay: 0.22, duration: 0.2 },
media: 0.4,
}
const FeatureTourTuningContext = createContext(FEATURE_TOUR_TUNING)
export const FeatureTourTuningProvider = FeatureTourTuningContext.Provider
export function useFeatureTourTuning() {
return useContext(FeatureTourTuningContext)
}

References

  1. 1.Disclosure controls · Human Interface Guidelines · Apple
  2. 2.Accordion pattern · ARIA Authoring Practices Guide · W3C
  3. 3.Layout animations · Motion

Would you like to see more of this?

One email each time a new experiment is published.

Next experiment

Checkbox

A checkbox whose whole container is the hit area, in three layouts from one compound component