Experiments

Dynamic Island

The Dynamic Island and the morph between its states, rebuilt with Motion

August 1, 2026

Tap the pill

Apple's Dynamic Island playing music, rebuilt with Motion. It is the morph itself, not a screenshot of the end result. The player UI is the easy half, and the part worth building is what happens between the states.

I built it together with an AI agent. I already knew the key trick from Emil Kowalski's animations course3, which I took. To show the agent what the real thing does, I recorded the island on my phone and watched it frame by frame. Then I explained to the agent how it should work. The full code is at the end.

What went wrong first

The agent got two things wrong, and both came from how Motion's layout prop2 works.

The first was the compact view, which flattened into the top edge and sprang back out. Motion doesn't resize the pill frame by frame. It lays the pill out at the final size and scales it down to look like the old one. So every child is painted at full size and squashed to match, unless it carries layout too.

The second was the exiting copy of a view (more on that below). The agent made it a sibling of the island, on the theory that the island's overflow: hidden would crop something meant to scale past the edges. It does crop it, and that turns out to be right. Outside the clip, collapsing the player left full-height controls painting over open space the pill had already vacated. Explaining how the animation should look, one state at a time, is what fixed both.

The three states

They are named the way Apple names them in ActivityKit1: idle, compact and expanded. Tapping moves forward, and dismissing walks back one step. So Escape on expanded gets you compact rather than idle. The music is still playing, and an empty pill would lie about the system.

How the morph works

The island is one motion.div with layout, and that single prop drove most of the build.

Two copies of the active view handle the crossfade, the technique from Emil's lesson. One plays only the enter animation. The other starts invisible and shows only while it exits. Both are absolutely positioned and fixed in size, which is why the pill carries its own dimensions. With both out of flow, nothing inside can size it.

The artwork and waveform appear in both states, so they belong to neither copy. Inside a view they get painted twice during a move, and no amount of tuning makes two album sleeves crossfading past each other look like one sleeve moving. Hoisted onto the pill instead, layout walks each one between its two resting places. That leaves compact with no content at all.

The spring values

Each move between states has its own spring, and I tuned them by eye. I changed the duration and the bounce until they felt right. I can't give a better reason than that I liked them. They are in tuning.ts at the end.

If you're about to build one

The duplicated view is aria-hidden and inert. Without that, a copy that exists purely to animate out adds a second set of transport controls to the accessibility tree and five extra tab stops. MotionConfig carries reducedMotion={'user'}, so if you've asked your OS for less motion, everything lands on its end state instantly.

The code

DynamicIsland.tsx

"use client"
import {
BroadcastIcon,
FastForwardIcon,
PauseIcon,
PlayIcon,
RewindIcon,
StarIcon,
} from "@phosphor-icons/react"
import { AnimatePresence, MotionConfig, motion, type Variants } from "motion/react"
import Image from "next/image"
import { useEffect, useMemo, useRef, useState } from "react"
import { useOnClickOutside } from "usehooks-ts"
import { cn } from "@/src/utils"
import styles from "./DynamicIsland.module.css"
import { type IslandTransitionKey, useIslandTuning } from "./tuning"
const TRACK_SECONDS = 241
const START_SECONDS = 31
const BARS = [0.45, 0.8, 1, 0.6, 0.9]
const ARTWORK = "/images/artwork.jpg"
type View = "idle" | "compact" | "expanded"
/** only reached if a pair of views is ever wired up that the tuning does not name */
const DEFAULT_TRANSITION = { duration: 0.45, bounce: 0.4 }
const EXIT: Record<string, { scale: number; scaleX?: number; y: number }> = {
"idle-compact": { scale: 1.15, y: 2 },
"compact-idle": { scale: 0.85, scaleX: 0.9, y: -2 },
"compact-expanded": { scale: 1.5, y: 12 },
"expanded-compact": { scale: 0.55, scaleX: 0.9, y: -12 },
}
/**
* How far below its resting place the incoming copy starts. The pill opens downwards, so the new
* view rises into it and a collapse drops the smaller view in from above - the copy always travels
* the axis the pill is moving on, never across it.
*/
const ENTER: Record<string, number> = {
"idle-compact": 4,
"compact-idle": -4,
"compact-expanded": -8,
"expanded-compact": 8,
}
const exitVariants: Variants = {
exit: (custom) => ({ ...custom, opacity: [1, 0], filter: "blur(4px)" }),
}
function formatTime(seconds: number) {
const minutes = Math.floor(seconds / 60)
const rest = Math.floor(seconds % 60)
return `${minutes}:${String(rest).padStart(2, "0")}`
}
/**
* Sized by the pill, not by this component. The bars are plain children, so the box animation the
* container is running scales them with it - which is the morph, and it keeps every bar's own
* scaleY loop untouched by it.
*/
function Waveform({ isPlaying, isVisible }: { isPlaying: boolean; isVisible: boolean }) {
return (
<motion.div
layout
className={styles.waveform}
initial={false}
animate={{ opacity: isVisible ? 1 : 0 }}
>
{BARS.map((height, index) => (
<motion.span
key={`${height}-${index.toString()}`}
className={styles.bar}
style={{ height: `${height * 100}%` }}
animate={isPlaying ? { scaleY: [1, 0.35, 1] } : { scaleY: 0.35 }}
transition={
isPlaying
? {
duration: 0.75 + index * 0.14,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}
: { duration: 0.2 }
}
/>
))}
</motion.div>
)
}
interface ExpandedViewProps {
elapsed: number
isPlaying: boolean
onTogglePlay: () => void
}
function ExpandedView({ elapsed, isPlaying, onTogglePlay }: ExpandedViewProps) {
return (
<div className={styles.expanded}>
<div className={styles.header}>
<div className={styles.meta}>
<p className={styles.title}>{"Paper Lanterns"}</p>
<p className={styles.artist}>{"Marlow Vane"}</p>
</div>
</div>
<div className={styles.progress}>
<span className={styles.time}>{formatTime(elapsed)}</span>
<div className={styles.track}>
<div className={styles.fill} style={{ width: `${(elapsed / TRACK_SECONDS) * 100}%` }} />
</div>
<span className={styles.time}>{`-${formatTime(TRACK_SECONDS - elapsed)}`}</span>
</div>
<div className={styles.controls}>
<button
type={"button"}
aria-label={"Favourite"}
className={cn(styles.control, styles.controlDimmed)}
>
<StarIcon size={22} />
</button>
<button type={"button"} aria-label={"Previous"} className={styles.control}>
<RewindIcon size={26} weight={"fill"} />
</button>
<button
type={"button"}
aria-label={isPlaying ? "Pause" : "Play"}
className={styles.controlMain}
onClick={onTogglePlay}
>
<AnimatePresence mode={"wait"} initial={false}>
<motion.span
key={isPlaying ? "pause" : "play"}
className={styles.controlIcon}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{ duration: 0.14 }}
>
{isPlaying ? (
<PauseIcon size={30} weight={"fill"} />
) : (
<PlayIcon size={30} weight={"fill"} />
)}
</motion.span>
</AnimatePresence>
</button>
<button type={"button"} aria-label={"Next"} className={styles.control}>
<FastForwardIcon size={26} weight={"fill"} />
</button>
<button
type={"button"}
aria-label={"AirPlay"}
className={cn(styles.control, styles.controlDimmed)}
>
<BroadcastIcon size={22} />
</button>
</div>
</div>
)
}
const TAP_TARGET: Record<View, View> = {
idle: "compact",
compact: "expanded",
expanded: "compact",
}
const DISMISS_TARGET: Record<View, View> = {
idle: "idle",
compact: "idle",
expanded: "compact",
}
const TAP_LABEL: Record<View, string> = {
idle: "Start playing",
compact: "Expand the island",
expanded: "Collapse the island",
}
export function DynamicIsland() {
const transitions = useIslandTuning()
const [view, setView] = useState<View>("idle")
const [variantKey, setVariantKey] = useState("idle")
const [elapsed, setElapsed] = useState(START_SECONDS)
const [isPlaying, setIsPlaying] = useState(true)
const islandRef = useRef<HTMLDivElement>(null)
function switchTo(next: View) {
if (next === view) return
setVariantKey(`${view}-${next}`)
setView(next)
if (next === "idle") {
setElapsed(START_SECONDS)
setIsPlaying(true)
}
}
useOnClickOutside(islandRef as React.RefObject<HTMLDivElement>, () =>
switchTo(DISMISS_TARGET[view]),
)
useEffect(() => {
if (view === "idle" || !isPlaying) return
const id = setInterval(() => setElapsed((prev) => (prev + 1) % TRACK_SECONDS), 1000)
return () => clearInterval(id)
}, [view, isPlaying])
useEffect(() => {
if (view === "idle") return
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return
const next = DISMISS_TARGET[view]
setVariantKey(`${view}-${next}`)
setView(next)
if (next === "idle") {
setElapsed(START_SECONDS)
setIsPlaying(true)
}
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [view])
/**
* Idle and compact are made of nothing but the two pieces the pill paints for itself, so the
* expanded view is the only one left with a copy to cross-fade.
*/
const content = useMemo(
() =>
view === "expanded" ? (
<ExpandedView
elapsed={elapsed}
isPlaying={isPlaying}
onTogglePlay={() => setIsPlaying((prev) => !prev)}
/>
) : null,
[view, elapsed, isPlaying],
)
return (
<div className={styles.demo}>
<MotionConfig
reducedMotion={"user"}
transition={{
type: "spring",
...(transitions[variantKey as IslandTransitionKey] ?? DEFAULT_TRANSITION),
}}
>
<div className={styles.stage}>
<motion.div
ref={islandRef}
layout
data-view={view}
className={styles.island}
style={{ borderRadius: 32 }}
>
<button
type={"button"}
aria-label={TAP_LABEL[view]}
className={styles.surface}
onClick={() => switchTo(TAP_TARGET[view])}
/>
{/*
* Hoisted out of both copies so only ever one of each exists. Inside a view they
* would be painted twice during a move - once by the copy arriving and once by
* the copy leaving - and the two would cross-fade past each other instead of
* travelling between the places the two views put them.
*/}
<motion.div
layout
className={styles.artwork}
initial={false}
animate={{
borderRadius: view === "expanded" ? 12 : 7,
opacity: view === "idle" ? 0 : 1,
}}
>
<Image src={ARTWORK} alt={""} fill sizes={"56px"} />
</motion.div>
<Waveform isPlaying={isPlaying} isVisible={view !== "idle"} />
{/*
* `layout` sits on the wrapper and the key sits on the copy inside it, the same
* split the ghost uses. A projection node that remounts has no box to be
* corrected from, so Motion drops it into the pill's projected frame and it
* slides in off the pill's left edge; a stable wrapper only ever takes the
* scale correction.
*/}
<motion.div layout className={styles.layer}>
<motion.div
key={view}
initial={{ y: ENTER[variantKey] ?? 0, scale: 0.9, opacity: 0, filter: "blur(4px)" }}
animate={{
y: 0,
scale: 1,
opacity: 1,
filter: "blur(0px)",
transition: { delay: 0.05 },
}}
>
{content}
</motion.div>
</motion.div>
{/*
* The outgoing copy lives inside the pill, not beside it. As a sibling it sat
* outside the island's clip, so on a collapse it kept painting the expanded
* controls at full height while the pill had already shrunk under them.
*
* `layout` here and on the incoming layer is not for animating either box - both
* keep a fixed size. It makes them projection nodes, which is what earns them the
* scale correction that cancels the island's own layout scale. Without it the pill
* paints at its target size and squashes its children to fake the starting size,
* so a compact view flattens into the top edge before it morphs.
*/}
<motion.div layout className={styles.ghost} aria-hidden={true} inert>
<AnimatePresence mode={"popLayout"} custom={EXIT[variantKey]}>
<motion.div
key={view}
initial={{ opacity: 0 }}
exit={"exit"}
variants={exitVariants}
>
{content}
</motion.div>
</AnimatePresence>
</motion.div>
</motion.div>
</div>
</MotionConfig>
</div>
)
}

DynamicIsland.module.css

.demo {
background:
radial-gradient(circle at 20% 15%, oklch(0.72 0.221 340 / 0.92), oklch(0.72 0.221 340 / 0) 60%),
radial-gradient(circle at 80% 25%, oklch(0.72 0.114 205 / 0.88), oklch(0.72 0.114 205 / 0) 60%),
radial-gradient(circle at 50% 92%, oklch(0.72 0.14 285 / 0.88), oklch(0.72 0.14 285 / 0) 62%),
oklch(0.9 0.014 300);
}
@media (color-gamut: p3) {
.demo {
background:
radial-gradient(
circle at 20% 15%,
oklch(0.72 0.289 340 / 0.92),
oklch(0.72 0.289 340 / 0) 60%
),
radial-gradient(
circle at 80% 25%,
oklch(0.72 0.152 205 / 0.88),
oklch(0.72 0.152 205 / 0) 60%
),
radial-gradient(
circle at 50% 92%,
oklch(0.72 0.152 285 / 0.88),
oklch(0.72 0.152 285 / 0) 62%
),
oklch(0.9 0.014 300);
}
}
.stage {
position: relative;
display: flex;
width: 100%;
min-height: 236px;
flex-direction: column;
align-items: center;
}
.island {
position: relative;
overflow: hidden;
background: #000000;
outline: none;
}
/*
* The pill carries its own box rather than taking it from whichever view is inside. Both copies
* are absolutely centred - that is what keeps them off the pill's moving left edge - so neither
* is in the flow to size it. Expanded height follows .expanded: 18 + 56 + 14 + 18 + 14 + 44 + 12.
*/
.island[data-view="idle"] {
width: 126px;
height: 34px;
}
.island[data-view="compact"] {
width: 176px;
height: 36px;
}
.island[data-view="expanded"] {
width: 320px;
height: 176px;
}
.surface {
position: absolute;
inset: 0;
z-index: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
outline: none;
}
.surface:focus-visible {
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.15);
}
.layer,
.ghost {
position: absolute;
top: 0;
left: 50%;
display: flex;
width: 360px;
height: 220px;
margin-left: -180px;
align-items: flex-start;
justify-content: center;
pointer-events: none;
outline: none;
}
.layer {
z-index: 1;
}
.expanded {
display: flex;
width: 320px;
flex-direction: column;
gap: 14px;
padding: 18px 20px 12px 20px;
}
/*
* The artwork and the waveform are painted by the pill, so this row holds their shape instead:
* 56px of height from the artwork, and an inset on each side of that piece's width plus the 18px
* gap that used to sit beside it - 56 + 18 on the left, 27 + 18 on the right.
*/
.header {
display: flex;
min-height: 56px;
align-items: center;
padding-right: 45px;
padding-left: 74px;
}
/*
* One artwork for the whole component. It belongs to the pill rather than to either copy of a
* view, so a move animates it from one resting place to the other instead of cross-fading a
* second copy over the first.
*/
.artwork {
position: absolute;
z-index: 2;
overflow: hidden;
pointer-events: none;
}
/* the sleeve fills whichever of the two boxes the pill is holding it at */
.artwork img {
object-fit: cover;
}
/* idle has no artwork of its own, so it rests where the compact one sits and fades there */
.island[data-view="idle"] .artwork,
.island[data-view="compact"] .artwork {
top: 6px;
left: 10px;
width: 24px;
height: 24px;
}
.island[data-view="expanded"] .artwork {
top: 18px;
left: 20px;
width: 56px;
height: 56px;
}
.meta {
flex: 1;
min-width: 0;
}
.title {
margin: 0;
overflow: hidden;
color: oklch(1 0 0);
font-size: 17px;
font-weight: 600;
letter-spacing: -0.01em;
white-space: nowrap;
text-overflow: ellipsis;
}
.artist {
margin: 0;
overflow: hidden;
color: oklch(0.72 0 0);
font-size: 15px;
white-space: nowrap;
text-overflow: ellipsis;
}
/* the pill's other travelling piece; each box is its bars plus its gaps, so nothing has to stretch */
.waveform {
position: absolute;
z-index: 2;
display: flex;
align-items: center;
pointer-events: none;
}
/* both insets are the padding the view beside them used, so the box holds its place as it grows */
.island[data-view="idle"] .waveform,
.island[data-view="compact"] .waveform {
top: 10.5px;
right: 10px;
width: 20.5px;
height: 15px;
gap: 2px;
}
.island[data-view="expanded"] .waveform {
top: 34px;
right: 20px;
width: 27px;
height: 24px;
gap: 3px;
}
.bar {
display: block;
width: 3px;
border-radius: 999px;
background: linear-gradient(180deg, oklch(0.84 0.13 195), oklch(0.62 0.096 195));
opacity: 0.7;
}
.island[data-view="idle"] .bar,
.island[data-view="compact"] .bar {
width: 2.5px;
}
.waveformCompact .bar {
width: 2.5px;
}
.progress {
display: flex;
align-items: center;
gap: 10px;
}
.time {
flex-shrink: 0;
color: oklch(0.68 0 0);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.track {
position: relative;
flex: 1;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: oklch(1 0 0 / 0.18);
}
.fill {
height: 100%;
border-radius: 999px;
background: oklch(0.93 0 0);
}
.controls {
display: flex;
align-items: center;
justify-content: space-between;
padding-inline: 2px;
}
.control,
.controlMain {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border: none;
border-radius: 999px;
background: transparent;
color: oklch(0.9 0 0);
cursor: pointer;
outline: none;
pointer-events: auto;
}
.controlDimmed {
color: oklch(0.68 0 0);
}
.control:focus-visible,
.controlMain:focus-visible {
box-shadow: 0 0 0 1px oklch(1 0 0 / 0.45);
}
.controlMain {
width: 44px;
height: 44px;
color: oklch(1 0 0);
}
.controlIcon {
display: flex;
}
@media (prefers-color-scheme: dark) {
.demo {
background:
radial-gradient(
circle at 20% 15%,
oklch(0.48 0.194 340 / 0.92),
oklch(0.48 0.194 340 / 0) 60%
),
radial-gradient(
circle at 80% 25%,
oklch(0.48 0.077 205 / 0.88),
oklch(0.48 0.077 205 / 0) 60%
),
radial-gradient(circle at 50% 92%, oklch(0.48 0.26 285 / 0.88), oklch(0.48 0.26 285 / 0) 62%),
oklch(0.2 0.03 300);
}
}
@media (prefers-color-scheme: dark) and (color-gamut: p3) {
.demo {
background:
radial-gradient(
circle at 20% 15%,
oklch(0.48 0.216 340 / 0.92),
oklch(0.48 0.216 340 / 0) 60%
),
radial-gradient(
circle at 80% 25%,
oklch(0.48 0.102 205 / 0.88),
oklch(0.48 0.102 205 / 0) 60%
),
radial-gradient(
circle at 50% 92%,
oklch(0.48 0.265 285 / 0.88),
oklch(0.48 0.265 285 / 0) 62%
),
oklch(0.2 0.03 300);
}
}

tuning.ts

"use client"
import { createContext, useContext } from "react"
/**
* A Motion spring in its duration/bounce form, which is what the island has always used. Not
* DialKit's `visualDuration`, which is a different parameter - seeding one from the other would
* make the bench behave unlike the published component.
*/
export interface IslandSpring {
duration: number
bounce: number
}
/**
* Keyed by the move being made, `${from}-${to}`, so each direction can carry its own weight. Both
* ways out of expanded lead to compact, so there is no expanded-idle move to name.
*/
export type IslandTransitionKey =
| "idle-compact"
| "compact-idle"
| "compact-expanded"
| "expanded-compact"
export const ISLAND_TUNING: Record<IslandTransitionKey, IslandSpring> = {
"idle-compact": { duration: 0.4, bounce: 0.45 },
"compact-idle": { duration: 0.32, bounce: 0.4 },
"compact-expanded": { duration: 0.5, bounce: 0.4 },
"expanded-compact": { duration: 0.38, bounce: 0.3 },
}
/**
* Nothing in the site supplies this, so the island runs on the values above wherever it is
* published. The lab is the only caller that overrides them.
*/
const IslandTuningContext = createContext(ISLAND_TUNING)
export const IslandTuningProvider = IslandTuningContext.Provider
export function useIslandTuning() {
return useContext(IslandTuningContext)
}

References

  1. 1.ActivityKit · Apple Developer
  2. 2.Layout animations · Motion
  3. 3.Animations on the web · Emil Kowalski

Would you like to see more of this?

One email each time a new experiment is published.

Next experiment

iOS Menu

Recreating the iOS context menu submenu expansion with Motion