dynamic island.

The iOS Dynamic Island, morphing between seven live activities on a single spring that survives being interrupted.

react / spring / motion / ios

Loading component...

live component, try it

what to expect.

Tap the chips to send the island an activity
Tap a second chip mid-morph to interrupt it
Tap the island itself to open and close it

how it works.

The obvious build gives width and height a spring each, and it looks perfect, which is the trap. A spring is linear, so two axes released together from rest hold the same normalised progress however far apart their targets are. It comes apart on interruption, and for this component interruption is not an edge case: a call arrives while the timer is still expanding. Each axis carries its own velocity into the new leg, those velocities are not in the ratio of the new distances, and the island travels through shapes that belong to no state. One scalar spring fixes it by construction, and an interruption moves the line rather than starting a second race. The content is a handover rather than a crossfade, because two labels at half opacity stacked on each other is not a transition, it is mush. The layout follows the hardware too: a compact state keeps the camera's width empty in the middle and puts its content either side, the clock and battery slide outward as the island widens so it never sits on them, and the camera fades out while the island is busy and comes back only once it has settled.

the component.

import { settled, step, type SpringConfig, type SpringState } from "../_shared/spring";

export interface Box {
  w: number;
  h: number;
  r: number;
}

export interface MorphState {
  from: Box;
  to: Box;
  t: SpringState;
}

const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));

const legLength = (from: Box, to: Box) => Math.hypot(to.w - from.w, to.h - from.h);

export function interpolate(from: Box, to: Box, t: number): Box {
  const h = lerp(from.h, to.h, t);
  return {
    w: lerp(from.w, to.w, t),
    h,
    // Cap the corner at a pill so overshoot never paints a lozenge.
    r: Math.min(lerp(from.r, to.r, t), h / 2),
  };
}

// One spring drives progress along the whole shape, so an interruption stays on the path between states.
export function beginMorph(from: Box, to: Box, carried = 0, carriedFrom?: Box): MorphState {
  const next = legLength(from, to);
  const prev = carriedFrom ? legLength(from, carriedFrom) : 0;
  // Rescale carried velocity by leg length, or retargeting onto a shorter leg jumps in speed.
  const v = next > 0 && prev > 0 ? (carried * prev) / next : 0;
  return { from, to, t: { x: 0, v } };
}

export function stepMorph(state: MorphState, dtMs: number, cfg: SpringConfig): MorphState {
  return { ...state, t: step(state.t, 1, dtMs, cfg) };
}

export function boxAt(state: MorphState): Box {
  return interpolate(state.from, state.to, state.t.x);
}

export function morphDone(state: MorphState): boolean {
  return settled(state.t, 1);
}

export interface AxisState {
  w: SpringState;
  h: SpringState;
  r: SpringState;
}

// Naive version with a spring per axis, kept for comparison.
export function stepAxes(state: AxisState, to: Box, dtMs: number, cfg: SpringConfig): AxisState {
  return {
    w: step(state.w, to.w, dtMs, cfg),
    h: step(state.h, to.h, dtMs, cfg),
    r: step(state.r, to.r, dtMs, cfg),
  };
}

export function axisBox(state: AxisState): Box {
  return { w: state.w.x, h: state.h.x, r: Math.min(state.r.x, state.h.x / 2) };
}

export function axesDone(state: AxisState, to: Box): boolean {
  return settled(state.w, to.w) && settled(state.h, to.h) && settled(state.r, to.r);
}

export interface Fade {
  opacity: number;
  scale: number;
  blur: number;
}

const ENTER_SCALE = 0.92;
const ENTER_BLUR = 3;

export function contentPhase(t: number, handover: number): { leaving: Fade; arriving: Fade } {
  const p = clamp01(t);
  const out = handover > 0 ? clamp01(1 - p / handover) : Number(p <= 0);
  const inn = handover < 1 ? clamp01((p - handover) / (1 - handover)) : Number(p >= 1);
  return {
    leaving: { opacity: out, scale: lerp(ENTER_SCALE, 1, out), blur: lerp(ENTER_BLUR, 0, out) },
    arriving: { opacity: inn, scale: lerp(ENTER_SCALE, 1, inn), blur: lerp(ENTER_BLUR, 0, inn) },
  };
}