Apple's Dynamic Island is one black rounded rectangle that changes size. What makes it feel alive is not the animation. It is that two things can want it at once, and the rules for who gets it are fixed, total and deterministic.
๐ Live, runs in your browser: https://dev48.in/design/day81-dynamic-island.html
Not a stack
The tempting model is a stack: whatever started last is on top. It is wrong in a way users notice immediately โ start a timer during a phone call and the call disappears.
The real model is a priority queue keyed by the kind of activity, with start time used only to break ties between two activities of equal rank.
function compareActivities(a, b) {
const ra = KINDS[a.kind].rank, rb = KINDS[b.kind].rank;
if (ra !== rb) return ra - rb; // call beats timer beats music
if (a.startedAt !== b.startedAt) return a.startedAt - b.startedAt;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; // total, never 0 for distinct ids
}
Checked over all 31 non-empty subsets of a five-activity pool, in every permutation of each: one order-independent owner every time. Both models agree while only one activity runs, which is exactly why the stack version ships and then breaks in the field.
The corner radius is not a style
Pick a fixed radius and animate the box from the small pill to the tall expanded card, and the corners visibly change character. The shape stops reading as the same object and starts reading as a box that grew.
The radius has to be a function of the height:
function radiusFor(h) {
if (!(h > 0)) throw new Error("height must be positive");
return Math.min(SCREEN_R, Math.max(MIN_R, h / 2));
}
A single fixed 22 is wrong by up to 10 points across the four presentations โ enough that the small pill looks over-rounded and the sheet looks square.
๐ด Two bugs the verifier found
A stateless resolver teleports. Given the current state it happily names a presentation several hops away. Ending the last activity while expanded resolves straight to minimal โ and expanded โ minimal is not a transition the machine allows. An exhaustive replay of every 4-event sequence found 36 such jumps.
The fix is not to widen the transition table. It is to make the machine route:
transitionPath("expanded", "minimal") // => ["expanded", "compact", "minimal"]
An interrupted expansion came back by itself. Expand the music, take a call, end the call โ and the music silently reopened in its expanded state. The user expanded it, something took the island, and that expansion is gone for good:
const owner = ownerOf(s.activities);
if (s.expandedBy && (!owner || owner.id !== s.expandedBy)) s.expandedBy = null;
The spring has to land exactly
A spring that merely approaches its target is a bug. The island has to arrive at a whole number of points and stop, or the next transition starts from a position that is off by a fraction and the error compounds. Critically damped, then snap once distance and velocity are both under a threshold.
The verifier checks this against the closed form of a critically damped oscillator โ
x(t) = target + (A + Bt)ยทe^(โฯt), ฯ = โk
โ because "it never overshoots" is a property of the solution, and no amount of stepping establishes it.
One scalar, not two
Animate width and height as separate springs and the island passes through shapes that never existed as a presentation. One scalar drives both, so every frame lies on the straight line between two real presentations. Worst deviation: 0.
๐ด Two independently tuned springs wander up to 15 points off that line โ and still arrive at the right size. Which is precisely why a screenshot test of the end state misses this bug entirely.
๐ด And one claim of mine that was wrong
I asserted in the verifier that at least one transition pair must be one-way. It failed. The size ladder is symmetric by design: every legal step is legal in reverse. That is a design choice worth pinning down, not an oversight.
210 verifier asserts, 35 in-page checks, 0 failures.
Top comments (0)