Live Activities

A Live Activity surfaces one in-progress event — a delivery, a timer, a live score — on the Lock Screen and in the Dynamic Island, updating in place without the user opening the app. Built on ActivityKit, not WidgetKit, and governed by a different set of visual constraints than home screen widgets.

Not the same surface as widgets

Mobile Widgets are static, timeline-refreshed snapshots built on WidgetKit, and can use .containerBackground(for: .widget)— the system material that handles light/dark and vibrancy automatically. Live Activities are built on ActivityKit for a single, currently-running event, are pushed or locally updated in near-real time, and end when the event does. Because the Dynamic Island and lock screen present outside the normal app chrome, they structurally cannot use System Materials at all — the constraint below isn't a stylistic choice, it's the only option ActivityKit gives you.

Design note

Don't try to port Sitka's card/glass tokens onto a Live Activity. Treat it as its own small design system with exactly two ingredients — one solid tint color and white text at two opacities — and put your effort into choosing a tint that reads clearly on both the Dynamic Island's black surface and the Lock Screen's wallpaper.

Regions

A single Live Activity configuration renders in up to five places. Design them together — the same underlying state (a title, a detail line, a live value) is what populates all five, just at different sizes.

🚚
minimal
🚚12:48
compact leading + trailing
🚚Out for delivery
12:48
Arriving at 428 Elm Street
expanded · leading / trailing / bottom regions
Out for delivery
Arriving at 428 Elm Street
12:48
lock screen banner
RegionWhere it appearsWhat to put there
MinimalShown when a second Live Activity is also active and competing for Dynamic Island space.A single glyph only — no text fits.
Compact leadingLeft side of the pill, always visible while the activity is running.One small icon or glyph identifying the activity.
Compact trailingRight side of the pill.The single most important live value — usually a countdown or short status.
Expanded leadingTop-left when the user long-presses the island.Icon + short title.
Expanded trailingTop-right when expanded.The same live value as compact trailing, at a larger size.
Expanded bottomFull-width row beneath the top regions when expanded.One line of supporting detail — a destination, a subtitle, a next action.
Lock screen bannerFull-width card on the lock screen and in Notification Center.The complete activity: title, one line of detail, and the live value. This is the only region with real horizontal space — expanded and lock screen typically share the same view.

Design constraints

Solid tint, not glass

Dynamic Island and lock screen surfaces render outside your app's normal view hierarchy and can't use System Materials, vibrancy, or Sitka's card/glass chrome. Set a single solid background color with .activityBackgroundTint(_:) — that color is the entire visual language available to you.

White-on-tint text

With no material to lean on, legibility over an arbitrary background comes from contrast, not chrome. Pick a tint dark enough that white text at ~90–100% opacity for primary content and ~55–70% for secondary clears WCAG AA — don't try to reuse light-mode/dark-mode text tokens here.

Design for the smallest region first

Compact and minimal have almost no space — a glyph and a handful of characters. Design those first, then expand outward to the lock screen banner, not the other way around; content that only fits the banner has nowhere to go in the compact states.

SwiftUI implementation

ActivityKit is iOS/watchOS-only — there is no React or HTML equivalent, unlike most patterns in this library. A minimal Live Activity needs an ActivityAttributes type (the fixed data, set once) plus a ContentState (the part that updates), and a Widget conformance that lays out the lock screen view and every Dynamic Island region.

DeliveryLiveActivity.swift
import ActivityKit
import WidgetKit
import SwiftUI

// 1. Attributes — fixed for the activity's lifetime
struct DeliveryAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable {
        var eta: Date
        var address: String
    }
    var orderId: String
}

// 2. Widget — lays out lock screen + every Dynamic Island region
struct DeliveryLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: DeliveryAttributes.self) { context in
            // Lock screen / banner
            HStack {
                VStack(alignment: .leading, spacing: 2) {
                    Text("Out for delivery").font(.headline).foregroundStyle(.white)
                    Text(context.state.address)
                        .font(.caption)
                        .foregroundStyle(.white.opacity(0.65))
                }
                Spacer()
                Text(timerInterval: Date.now...context.state.eta, countsDown: true)
                    .font(.title2.monospacedDigit())
                    .foregroundStyle(.white)
            }
            .padding()
            // The ONLY chrome available — a flat tint, no material.
            .activityBackgroundTint(Color(red: 0.30, green: 0.12, blue: 0.03))
            .activitySystemActionForegroundColor(.white)

        } dynamicIsland: { context in
            DynamicIsland {
                DynamicIslandExpandedRegion(.leading) {
                    Label("Out for delivery", systemImage: "shippingbox.fill")
                        .lineLimit(1)
                }
                DynamicIslandExpandedRegion(.trailing) {
                    Text(timerInterval: Date.now...context.state.eta, countsDown: true)
                        .font(.caption.monospacedDigit())
                }
                DynamicIslandExpandedRegion(.bottom) {
                    Text(context.state.address).font(.caption2).foregroundStyle(.secondary)
                }
            } compactLeading: {
                Image(systemName: "shippingbox.fill")
            } compactTrailing: {
                Text(timerInterval: Date.now...context.state.eta, countsDown: true)
                    .font(.caption2.monospacedDigit())
                    .frame(width: 44)
            } minimal: {
                Image(systemName: "shippingbox.fill")
            }
        }
    }
}

Real-world reference: JobFlo ships two Live Activities on this same shape — an interview countdown and an application-deadline countdown — each with its own tint color and SF Symbol, structured identically to the example above.

Accessibility

  • →VoiceOver reads the Dynamic Island and lock screen banner as a unit — provide an .accessibilityLabel summarizing the full state ('Out for delivery, arriving in 12 minutes') rather than letting it read each Text view separately.
  • →The countdown timer text (Text(timerInterval:countsDown:)) updates its accessible value automatically — don't wrap it in a custom label that goes stale relative to the visible time.
  • →Never rely on the tint color alone to convey status (e.g. red = urgent) — pair any status change with a text or icon change, since the tint is a single fixed value for the activity's lifetime in most implementations.
  • →Keep the compact and minimal region content meaningful on its own — some users only ever see the collapsed Dynamic Island, never the expanded view.
  • →Always set a sensible staleness date and end the activity promptly when the event concludes — a Live Activity that outlives its event (a delivery marked 'arriving' long after it arrived) erodes trust in every future one your app shows.