A Dynamic Island is easy to get right in a screenshot and hard to get right in motion. Done the obvious way, the pill stretches, the text inside squashes, and new content pops in before the shell has room for it.
It takes five small details to make it feel like the one on an iPhone. Here they are, with the code. I build beUI, an open source library of animated React components, and this is how its Dynamic Island works.
Live demo: beui.dev/components/blocks/dynamic-island
1. Resize the shell, don't scale it
The obvious move is scale. It's cheap and runs on the GPU. But scaling the black pill also scales everything inside it, so text and icons stretch while the shell grows.
So the shell animates its real width and height instead. Animating layout properties is usually something to avoid, but here it's one small element, and it's the only way to keep the content crisp.
The catch: you can't animate to width: auto. You need real numbers, which brings us to the next part.
2. Measure before the first paint
Put the content in a wrapper with w-max so it takes its natural size, measure it, and spring the shell to those numbers.
My first version used only a ResizeObserver. It worked until I clicked fast. A ResizeObserver reports after the browser paints, so a quick first click could beat it. The shell had no size yet, and the view snapped open with no animation.
The fix is to measure once in useLayoutEffect (which runs before paint), then let the observer handle changes after that:
function useContentSize() { const ref = useRef<HTMLDivElement>(null); const [size, setSize] = useState<{ width: number; height: number } | null>(null); // Runs before paint, so the shell has real numbers before the first click. useLayoutEffect(() => { const el = ref.current; if (el) setSize({ width: el.offsetWidth, height: el.offsetHeight }); }, []); // Keeps up as views change. useEffect(() => { const el = ref.current; if (!el) return; const observer = new ResizeObserver(() => setSize({ width: el.offsetWidth, height: el.offsetHeight }), ); observer.observe(el); return () => observer.disconnect(); }, []); return [ref, size] as const; } 3. A border radius you never animate
I expected to animate the radius as the pill turns into a card. You don't have to.
Set border-radius: 32px and leave it. Browsers clamp a radius to half the element's height. At the pill's 37px height, the 32px radius gets clamped and the ends read as a full pill. When the shell grows taller, the full 32px fits and it reads as a rounded card. The shape change comes free with the resize, and nothing can glitch at the corners.
4. Exits are fast and get pulled back in
Each view sits inside AnimatePresence with mode="popLayout". That mode takes the leaving view out of the layout right away, so the measured size is only the new view's, and the shell starts resizing at once.
The leaving view should look like it gets sucked back into the pill:
- Exit in 80ms, with no blur, so it's gone before the shrinking shell clips it.
- Enter with a spring from
scale: 0.9,y: -8and a 5px blur. -
transform-origin: top center, so content unfurls down out of the pill, like under a notch.
I use one spring for the shell and a slightly bouncier one for the content, so the content has a bit more life than the container:
const SHELL = { type: "spring", duration: 0.8, bounce: 0.2 } as const; const CONTENT = { type: "spring", duration: 0.8, bounce: 0.35 } as const; 5. Reduced motion keeps the fade, drops the movement
Some people turn on "reduce motion" because movement makes them feel sick. The CSS media query can't stop a JavaScript spring, so you need useReducedMotion() from Motion.
With it on, the shell snaps to size, and views only fade. No scale, no slide, no blur. It still shows what changed, it just doesn't move.
Putting it together
Here's a small version with all five details. It uses useContentSize, SHELL and CONTENT from above:
"use client"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type { ReactNode } from "react"; export function Island({ view, children }: { view: string; children: ReactNode }) { const reduce = useReducedMotion(); const [ref, size] = useContentSize(); return ( <motion.div role="status" aria-live="polite" initial={false} // 126 x 37 is the iPhone pill, used until the first measure. animate={size ?? { width: 126, height: 37 }} transition={reduce ? { duration: 0 } : SHELL} style={{ borderRadius: 32 }} className="inline-flex items-start justify-center overflow-hidden bg-black text-white" > <div ref={ref} className="w-max"> <AnimatePresence mode="popLayout" initial={false}> <motion.div key={view} initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, y: -8, filter: "blur(5px)" }} animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }} exit={ reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.9, y: -6, transition: { duration: 0.08 } } } transition={reduce ? { duration: 0.15 } : CONTENT} style={{ transformOrigin: "top center" }} > {children} </motion.div> </AnimatePresence> </div> </motion.div> ); } Pass a different view key and different children, and the island morphs between them. The role="status" and aria-live mean screen readers announce the new content, like a call or a timer.
Or install it
If you use shadcn/ui, the full component is one command:
npx shadcn@latest add @beui/dynamic-island <DynamicIsland view={view} compact={<span>9:41</span>}> <DynamicIslandView id="call">Incoming call</DynamicIslandView> <DynamicIslandView id="music">Midnight City</DynamicIslandView> </DynamicIsland> Set view to a view's id to open it, or null to go back to the compact pill. The source lands in your project, so you can change anything.
- Demo and docs: beui.dev/components/blocks/dynamic-island
- Source: github.com/starc007/ui-components
If you've built one of these and found a better trick for the exit, I'd like to hear it in the comments.
