Dynamic Island π΄
Apple's Dynamic Island is one of those rare design decisions that turns a hardware constraint into a defining feature. The notch became a canvas. A static cutout became a living, breathing element that responds to context.
What makes it feel different from typical UI? It's not just the animation β it's the physics. The way elements flow and settle. The seamless transitions between states. The sense that everything belongs to a single, unified system.
I wanted to recreate this on the web. Not a pixel-perfect clone, but an exploration of the principles that make it work β spring physics, adaptive sizing, and contextual content.
Click the buttons above to see the island morph between different sizes. Each transition uses the same spring physics Apple employs on iOS.
Why this matters
Good artists copy; great artists steal.
I first saw Lee Robinson's leerob.io β the site that displays what he's currently playing on Spotify. Simple idea. Powerful execution. I wanted to do something similar, but with a twist.
Then Apple released the Dynamic Island, and suddenly I had my technical hurdle. Not just "display what I'm listening to" β but how do I display it? How do I make it feel alive?
I checked out various Android clones of the Dynamic Island. All of them had awkward animation curves. Something felt off β the motion was calculated, not natural. That got me interested in understanding the details that make Apple's version feel different.
The physics of motion
Most web animations use parametric curves β bezier functions that interpolate between start and end states. They're predictable, CSS-native, and work well for simple transitions.
// Standard CSS easing
transition: transform 0.3s ease-in-out;But Apple uses spring physics. Instead of following a predetermined curve, the animation behaves like an actual spring β governed by stiffness and damping values that produce natural, physical motion.
Parametric vs Spring curves
We can classify animations into two big categories:
Parametric curves. Given a start and endpoint, place control points and interpolate using mathematical formulas. The BΓ©zier curve that most developers use falls here. CSS-native, easy to reason about.
Spring curves. Based on Newtonian dynamics (Hooke's law), we calculate physical trajectory using stiffness and dampening. The animation behaves like an actual spring would.
The difference is subtle but significant. Bezier curves feel calculated. Springs feel alive. They overshoot slightly, settle gradually, and respond to interruption naturally.
const SPRING_CONFIG = {
stiffness: 400,
damping: 30,
};These values are the result of experimentation. Higher stiffness means snappier motion. Higher damping means less bounce. The ratio between them determines the character of the animation.
stiffness: 400 with damping: 30 produces motion that's quick enough to feel responsive but smooth enough to feel natural. It's the sweet spot.
I spent way too long tweaking these numbers
Framer Motion provides a useSpring() hook that gives us control over these physical properties:
import { useSpring } from 'framer-motion'
useSpring(x, { stiffness: 1000, damping: 10 })Anatomy of the island
The Dynamic Island isn't one size β it's a system of sizes. Apple defines several presets for different contexts:
| Size | Use Case |
|---|---|
| Default | Idle state, the notch itself |
| Compact | Single background activity (music, timer) |
| Minimal | Multiple activities, showing one per side |
| Medium | Expanded view with moderate content |
| Large | Full expanded view with rich content |
Apple's documentation calls all the big sizes "Expanded," but in practice there are distinct dimensions for each context β small, medium, large, long, tall, ultra. Each serves a different information density.
Each size has specific dimensions. When the island transitions, it animates width, height, and border radius simultaneously β all using the same spring physics.
export type DynamicIslandSize =
| 'compact'
| 'minimalLeading'
| 'minimalTrailing'
| 'default'
| 'long'
| 'large'
| 'ultra'
export const ISLAND_SIZES = {
default: { width: 126, height: 37, borderRadius: 22 },
compact: { width: 220, height: 37, borderRadius: 22 },
minimal: { width: 52, height: 37, borderRadius: 22 },
medium: { width: 320, height: 140, borderRadius: 36 },
large: { width: 360, height: 160, borderRadius: 42 },
};The squircle problem
Standard border-radius creates circular arcs at the corners. But Apple's corners are squircles β superellipses that have continuously varying curvature.
The mathematical definition is:
Where n controls the curvature. Higher values produce more rectangular shapes; lower values produce more circular ones. Apple uses values around 4-5 for their corners.
Standard CSS border-radius produces a constant curvature, which creates a subtle but noticeable "pinch" at the transition points. Squircles have gradual curvature changes that feel more organic.
For web implementations, we have two options:
- SVG
clipPathβ Precise but expensive to animate - Standard
border-radiusβ Performant but not geometrically perfect
I chose a hybrid approach: use border-radius during animation, then clip to the true squircle shape when the animation settles. The difference is barely perceptible, and the performance gain is significant.
I saw a similar clipping approach in iOS 16's Notification Center β maybe Apple does the same thing?
Building the component system
The foundation is a React context that manages island state:
interface IslandState {
size: IslandSize;
previousSize: IslandSize;
isAnimating: boolean;
}
const IslandContext = createContext<IslandContextValue | null>(null);Tracking the previous size allows for transition-aware rendering. Some content should only appear after the island has finished expanding. Some should fade out before it starts contracting.
The core component wraps content in an animated container:
<motion.div
animate={{
width: dimensions.width,
height: dimensions.height,
borderRadius: dimensions.borderRadius,
}}
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
>
<AnimatePresence mode="popLayout">
{children}
</AnimatePresence>
</motion.div>AnimatePresence with mode="popLayout" ensures smooth transitions between different content states. The outgoing content animates out while the incoming content animates in, and the layout adjusts continuously.
The core animation code looks like this:
<motion.div
initial={{
opacity: size === previousSize ? 1 : 0,
scale: size === previousSize ? 1 : 0.9,
}}
animate={{
opacity: size === previousSize ? 0 : 1,
scale: size === previousSize ? 0.9 : 1,
transition: { type: 'spring', stiffness: 400, damping: 30 },
}}
exit={{ opacity: 0, filter: 'blur(10px)', scale: 0 }}
style={{ willChange }}
className={className}
>
{children}
</motion.div>The exit animation with filter: 'blur(10px)' adds that satisfying dissolve effect when content leaves the island.
Live Spotify Integration
This is showing what I'm actually listening to right now β live from Spotify. The island updates every 30 seconds to fetch the current track.
Live from Spotify Β· Click to expand
Setting up OAuth
Spotify uses OAuth 2.0 with a refresh token flow. You'll need:
- A Spotify Developer account
- A registered application with
user-read-currently-playinganduser-read-recently-playedscopes - A refresh token obtained through the authorization flow
// Environment variables
SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
SPOTIFY_REFRESH_TOKEN=your_refresh_tokenThe API route
The Next.js API route handles token refresh and data fetching:
async function getAccessToken() {
const basic = Buffer.from(
`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`
).toString("base64");
const response = await fetch(TOKEN_ENDPOINT, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: SPOTIFY_REFRESH_TOKEN,
}),
});
return response.json();
}The /me/player/currently-playing endpoint returns the active track. If nothing is playing, we fall back to /me/player/recently-played to show the last played track.
Spotify rate limits are generous but not infinite
Caching strategy
API quotas and rate limits make caching essential. A simple approach:
- Revalidate on interval β Next.js
revalidateoption caches responses - Client polling β Fetch every 30 seconds to keep data fresh without hammering the API
- Graceful fallback β Show cached data if the API fails
For more aggressive caching, you could use GitHub Actions to fetch data periodically and commit it to a JSON file, then serve that static file instead of hitting the API directly.
The equalizer
The music player felt empty without visual feedback. Static album art next to a play button β functional, but lifeless.
Enter the equalizer.
I searched for existing React equalizer components but couldn't find anything that felt right. So I built one with Framer Motion. Each bar has a randomized height that oscillates independently:
const Equalizer = () => {
return (
<div className="flex items-end gap-0.5 h-4">
{[...Array(4)].map((_, i) => (
<motion.div
key={i}
className="w-0.5 bg-white rounded-full"
animate={{
height: [4, 12 + Math.random() * 4, 6, 14 + Math.random() * 4, 4],
}}
transition={{
duration: 1.2 + Math.random() * 0.4,
repeat: Infinity,
ease: "easeInOut",
}}
/>
))}
</div>
);
};The amplitude problem
First iteration: completely random heights for each bar. Looked wrong.
Here's why β vocal music typically has smaller amplitudes on low and high frequencies. But random values give every frequency band equal prominence. The result looked chaotic, not musical.
The fix: set a base amplitude for each bar, then add small random variations on top. Low and high frequencies get shorter base heights. Middle frequencies get taller ones. Now the equalizer looks like it's actually responding to music, not just noise.
Sometimes you have to understand the physics to fake it convincingly
I also pull the dominant color from the album art and tint the equalizer accordingly. Spotify's API returns this data β no additional processing needed.
Performance considerations
Animation performance matters. Janky transitions undermine the fluidity we're trying to achieve.
will-change
The CSS will-change property tells the browser to prepare for specific changes:
import { useWillChange } from "motion/react";
const willChange = useWillChange();
<motion.div style={{ willChange }} />This promotes the element to its own compositing layer, enabling GPU-accelerated transforms. The trade-off is memory usage β don't apply it to everything.
Layout containment
contain: layout prevents layout recalculations from propagating:
.dynamic-island {
contain: layout;
}This isolates the island's internal layout from the rest of the page, reducing the work the browser needs to do during animation.
Transform vs layout properties
Always animate transform and opacity when possible. These are "cheap" properties that don't trigger layout or paint. Animating width and height is more expensive, but necessary for the morphing effect.
Framer Motion handles this intelligently, using transform: scale() when possible and falling back to layout animations when needed.
When to use this pattern
The Dynamic Island works well for:
- Background activities β Music, timers, downloads, calls
- Transient notifications β Messages that need attention but not interruption
- Contextual controls β Actions related to an ongoing activity
It works less well for:
- Primary navigation β The island is for context, not structure
- Complex forms β Limited space makes input difficult
- Dense information β Tables, lists, detailed content
The constraint is also its strength. Forcing content into the island's form factor requires distillation. What's the essential information? What action needs to be immediately accessible?
Embedding and integration
I wanted to embed this island on other pages β maybe as a persistent music widget, maybe as a notification system. But adding Framer Motion as a dependency everywhere felt heavy.
The solution: iframe with responsive resizing.
// Dedicated routes for embedding
/embed-player // Music player islandCSS position: sticky keeps the island visible while scrolling. The iframe resizes dynamically based on the island's current state using iframe-resizer.
It's not the most elegant solution β iframes carry their own baggage. But it works, it's isolated, and it keeps the main bundle size down.
What I learned
This project took about a month of sporadic work. A few reflections:
Spring physics matter more than you'd think. The difference between bezier curves and springs is subtle in isolation. But across an entire interface, it's the difference between "functional" and "delightful." Worth the extra complexity.
API integration requires patience. Spotify's OAuth flow and token refresh mechanism took some iteration to get right. Understanding the rate limits, caching strategies, and graceful fallbacks was essential for a reliable implementation.
The details compound. Squircles. Equalizer amplitude curves. Content timing. Exit blur effects. None of these matter in isolation. Together, they create the feeling of coherence that makes the Dynamic Island feel alive.
The Dynamic Island isn't revolutionary technology. It's spring physics, animated containers, and thoughtful content design. What makes it special is the coherence β every element responding to the same physical laws, every transition serving the same interaction model.
Recreating it on the web won't give you a notch to work with. But the principles β responsive motion, contextual adaptation, continuous feedback β apply to any interface that wants to feel alive.