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.

Default behavior vs metaballs comparison

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.

Demo of dynamic island animations in an Angular application

Setting Up a New Project

To get started, scaffold a fresh Angular application with the command below:

ng new dynamic-island --routing --style=scss
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For IonIcons, the installation command is:

npm i --save ionicons
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

Checkpoint 1 - dynamic island default state

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>
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }

}
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }

}
Enter fullscreen mode Exit fullscreen mode

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();
          }
        });
      });
    })
  }

}
Enter fullscreen mode Exit fullscreen mode

To keep Popmotion's animation loop outside of Angular's change detection, wrap the animate call inside NgZone's runOutsideAngular method. 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.

Checkpoint 2 - dynamic island animation between default and expanded state

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>
Enter fullscreen mode Exit fullscreen mode

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;
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Checkpoint 3 - dynamic island expanded state's contents

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();
          }
        });
      });
    })
  }


}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Checkpoint 4 - dynamic island final demo

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