Prerequisites
The dynamic island feature introduced with the iPhone 14 has captured the attention of designers and developers alike, inspiring a wave of creative uses for this adaptable UI element. Below are some examples of how others have leveraged this novel component.
Several iOS examples you'll find online rely on a morphing effect known as metaballs. This approach causes elements to begin stretching toward each other as they draw closer. The accompanying illustration shows how two elements behave by default versus how they behave when the metaballs technique is applied.
This tutorial won't cover the metaballs technique. Instead, we'll focus on the core transformation: expanding the standard dynamic island into a larger container that holds additional content. If you'd like to see a more advanced guide on building this effect with metaballs, drop a note in the comments.
This guide demonstrates how to create dynamic island-style animations within Angular. Using both Popmotion and Angular's animation module, we'll build the interaction shown below.
Setting Up a New Project
To get started, scaffold a fresh Angular application with the command below:
ng new dynamic-island --routing --style=scss
If Angular is unfamiliar territory, the Getting Started guide will help you prepare your development environment.
Popmotion Library
Popmotion is a JavaScript animation library offering support for keyframes, springs, and inertia-based motion. Install its dependencies with:
npm i --save popmotion
For this project, Stylefire will be used to apply dynamic styles and read the target element's current styling. Install it with:
npm i --save stylefire
Ionic Integration (Optional)
The progress bar in this tutorial comes from Ionic components, and the control buttons make use of IonIcons. This step is not mandatory; a standard HTML progress element or different icon library will work just as well.
To enable Ionic within your Angular project, run:
ng add @ionic/angular
For IonIcons, the installation command is:
npm i --save ionicons
Project Configuration
To keep things straightforward, all animation logic will live inside the default AppComponent.
First, we'll construct the template for the dynamic island. Navigate to app.component.html and insert the code below 👇
<!-- src/app/app.component.html -->
<ion-content [fullscreen]="true">
<div class="dynamic-island-container">
<div class="dynamic-island">
</div>
</div>
</ion-content>
Following that, apply styling to the container and the dynamic island itself. Open app.component.scss and add the styles below 👇
// src/app/app.component.scss
ion-content {
--background: linear-gradient(to top right, #09033D, #276176);
}
.dynamic-island-container {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 11px;
.dynamic-island {
width: 126px;
height: 37.33px;
border-radius: 18.67px;
background-color: #222329;
}
}
Checkpoint: Execute ng serve and open http://localhost:4200 in your browser. A dynamic island should now appear at the top of the page, as shown:
Animating the Dynamic Island
Popmotion will handle the expansion of the dynamic island as it moves between its collapsed and expanded states. Start by assigning an id to the island element so we can reference it from the component. A click handler on the island will also be needed to switch between the two states. Make these changes in app.component.html:
<!-- src/app/app.component.html -->
<ion-content [fullscreen]="true">
<div class="dynamic-island-container">
<!-- Update this 👇 -->
<div #dynamicIsland class="dynamic-island" (click)="toggleDynamicIsland()">
</div>
</div>
</ion-content>
Now, in app.component.ts, bring in the ViewChild decorator to grab the island element. Alongside that, set up a toggleDynamicIsland method and a state flag that records whether the island is currently open or closed:
// src/app/app.component.ts
// Update this 👇
import { Component, ElementRef, ViewChild } from '@angular/core';
@Component({
selector: 'app-dynamic-island-popmotion',
templateUrl: 'dynamic-island-popmotion.page.html',
styleUrls: ['dynamic-island-popmotion.page.scss'],
})
export class DynamicIslandPopmotionPage {
// Add this 👇
@ViewChild('dynamicIsland') dynamicIsland: ElementRef;
dynamicIslandIsOpen = false;
// Add this 👇
toggleDynamicIsland(): void {
if (this.dynamicIslandIsOpen) {
this.dynamicIslandIsOpen = false;
} else {
this.dynamicIslandIsOpen = true;
}
}
}
In the AfterViewInit lifecycle hook, we'll use the styler utility from stylefire to read the island's initial width and height. These measurements get stashed in a variable that both the opening and closing animations will reference later:
// src/app/app.component.ts
import { Component, ElementRef, ViewChild } from '@angular/core';
// Add this 👇
import styler, { Styler } from 'stylefire';
@Component({
selector: 'app-dynamic-island-popmotion',
templateUrl: 'dynamic-island-popmotion.page.html',
styleUrls: ['dynamic-island-popmotion.page.scss'],
})
export class DynamicIslandPopmotionPage {
@ViewChild('dynamicIsland') dynamicIsland: ElementRef;
dynamicIslandIsOpen = false;
// Add this 👇
private styler: Styler;
private defaultDimensions;
// Add this 👇
ngAfterViewInit(): void {
this.styler = styler(this.dynamicIsland.nativeElement);
this.defaultDimensions = {
borderRadius: this.styler.get('borderRadius'),
width: this.styler.get('width'),
height: this.styler.get('height'),
}
}
toggleDynamicIsland(): void {
if (this.dynamicIslandIsOpen) {
this.dynamicIslandIsOpen = false;
} else {
this.dynamicIslandIsOpen = true;
}
}
}
With that in place, pull in Popmotion's animate function and define two methods — closeDynamicIsland and openDynamicIsland. Each animation applies style updates to the island through the onUpdate callback, which delegates to the styler instance we set up:
// src/app/app.component.ts
// Update this 👇
import { Component, ElementRef, NgZone, ViewChild } from '@angular/core';
// Add this 👇
import { animate as PopmotionAnimate } from 'popmotion';
import styler, { Styler } from 'stylefire';
@Component({
selector: 'app-dynamic-island-popmotion',
templateUrl: 'dynamic-island-popmotion.page.html',
styleUrls: ['dynamic-island-popmotion.page.scss'],
})
export class DynamicIslandPopmotionPage {
@ViewChild('dynamicIsland') dynamicIsland: ElementRef;
dynamicIslandIsOpen = false;
private styler: Styler;
private defaultDimensions;
// Add this 👇
constructor(private ngZone: NgZone) {}
ngAfterViewInit(): void {
this.styler = styler(this.dynamicIsland.nativeElement);
this.defaultDimensions = {
borderRadius: this.styler.get('borderRadius'),
width: this.styler.get('width'),
height: this.styler.get('height'),
}
}
// Update this 👇
toggleDynamicIsland(): void {
if (this.dynamicIslandIsOpen) {
this.dynamicIslandIsOpen = false
this.closeDynamicIsland().then(() => {
})
} else {
this.openDynamicIsland().then(() => {
this.dynamicIslandIsOpen = true
})
}
}
// Add this 👇
openDynamicIsland(): Promise<void> {
return new Promise<void>((resolve) => {
this.ngZone.runOutsideAngular(() => {
PopmotionAnimate({
from: JSON.stringify(this.defaultDimensions),
to: JSON.stringify({ borderRadius: 25, width: 400, height: 150 }),
duration: 600,
type: 'spring',
onUpdate: (latest) => {
const latestFormatted = JSON.parse(latest);
this.styler.set('borderRadius', `${latestFormatted.borderRadius}px`);
this.styler.set('width', `${latestFormatted.width}px`);
this.styler.set('height', `${latestFormatted.height}px`);
},
onComplete: () => {
resolve();
}
});
});
})
}
// Add this 👇
closeDynamicIsland(): Promise<void> {
return new Promise<void>((resolve) => {
this.ngZone.runOutsideAngular(() => {
PopmotionAnimate({
from: JSON.stringify(
{borderRadius: this.styler.get('borderRadius'),
width: this.styler.get('width'),
height: this.styler.get('height'),}
),
to: JSON.stringify(this.defaultDimensions),
duration: 600,
type: 'spring',
onUpdate: (latest) => {
const latestFormatted = JSON.parse(latest);
this.styler.set('borderRadius', `${latestFormatted.borderRadius}px`);
this.styler.set('width', `${latestFormatted.width}px`);
this.styler.set('height', `${latestFormatted.height}px`);
},
onComplete: () => {
resolve();
}
});
});
})
}
}
To keep Popmotion's animation loop outside of Angular's change detection, wrap the
animatecall insideNgZone'srunOutsideAngularmethod. The animation will run without triggering change detection on every frame.
Checkpoint: Fire up ng serve and navigate to http://localhost:4200. A click on the island should trigger the expansion animation, and clicking again should collapse it back down.
Filling the Expanded Dynamic Island
Inside app.component.html, the markup below builds out what appears once the island is in its expanded state:
<!-- src/app/app.component.html -->
<ion-content [fullscreen]="true">
<div class="dynamic-island-container">
<div #dynamicIsland class="dynamic-island" (click)="toggleDynamicIsland()">
<!-- Add this 👇 -->
<ng-container *ngIf="dynamicIslandIsOpen">
<div class="dynamic-island-contents-container">
<div class="info-container">
<img src="https://avatars.githubusercontent.com/u/80924473?s=200&v=4"/>
<div class="texts-container">
<span class="playlist">This is Angular</span>
<span class="title">Animations are pretty cool</span>
</div>
</div>
<div class="progress-container">
<span>3:20</span>
<ion-progress-bar mode="ios" value="0.7"></ion-progress-bar>
<span>-1:30</span>
</div>
<div class="controls-container">
<ion-icon name="play-back"></ion-icon>
<ion-icon name="play"></ion-icon>
<ion-icon name="play-forward"></ion-icon>
</div>
</div>
</ng-container>
</div>
</div>
</ion-content>
Some visual polish for those contents goes into app.component.scss:
// src/app/app.component.scss
ion-content {
--background: linear-gradient(to top right, #09033D, #276176);
}
.dynamic-island-container {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 11px;
.dynamic-island {
width: 126px;
height: 37.33px;
border-radius: 18.67px;
background-color: #222329;
// Add this 👇
.dynamic-island-contents-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 15px;
.info-container {
display: flex;
flex-direction: row;
img {
background-color: #fff;
width: 50px;
height: 50px;
border-radius: 10px;
}
.texts-container {
display: flex;
flex-direction: column;
margin-left: 10px;
align-self: center;
span {
&.playlist {
font-size: 0.7rem;
color: #999;
}
&.title {
font-size: 1rem;
font-weight: 500;
color: #fff;
margin-top: 2px;
}
}
}
}
.progress-container {
display: flex;
flex-direction: row;
align-items: center;
padding: 10px 0;
ion-progress-bar {
height: 3px;
flex-grow: 1;
margin: 0 5px;
--background: #555;
--progress-background: #fff;
}
span {
font-size: 0.7rem;
color: #888;
}
}
.controls-container {
display: flex;
flex-direction: row;
align-self: center;
ion-icon {
color: #fff;
margin: 0 10px;
font-size: 2rem;
}
}
}
}
}
Checkpoint: With ng serve running, visit http://localhost:4200. Toggling the island should now bring extra content into view alongside the expansion. A second click hides that content and returns the island to its resting size.
Staggering the Island's Inner Content
Rather than having the inner elements pop into existence all at once, Angular Animations can sequence them so each one slides up and fades in slightly after the one before it.
The animation definitions belong in the animations array of the Component decorator in app.component.ts:
// src/app/app.component.ts
import { Component, ElementRef, NgZone, ViewChild } from '@angular/core';
// Add this 👇
import { trigger, transition, style, animate, query, stagger } from '@angular/animations';
import { animate as PopmotionAnimate } from 'popmotion';
import styler, { Styler } from 'stylefire';
@Component({
selector: 'app-dynamic-island-popmotion',
templateUrl: 'dynamic-island-popmotion.page.html',
styleUrls: ['dynamic-island-popmotion.page.scss'],
// Add this 👇
animations: [
trigger('fadeSlideInOut', [
transition(':enter', [
query(':enter', [
style({ opacity: 0, transform: 'translateY(30px)' }),
stagger('50ms', [
animate(
'400ms cubic-bezier(0.17, 0.89, 0.24, 1.11)',
style({ opacity: 1, transform: 'translateY(0)' }),
)
])
])
])
]),
]
})
export class DynamicIslandPopmotionPage {
@ViewChild('dynamicIsland') dynamicIsland: ElementRef;
dynamicIslandIsOpen = false;
private styler: Styler;
private defaultDimensions;
constructor(private ngZone: NgZone) {}
ngAfterViewInit(): void {
this.styler = styler(this.dynamicIsland.nativeElement);
this.defaultDimensions = {
borderRadius: this.styler.get('borderRadius'),
width: this.styler.get('width'),
height: this.styler.get('height'),
}
}
toggleDynamicIsland(): void {
if (this.dynamicIslandIsOpen) {
this.dynamicIslandIsOpen = false
this.closeDynamicIsland().then(() => {
})
} else {
this.openDynamicIsland().then(() => {
this.dynamicIslandIsOpen = true
})
}
}
openDynamicIsland(): Promise<void> {
return new Promise<void>((resolve) => {
this.ngZone.runOutsideAngular(() => {
PopmotionAnimate({
from: JSON.stringify(this.defaultDimensions),
to: JSON.stringify({ borderRadius: 25, width: 400, height: 150 }),
duration: 600,
type: 'spring',
onUpdate: (latest) => {
const latestFormatted = JSON.parse(latest);
this.styler.set('borderRadius', `${latestFormatted.borderRadius}px`);
this.styler.set('width', `${latestFormatted.width}px`);
this.styler.set('height', `${latestFormatted.height}px`);
},
onComplete: () => {
resolve();
}
});
});
})
}
closeDynamicIsland(): Promise<void> {
return new Promise<void>((resolve) => {
this.ngZone.runOutsideAngular(() => {
PopmotionAnimate({
from: JSON.stringify(
{borderRadius: this.styler.get('borderRadius'),
width: this.styler.get('width'),
height: this.styler.get('height'),}
),
to: JSON.stringify(this.defaultDimensions),
duration: 600,
type: 'spring',
onUpdate: (latest) => {
const latestFormatted = JSON.parse(latest);
this.styler.set('borderRadius', `${latestFormatted.borderRadius}px`);
this.styler.set('width', `${latestFormatted.width}px`);
this.styler.set('height', `${latestFormatted.height}px`);
},
onComplete: () => {
resolve();
}
});
});
})
}
}
Last step: wire the fadeSlideInOut trigger onto the target element in app.component.html, and put *ngIf="true" on its immediate children so the stagger kicks in as each one enters:
<!-- src/app/app.component.html -->
<ion-content [fullscreen]="true">
<div class="dynamic-island-container">
<div #dynamicIsland class="dynamic-island" (click)="toggleDynamicIsland()">
<ng-container *ngIf="dynamicIslandIsOpen">
<!-- Update this 👇 -->
<div @fadeSlideInOut class="dynamic-island-contents-container">
<!-- Update this 👇 -->
<div *ngIf="true" class="info-container">
<img src="https://avatars.githubusercontent.com/u/80924473?s=200&v=4"/>
<div class="texts-container">
<span class="playlist">This is Angular</span>
<span class="title">Animations are pretty cool</span>
</div>
</div>
<!-- Update this 👇 -->
<div *ngIf="true" class="progress-container">
<span>3:20</span>
<ion-progress-bar mode="ios" value="0.7"></ion-progress-bar>
<span>-1:30</span>
</div>
<!-- Update this 👇 -->
<div *ngIf="true" class="controls-container">
<ion-icon name="play-back"></ion-icon>
<ion-icon name="play"></ion-icon>
<ion-icon name="play-forward"></ion-icon>
</div>
</div>
</ng-container>
</div>
</div>
</ion-content>
Checkpoint: Launch ng serve and open http://localhost:4200. The island expands on click, and its inner content now enters with a staggered motion. A second click reverses everything: the content disappears and the island contracts.
Closing Thoughts
The morphing approach used here for the dynamic island isn't limited to that one UI pattern. The same ideas translate to something like a floating action button that stretches into a full-screen modal on tap. Expect to see more of these shape-shifting transitions as Apple's design language continues to popularize them. Try it out and share what you build!
Questions or feedback? Drop a comment or reach out on Twitter at @williamjuan27.
Additional Resources
- For more animation ideas in Angular, browse the Angular Animations Explorer.
- More animation-focused guides can be found on my blog.






