iOS Widgets skill for Claude Code

Fresh widgets and Live Activities without reload spam. Claude builds widgets that render from a snapshot the app writes to a shared App Group, plans timelines around known future dates instead of burning the reload budget, wires taps through App Intents, and gives every declared family a real layout including accented modes. For Live Activities it handles token streams, the 4 KB content-state limit, exact APNs headers, and ending orphaned activities.

  • nameios-widgets
  • size253 lines · ≈3,000 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds iOS widgets, Live Activities, and controls with WidgetKit, ActivityKit, and App Intents — TimelineProvider and AppIntentTimelineProvider, reload budgets, interactive buttons and toggles, configurable widgets, accessory families for the Lock Screen, containerBackground and accented rendering, App Group data sharing, Dynamic Island layouts, push-to-update and push-to-start tokens, and Control Center controls. Use when adding a widget extension, a widget shows stale or placeholder data, a Live Activity will not start or update, or exposing actions through App Intents.

Try asking

  • Add a Home Screen and Lock Screen widget that shows today's tasks with a tap-to-complete button
  • Our widget keeps showing placeholder data even though the app has content — find out why
  • Build a delivery Live Activity that our backend updates over APNs

Use it when

  • Adding a widget extension, Lock Screen widget, or Control Center control
  • A widget is stale, blank, or shows defaults instead of app data
  • Starting or pushing updates to a Live Activity and Dynamic Island
  • Exposing app actions as App Intents for widgets, Siri, or Shortcuts

Skip it when

How to check the result

Previews render every supported family, the simulator widget updates within seconds of an in-app change, and a Live Activity accepts update and end pushes on a real device.

The common mistake it prevents

Forgetting the App Group entitlement on one target, so the shared store silently reads empty defaults and the widget never shows real data.

~/.claude/skills/ios-widgets/SKILL.md

--- name: ios-widgets description: Builds iOS widgets, Live Activities, and controls with WidgetKit, ActivityKit, and App Intents — TimelineProvider and… license: MIT ---

Done well means: the widget renders instantly from data the app already wrote to a shared App Group container, timelines are built from known future dates instead of frequent reloads, every family the widget declares has a real layout, interactions run through App Intents, and Live Activities start, update, and end through ActivityKit with server pushes that respect the payload and duration limits.

Before changing anything

  • Check the deployment target. Baseline APIs here assume iOS 17+ (interactive widgets, AppIntentConfiguration, containerBackground). Controls need iOS 18; widget push updates and Liquid Glass accented rendering arrive with iOS 26.
  • Confirm targets: app, widget extension, and that both share an App Group (group.com.acme.app) and the same Keychain access group if tokens are needed.
  • Find the data the widget needs and who produces it. Widgets cannot run long work, open sockets, or keep state between renders; everything comes from the timeline entry.
  • Look for an existing AppIntent set; widgets, Shortcuts, Siri, Spotlight, and controls can share them.

Workflow

- [ ] 1. Define the entry model (small, Codable, already formatted where possible)
- [ ] 2. App writes the snapshot to the App Group, then calls WidgetCenter reload
- [ ] 3. Provider: placeholder (static), snapshot (sample or cached), timeline (future entries)
- [ ] 4. Views per supported family, containerBackground, accented rendering checked
- [ ] 5. Interactions: Button(intent:) / Toggle(isOn:intent:) with AppIntent
- [ ] 6. Configuration: AppIntentConfiguration + WidgetConfigurationIntent if user-configurable
- [ ] 7. Live Activity (if needed): attributes, content state, Lock Screen + Dynamic Island
- [ ] 8. Push: ActivityKit tokens to server; APNs liveactivity payloads
- [ ] 9. Test in previews, simulator gallery, real device Lock Screen and StandBy

Pick the surface

NeedUse
Glanceable state on Home/Lock Screen, updates every 15+ min or at known timesWidget with timeline
Real-time progress for a bounded event (delivery, ride, match, timer)Live Activity (max ~8 h active, then up to 4 h on Lock Screen)
One-tap action from Control Center, Lock Screen, Action buttonControl widget (iOS 18+)
Act without opening the app (mark done, play, toggle)Button(intent:) inside a widget
User picks which item/account the widget showsAppIntentConfiguration with parameters

Shared data

enum SharedStore {
    static let suite = UserDefaults(suiteName: "group.com.acme.app")!
    static let url = FileManager.default
        .containerURL(forSecurityApplicationGroupIdentifier: "group.com.acme.app")!
        .appending(path: "widget-snapshot.json")

    static func write(_ snapshot: WidgetSnapshot) throws {
        try JSONEncoder().encode(snapshot).write(to: url, options: .atomic)
        WidgetCenter.shared.reloadTimelines(ofKind: "TasksWidget")
    }
    static func read() -> WidgetSnapshot? {
        (try? Data(contentsOf: url)).flatMap { try? JSONDecoder().decode(WidgetSnapshot.self, from: $0) }
    }
}
  • Write after every user-visible change in the app; that is the cheapest, freshest path.
  • SwiftData or Core Data: put the store file in the App Group container and open it read-only in the extension; keep the widget query small.
  • Keep the extension under its memory limit (~30 MB). Downscale images in the app before writing them to the group container; never load full-size photos in a widget.

Timeline provider

struct TasksEntry: TimelineEntry {
    let date: Date
    let snapshot: WidgetSnapshot
}

struct TasksProvider: AppIntentTimelineProvider {
    func placeholder(in context: Context) -> TasksEntry { .init(date: .now, snapshot: .sample) }

    func snapshot(for config: SelectListIntent, in context: Context) async -> TasksEntry {
        .init(date: .now, snapshot: context.isPreview ? .sample : (SharedStore.read() ?? .sample))
    }

    func timeline(for config: SelectListIntent, in context: Context) async -> Timeline<TasksEntry> {
        let snap = SharedStore.read() ?? .empty
        // One entry per known change (due times), not one per minute.
        let dates = [Date.now] + snap.upcomingDueDates.prefix(10)
        let entries = dates.map { TasksEntry(date: $0, snapshot: snap.asOf($0)) }
        return Timeline(entries: entries, policy: .after(.now.addingTimeInterval(60 * 60)))
    }
}
  • Reload budget: a frequently viewed widget gets roughly 40-70 refreshes per day; reloads triggered while the app is in the foreground do not count. Do not schedule .after less than 15 minutes out and expect it to be honoured.
  • For clocks and countdowns use Text(date, style: .timer) / .relative or Text(timerInterval:); they tick without timeline entries.
  • Network fetch inside timeline is allowed but has a short time budget; cache the result to the group container and fall back to it on failure.

Widget definition and views

struct TasksWidget: Widget {
    var body: some WidgetConfiguration {
        AppIntentConfiguration(kind: "TasksWidget", intent: SelectListIntent.self,
                               provider: TasksProvider()) { entry in
            TasksWidgetView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("Tasks")
        .description("Upcoming tasks from a list.")
        .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
    }
}

struct TasksWidgetView: View {
    @Environment(\.widgetFamily) private var family
    @Environment(\.widgetRenderingMode) private var renderingMode
    let entry: TasksEntry

    var body: some View {
        switch family {
        case .accessoryInline: Text("\(entry.snapshot.dueToday) due today")
        case .accessoryRectangular: CompactList(entry: entry)
        default:
            VStack(alignment: .leading) {
                ForEach(entry.snapshot.tasks.prefix(family == .systemSmall ? 2 : 4)) { task in
                    Button(intent: CompleteTaskIntent(taskID: task.id)) {
                        Label(task.title, systemImage: task.done ? "checkmark.circle.fill" : "circle")
                    }
                    .buttonStyle(.plain)
                }
            }
            .widgetURL(URL(string: "acme://lists/\(entry.snapshot.listID)"))
        }
    }
}
  • containerBackground(for: .widget) is required from iOS 17; without it the gallery shows a “please adopt containerBackground” placeholder.
  • Accessory families and accented/vibrant modes render monochrome: check widgetRenderingMode, use .widgetAccentable() on the parts that should tint, and set widgetAccentedRenderingMode on images (desaturated for most, full color for album art).
  • widgetURL applies to the whole small widget; medium and large can use Link per row.
  • Use .invalidatableContent() on values an interaction changes so the system shows a pending state until the new timeline arrives.

App Intents for interactions

struct CompleteTaskIntent: AppIntent {
    static let title: LocalizedStringResource = "Complete Task"
    @Parameter(title: "Task ID") var taskID: String

    init() {}
    init(taskID: String) { self.taskID = taskID }

    func perform() async throws -> some IntentResult {
        try await TaskRepository.shared.complete(id: taskID)   // writes App Group store
        return .result()                                       // WidgetKit reloads timelines after perform
    }
}
  • The intent runs in the widget extension process by default; shared code and storage must be in a framework or package both targets link.
  • To run in the app process (e.g. audio), conform to the appropriate protocol such as AudioPlaybackIntent or ForegroundContinuableIntent; check availability per OS.
  • Configurable widgets: WidgetConfigurationIntent with @Parameter of an AppEntity type and an EntityQuery that reads from the shared store.

Live Activities

struct DeliveryAttributes: ActivityAttributes {
    struct ContentState: Codable, Hashable { var status: String; var eta: Date }
    let orderID: String
}

// Start (app in foreground), request a push token for server updates
let activity = try Activity.request(
    attributes: DeliveryAttributes(orderID: order.id),
    content: .init(state: .init(status: "Preparing", eta: eta), staleDate: eta.addingTimeInterval(900)),
    pushType: .token)
Task { for await token in activity.pushTokenUpdates { await api.register(liveActivityToken: token, order: order.id) } }

// Widget extension
ActivityConfiguration(for: DeliveryAttributes.self) { context in
    LockScreenDeliveryView(state: context.state)          // Lock Screen / banner
} dynamicIsland: { context in
    DynamicIsland {
        DynamicIslandExpandedRegion(.bottom) { DeliveryProgress(state: context.state) }
    } compactLeading: { Image(systemName: "bag") }
      compactTrailing: { Text(context.state.eta, style: .timer).monospacedDigit() }
      minimal: { Image(systemName: "bag") }
}
  • Add NSSupportsLiveActivities = YES to the app Info.plist; check ActivityAuthorizationInfo().areActivitiesEnabled before offering the feature.
  • Content state payloads must stay under 4 KB. Send IDs and short strings, not images.
  • Push updates: APNs with apns-push-type: liveactivity, topic <bundle-id>.push-type.liveactivity, body {"aps":{"timestamp":<unix>,"event":"update","content-state":{...},"stale-date":<unix>}}; "event":"end" with "dismissal-date" to end. Keys in content-state must match the Swift property names exactly or decoding silently fails.
  • Push-to-start (iOS 17.2+): collect Activity<DeliveryAttributes>.pushToStartTokenUpdates and send "event":"start" with attributes-type and attributes.
  • Use priority 5 for routine updates; priority 10 has a per-hour budget and throttles.
  • End activities when the event ends (activity.end(_:dismissalPolicy:)), including on app launch for orphaned ones from Activity<...>.activities.

Controls (iOS 18+)

ControlWidget with StaticControlConfiguration or AppIntentControlConfiguration, containing ControlWidgetButton(action:) or ControlWidgetToggle(isOn:action:) backed by an AppIntent / SetValueIntent. Reload with ControlCenter.shared.reloadControls(ofKind:).

Gotchas

  • Shared UserDefaults(suiteName:) returns a separate store if the App Group is missing from either target’s entitlements; it fails silently, the widget just shows defaults.
  • Placeholder must not touch disk or network; it is used for redacted rendering.
  • Code that compiles in the app may not be available in the extension (UIApplication.shared).
  • Debug widgets by selecting the widget scheme and setting _XCWidgetKind / _XCWidgetFamily environment variables, or use #Preview(as: .systemSmall) with a timeline of entries.
  • Widget timelines are cached across app updates; bump the snapshot schema with a version field and handle old data.
  • Live Activity tokens rotate; always handle pushTokenUpdates as a stream and update the server, never cache the first token forever.

Verification

  1. #Preview(as: .systemMedium) { TasksWidget() } timeline: { entries } for every family.
  2. Simulator: add the widget from the gallery, change data in the app, confirm the widget updates within seconds of reloadTimelines.
  3. Real device: Lock Screen accessory families, StandBy, tinted/clear Home Screen modes.
  4. Live Activity: start from the app, send real APNs pushes to the logged token through Apple’s Push Notifications Console or a small APNs script (sandbox environment for debug builds), confirm update, stale, and end states and Dynamic Island compact/expanded.
  5. Interaction: tap the intent button with the app killed; the state must change and persist after relaunch.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc