Guide

How to build an iPhone Dynamic Island in React

The Dynamic Island feels alive because three things happen together: the black pill springs to the exact size of its new content, the old content gets pulled back into the pill, and the new content unfurls out of it. This guide builds each piece with Framer Motion, then shows the beUI DynamicIsland block that does the same with a small, controlled API.

Measure the content, then spring the shell to it

The pill cannot animate to width auto, so you measure the content and animate to real numbers. Wrap the content in an element sized with w-max so it takes its natural width, read offsetWidth and offsetHeight in a layout effect before the first paint, and keep reading with a ResizeObserver as views change.

Animate the shell's width and height rather than scaling it. A scale transform would squash the text and icons inside, while a real resize keeps them crisp. It is a layout animation, so keep it on this one small element. A constant border radius of 32px does the shape change for free: browsers clamp the radius to half the height, so the shell reads as a pill when compact and a rounded rectangle when expanded.

Cross-fade views with AnimatePresence popLayout

Key each view and render it inside AnimatePresence with mode="popLayout". That mode pops the exiting view out of the layout immediately, so the measured size reflects only the incoming view and the shell starts resizing right away. Make the exit short and blur-free so it disappears before the shrinking shell clips it, and anchor the transform origin to the top so content unfurls downward like it does under a notch.

TSXisland.tsx
"use client";

import { AnimatePresence, motion } from "motion/react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";

function useMeasure() {
  const ref = useRef<HTMLDivElement>(null);
  const [size, setSize] = useState<{ width: number; height: number } | null>(null);

  // Measure before paint, then keep measuring as the content changes.
  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    const update = () => setSize({ width: el.offsetWidth, height: el.offsetHeight });
    update();
    const observer = new ResizeObserver(update);
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  return [ref, size] as const;
}

export function Island({ viewKey, children }: { viewKey: string; children: ReactNode }) {
  const [ref, size] = useMeasure();

  return (
    <motion.div
      initial={false}
      animate={size ?? { width: 126, height: 37 }}
      transition={{ type: "spring", duration: 0.7, bounce: 0.2 }}
      style={{ borderRadius: 32 }}
      className="overflow-hidden bg-black text-white"
    >
      {/* w-max keeps this wrapper at the natural size of the current view. */}
      <div ref={ref} className="w-max">
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.div
            key={viewKey}
            initial={{ opacity: 0, scale: 0.9, filter: "blur(4px)" }}
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.08 } }}
            style={{ transformOrigin: "top center" }}
          >
            {children}
          </motion.div>
        </AnimatePresence>
      </div>
    </motion.div>
  );
}

Install the beUI Dynamic Island block

beUI ships this pattern as a block you install as source with the shadcn CLI. It lands at components/motion/dynamic-island.tsx, and Framer Motion is added as a dependency if your project does not have it yet.

ShellTerminal
npx shadcn@latest add @beui/dynamic-island

Using DynamicIsland and DynamicIslandView

DynamicIsland is controlled by a single view prop. Pass null to show the compact pill with whatever you put in compact, or pass a view id to expand into the DynamicIslandView with that id. Views are plain children, so a call, a timer and a now-playing card can all live side by side and only the active one renders.

Put the island in a top-aligned container with room below it, so expansion reads as unfurling downward rather than pushing content around.

TSXcall-island.tsx
"use client";

import { Phone, PhoneOff } from "lucide-react";
import { useState } from "react";
import { DynamicIsland, DynamicIslandView } from "@/components/motion/dynamic-island";

type View = "call" | "music" | null;

export function CallIsland() {
  const [view, setView] = useState<View>(null);

  return (
    <div className="flex h-32 items-start justify-center pt-2">
      <DynamicIsland
        view={view}
        compact={<span>9:41</span>}
      >
        <DynamicIslandView id="call" className="gap-4">
          <span className="text-sm font-semibold">Incoming call</span>
          <button type="button" aria-label="Decline" onClick={() => setView(null)}>
            <PhoneOff className="size-3.5" />
          </button>
          <button type="button" aria-label="Accept" onClick={() => setView(null)}>
            <Phone className="size-3.5" />
          </button>
        </DynamicIslandView>
        <DynamicIslandView id="music" className="gap-3">
          <span className="text-xs font-semibold">Now playing</span>
        </DynamicIslandView>
      </DynamicIsland>
    </div>
  );
}

Accessibility and reduced motion for a Dynamic Island

The beUI shell renders with role status and aria-live polite, so screen readers announce new content when the view changes without interrupting the user. Keep view content short so announcements stay useful, and give icon-only buttons an aria-label, as the Decline and Accept buttons do above.

With reduced motion enabled, the shell resize runs with zero duration and the views cross-fade with opacity only, dropping the scale, slide and blur. If you build your own, read useReducedMotion and swap the transitions the same way, because the CSS prefers-reduced-motion media query cannot stop a JavaScript spring.

Components in this guide

FAQ

Why not animate scale for the Dynamic Island resize?
Scaling the shell would also scale everything inside it, so text and icons would stretch mid-animation. Animating real width and height on one small element keeps the content sharp.
How do I animate to height auto in Framer Motion?
Measure the content with a ref and ResizeObserver and animate to the measured pixel values. Wrapping the content in a w-max element lets you read its natural width as well as its height.
What does AnimatePresence mode popLayout do?
It removes the exiting element from the layout flow as soon as it starts leaving, so siblings and measured containers respond to the new content immediately instead of waiting for the exit to finish.
Can the beUI Dynamic Island hold buttons?
Yes. DynamicIslandView children are ordinary React nodes, so you can render buttons, timers or media controls. The preview on beUI uses a NumberTicker for a countdown and buttons for call actions.
Is there a React Native version?
beUI Native publishes React Native components separately at /react-native. Check that list for the components currently available there.

Updated