Dynamic Island Pill
A black capsule at the top of the screen that grows to show a live status and shrinks back to a pill.

- Feedback
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- dynamic island · pill · capsule · live status · expand · morph · now playing · isla dinámica · píldora · cápsula · estado en directo · expandir · transformación · reproduciendo
Description
Section titled “Description”A black capsule that grows to show a live status and shrinks back to a pill.
Place it at the top of the screen, over the island of the device. Its content starts under the pill, because the sensors of a real island would cover it there. Hide the status bar while it is expanded, because it takes that space. With Reduce Motion on, it changes size without bouncing and its content only fades.
private enum DynamicIslandPillDemoStatus { case music case timer}
struct DynamicIslandPillDemo: View { @State private var status: DynamicIslandPillDemoStatus? @State private var timerEnd = Date.now @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { ZStack(alignment: .top) { LinearGradient(colors: [.purple, .indigo, .blue], startPoint: .top, endPoint: .bottom) .ignoresSafeArea()
DynamicIslandPill(isExpanded: status != nil) { switch status { case .music: musicStatus case .timer: timerStatus case nil: EmptyView() } } .animation(reduceMotion ? nil : .snappy, value: status) .padding(.top, 11) .ignoresSafeArea(edges: .top)
VStack(spacing: 14) { Button("Music") { status = .music } Button("Timer") { timerEnd = .now + 25 * 60 status = .timer } Button("Collapse") { status = nil } } .buttonStyle(.borderedProminent) .tint(.white.opacity(0.25)) .controlSize(.large) .frame(maxHeight: .infinity) } .statusBarHidden(status != nil) }
private var musicStatus: some View { HStack(spacing: 14) { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(LinearGradient(colors: [.pink, .orange], startPoint: .top, endPoint: .bottom)) .frame(width: 48, height: 48) .overlay { Image(systemName: "music.note") .foregroundStyle(.white) } .accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) { Text("Midnight City") .font(.headline) Text("Now playing") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "waveform") .font(.title2) .foregroundStyle(.pink) .symbolEffect(.variableColor.iterative, options: .repeating, isActive: !reduceMotion) .accessibilityHidden(true) } }
private var timerStatus: some View { HStack(spacing: 14) { Image(systemName: "timer") .font(.title) .foregroundStyle(.orange) .accessibilityHidden(true)
Text("Focus session") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading)
Text(timerEnd, style: .timer) .font(.title2.bold()) .monospacedDigit() .foregroundStyle(.orange) } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
isExpanded | Bool | Required |
pillSize | CGSize | CGSize(width: 126, height: 37) |
expandedCornerRadius | CGFloat | 36 |
content | () -> Content | Required |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A black capsule that grows to show a live status and shrinks back to a pill.////// Place it at the top of the screen, over the island of the device. Its content starts/// under the pill, because the sensors of a real island would cover it there. Hide the/// status bar while it is expanded, because it takes that space. With Reduce Motion on,/// it changes size without bouncing and its content only fades.struct DynamicIslandPill<Content: View>: View { var isExpanded: Bool var pillSize = CGSize(width: 126, height: 37) var expandedCornerRadius: CGFloat = 36 @ViewBuilder var content: () -> Content
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var width: CGFloat { max(pillSize.width, 0) } private var height: CGFloat { max(pillSize.height, 0) }
var body: some View { let shape = RoundedRectangle( cornerRadius: isExpanded ? expandedCornerRadius : height / 2, style: .continuous )
ZStack { if isExpanded { content() .foregroundStyle(.white) .environment(\.colorScheme, .dark) .frame(maxWidth: .infinity) .padding(.horizontal, 22) .padding(.top, height + 8) .padding(.bottom, 18) .transition(reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 0.8, anchor: .top))) } } .frame(width: isExpanded ? nil : width, height: isExpanded ? nil : height) .background(.black, in: shape) .clipShape(shape) .padding(.horizontal, isExpanded ? 10 : 0) .animation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.5, bounce: 0.3), value: isExpanded) .accessibilityElement(children: .contain) .accessibilityHidden(!isExpanded) }}
// MARK: - Demo
private enum DynamicIslandPillDemoStatus { case music case timer}
struct DynamicIslandPillDemo: View { @State private var status: DynamicIslandPillDemoStatus? @State private var timerEnd = Date.now @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { ZStack(alignment: .top) { LinearGradient(colors: [.purple, .indigo, .blue], startPoint: .top, endPoint: .bottom) .ignoresSafeArea()
DynamicIslandPill(isExpanded: status != nil) { switch status { case .music: musicStatus case .timer: timerStatus case nil: EmptyView() } } .animation(reduceMotion ? nil : .snappy, value: status) .padding(.top, 11) .ignoresSafeArea(edges: .top)
VStack(spacing: 14) { Button("Music") { status = .music } Button("Timer") { timerEnd = .now + 25 * 60 status = .timer } Button("Collapse") { status = nil } } .buttonStyle(.borderedProminent) .tint(.white.opacity(0.25)) .controlSize(.large) .frame(maxHeight: .infinity) } .statusBarHidden(status != nil) }
private var musicStatus: some View { HStack(spacing: 14) { RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(LinearGradient(colors: [.pink, .orange], startPoint: .top, endPoint: .bottom)) .frame(width: 48, height: 48) .overlay { Image(systemName: "music.note") .foregroundStyle(.white) } .accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) { Text("Midnight City") .font(.headline) Text("Now playing") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "waveform") .font(.title2) .foregroundStyle(.pink) .symbolEffect(.variableColor.iterative, options: .repeating, isActive: !reduceMotion) .accessibilityHidden(true) } }
private var timerStatus: some View { HStack(spacing: 14) { Image(systemName: "timer") .font(.title) .foregroundStyle(.orange) .accessibilityHidden(true)
Text("Focus session") .font(.headline) .frame(maxWidth: .infinity, alignment: .leading)
Text(timerEnd, style: .timer) .font(.title2.bold()) .monospacedDigit() .foregroundStyle(.orange) } }}
#Preview { DynamicIslandPillDemo()}