swiftcnearly preview
Component library

Toast

Native toast composition, package imports, and editable Swift source.

Preview

Toast rendered by native SwiftUI.Toast rendered by native SwiftUI. Dark appearance.
Captured from the native SwiftUI catalog on macOS.

Use it

Import SwiftCN to use CNToastHost directly. This complete example also runs in the native component gallery.

ToastExample.swift
import SwiftUI
import SwiftCN

struct ToastExample: View {
    @State private var toasts: [CNToast] = []

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            CNToastHost(toasts: $toasts) {
                CNButton("Save changes", action: { toasts.append(CNToast(title: "Saved", message: "Your changes are up to date.")) })
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
            }.tw("h-[180]")

        }
    }
}

Native behavior

Use CNToastHost(toasts: $toasts) for a layered deck, or keep CNToastHost(toast: $toast) for one notification. Append notifications with unique, stable IDs. The newest sits in front; two earlier cards peek out behind it. The notification count button expands the deck into a bounded native scroll view for individual dismissal. Pass isExpanded: $expanded to control expansion from your app. Expansion preserves card identity and pauses all countdowns while reading. Each toast has its own cancellable deadline. Hover, keyboard focus, and accessibility focus pause its countdown without resetting the remaining time. Updating a toast restarts only its deadline. Nil duration requires explicit dismissal. Native accessibility announcements expose notifications without moving editing focus. The caller owns the array and can limit its size or remove a notification by ID. Use .toastContent { context in ... } for custom card content and context.action for native action buttons that pause the countdown while focused. See the complete observable-model example in native composition.

Style and compose

Visual recipes use .tw classes. Append classes: to override a recipe locally, or change the recipe in TWGlobalRules.named for your subtree. Native layout and interaction modifiers remain SwiftUI APIs. For control colors, append cn-tint-[primary], another theme token, or an interpolated Color.

Motion recipes are editable too: control-motion uses a 150 ms smooth response, disclosure-motion uses a 250 ms spring, and feedback-motion uses a 200 ms smooth response. Set a recipe to animate-none in your global rules to turn it off. Presets respect Reduce Motion. Static components stay still; native scrolling, drag tracking, and platform presentation keep their own gestures.

Action groups and card, dialog, and questionnaire footers stack when their content does not fit. The native layout preserves the same child controls during this change.

View builders preserve native child content. Native Button roles, labels, keyboard shortcuts, submit actions, and presentation modifiers still work. A string recipe does not replace a native control API or a typed chart mark.

Own the source

Copy the complete file below into your app alongside import SwiftCN. The file contains related composable parts from the same family. Its dependencies use public package APIs, so copied and imported components can coexist.

To own the styling engine too, download the complete source bundle. It contains every component, the .tw engine, its recipes, and the MIT license. Keep those files in one module and remove import SwiftCN from the usage example.

Toast.swift
import SwiftUI
import SwiftCN
import Accessibility

public struct CNToast: Identifiable, Equatable, Sendable {
    public let id: UUID
    public var title: String
    public var message: String?
    /// Nil keeps the toast until the user dismisses it. Seconds must be finite and positive.
    public var duration: TimeInterval?
    public init(id: UUID = UUID(), title: String, message: String? = nil, duration: TimeInterval? = 5) {
        precondition(duration == nil || (duration!.isFinite && duration! > 0))
        self.id = id; self.title = title; self.message = message; self.duration = duration
    }
}
/// Per-window notifications with stable, unique IDs. Append to show a stack, or use one optional toast.
public struct CNToastHost<Content: View>: View {
    @Binding private var toasts: [CNToast]
    @Binding private var boundExpansion: Bool
    private let usesLocalExpansion: Bool
    private let classes: TWClasses
    private let content: Content
    public init(toast: Binding<CNToast?>, classes: TWClasses = "", @ViewBuilder content: () -> Content) {
        _toasts = Binding(get: { toast.wrappedValue.map { [$0] } ?? [] },
                          set: { toast.wrappedValue = $0.first })
        _boundExpansion = .constant(false); usesLocalExpansion = true
        self.classes = classes; self.content = content()
    }
    /// The newest toast sits in front. Expand the deck to read and dismiss earlier messages.
    public init(toasts: Binding<[CNToast]>, isExpanded: Binding<Bool>? = nil,
                classes: TWClasses = "", @ViewBuilder content: () -> Content) {
        _toasts = toasts; _boundExpansion = isExpanded ?? .constant(false); usesLocalExpansion = isExpanded == nil
        self.classes = classes; self.content = content()
    }
    public var body: some View {
        host { context in CNToastMessage(toast: context.toast) }
    }
    /// Replace the message content while retaining stacking, dismissal, and deadlines.
    /// Use context.action for native actions that pause the deadline while focused.
    public func toastContent<Message: View>(@ViewBuilder _ message: @escaping (CNToastContentContext) -> Message) -> some View {
        host(message: message)
    }
    private func host<Message: View>(@ViewBuilder message: @escaping (CNToastContentContext) -> Message) -> some View {
        CNToastHostBody(toasts: $toasts, boundExpansion: $boundExpansion, usesLocalExpansion: usesLocalExpansion,
                        classes: classes, content: content, message: message)
    }
}

private struct CNToastHostBody<Content: View, Message: View>: View {
    @Environment(\.layoutDirection) private var direction
    @Binding var toasts: [CNToast]
    @Binding var boundExpansion: Bool
    let usesLocalExpansion: Bool
    @State private var localExpansion = false
    @FocusState private var expansionFocused: Bool
    @AccessibilityFocusState private var expansionAccessible: Bool
    let classes: TWClasses
    let content: Content
    let message: (CNToastContentContext) -> Message
    private var expansion: Binding<Bool> { usesLocalExpansion ? $localExpansion : $boundExpansion }
    public var body: some View {
        let expanded = expansion.wrappedValue
        let layout = expanded ? AnyLayout(VStackLayout(alignment: .trailing, spacing: 8)) : AnyLayout(CNToastDeckLayout())
        content.overlay(alignment: .bottomTrailing) {
            GeometryReader { geometry in
                let region = CNPlacementRegions.preferred(in: geometry.cnPlacementRegions(layoutDirection: direction),
                    near: CGPoint(x: direction == .leftToRight ? geometry.size.width : 0, y: geometry.size.height))
                VStack(alignment: .trailing, spacing: 8) {
                    ScrollView {
                        layout {
                            ForEach(Array(toasts.enumerated()), id: \.element.id) { index, current in
                                let depth = toasts.count - index - 1
                                CNToastCard(toast: current, classes: classes, concealed: !expanded && depth > 0,
                                            reading: expanded || expansionFocused || expansionAccessible, dismiss: dismiss, message: message)
                                    .scaleEffect(expanded ? 1 : 1 - CGFloat(min(depth, 2)) * 0.04, anchor: .bottom)
                                    .opacity(expanded || depth < 3 ? 1 : 0)
                                    .allowsHitTesting(expanded || depth == 0)
                                    .accessibilityHidden(!expanded && depth > 0)
                                    .zIndex(Double(index))
                            }
                        }
                        .tw("p-2")
                    }
                    .defaultScrollAnchor(.bottom)
                    .scrollBounceBehavior(.basedOnSize)
                    .frame(maxHeight: max(0, region.height - (toasts.count > 1 ? expansionControlHeight + 8 : 0)))
                    .fixedSize(horizontal: false, vertical: true)
                    if toasts.count > 1 {
                        Button { expansion.wrappedValue.toggle() } label: {
                            Label(expanded ? "Collapse" : "\(toasts.count) notifications",
                                  systemImage: expanded ? "chevron.down" : "chevron.up")
                        }
                        .focusable().focusEffectDisabled().focused($expansionFocused)
                        .buttonStyle(.tw("button-ghost w-[160] min-h-[\(expansionControlHeight)] px-2 py-1 text-xs",
                                         state: .init(isFocused: expansionFocused)))
                        .accessibilityLabel(expanded ? "Collapse notifications" : "Show all \(toasts.count) notifications")
                        .accessibilityFocused($expansionAccessible)
                    }
                }
                .twAnimation(cn("feedback-motion", classes), value: CNToastPresentation(ids: toasts.map(\.id), expanded: expanded))
                .frame(maxWidth: min(360, region.width))
                .fixedSize(horizontal: false, vertical: true)
                .frame(width: region.width, height: region.height, alignment: .bottomTrailing)
                .position(x: region.midX, y: region.midY)
            }
            .allowsHitTesting(!toasts.isEmpty)
            .accessibilityHidden(toasts.isEmpty)
        }
        .onChange(of: toasts.count) { _, count in if count < 2 { expansion.wrappedValue = false } }
    }
    private var expansionControlHeight: CGFloat {
        #if os(iOS)
        44
        #else
        28
        #endif
    }
    // A deadline from an older render cannot remove an updated notification with the same ID.
    private func dismiss(_ toast: CNToast) { toasts.removeAll { $0 == toast } }
}

private struct CNToastPresentation: Equatable {
    let ids: [UUID]
    let expanded: Bool
}

/// One layout change preserves every card's identity and its countdown.
private struct CNToastDeckLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        guard let front = subviews.last else { return .zero }
        let size = front.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil))
        return CGSize(width: size.width, height: size.height + CGFloat(min(subviews.count - 1, 2)) * 8)
    }
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let height = max(0, bounds.height - CGFloat(min(max(0, subviews.count - 1), 2)) * 8)
        for index in subviews.indices {
            let depth = min(subviews.count - index - 1, 2)
            subviews[index].place(at: CGPoint(x: bounds.midX, y: bounds.maxY - CGFloat(depth) * 8),
                                 anchor: .bottom, proposal: ProposedViewSize(width: bounds.width, height: height))
        }
    }
}

/// The clock preserves remaining time through hover and focus, without polling or a shared timer.
struct CNToastLifetime {
    private(set) var remaining: Duration?
    private(set) var deadline: ContinuousClock.Instant?
    mutating func reset(duration: TimeInterval?, paused: Bool, at now: ContinuousClock.Instant) {
        remaining = duration.flatMap { $0.isFinite && $0 > 0 ? .seconds($0) : nil }
        deadline = paused ? nil : remaining.map { now.advanced(by: $0) }
    }
    mutating func pause(at now: ContinuousClock.Instant) {
        guard let deadline else { return }
        remaining = max(.zero, now.duration(to: deadline))
        self.deadline = nil
    }
    mutating func resume(at now: ContinuousClock.Instant) {
        guard deadline == nil, let remaining else { return }
        deadline = now.advanced(by: remaining)
    }
}

private enum CNToastFocus: Hashable { case message, dismiss }

/// A toast supplies its current value and a dismissal action to custom content.
@MainActor public struct CNToastContentContext {
    public let toast: CNToast
    public let dismiss: () -> Void
    /// Custom native controls can share this focus binding using their own stable UUID.
    public let focus: FocusState<UUID?>.Binding
    public let accessibilityFocus: AccessibilityFocusState<UUID?>.Binding

    public func action(_ title: LocalizedStringKey, role: ButtonRole? = nil, classes: TWClasses = "",
                       action: @escaping () -> Void) -> some View {
        self.action(role: role, classes: classes, action: action) { Text(title) }
    }
    /// A native button with automatic keyboard and accessibility lifetime pausing.
    public func action<Label: View>(role: ButtonRole? = nil, classes: TWClasses = "",
                                   action: @escaping () -> Void, @ViewBuilder label: () -> Label) -> some View {
        CNToastAction(role: role, classes: classes, action: action, label: label(),
                      focus: focus, accessibilityFocus: accessibilityFocus)
    }
}
private struct CNToastAction<Label: View>: View {
    let role: ButtonRole?
    let classes: TWClasses
    let action: () -> Void
    let label: Label
    let focus: FocusState<UUID?>.Binding
    let accessibilityFocus: AccessibilityFocusState<UUID?>.Binding
    @State private var id = UUID()
    var body: some View {
        Button(role: role, action: action) { label }
            .focusable().focusEffectDisabled().focused(focus, equals: id)
            .buttonStyle(.tw(cn("button-outline", classes), state: .init(isFocused: focus.wrappedValue == id)))
            .accessibilityFocused(accessibilityFocus, equals: id)
    }
}
private struct CNToastMessage: View {
    let toast: CNToast
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(toast.title).tw("text-sm font-semibold")
            if let message = toast.message { Text(message).tw("text-sm text-mutedForeground") }
        }.accessibilityElement(children: .combine)
    }
}

private struct CNToastCard<Message: View>: View {
    let toast: CNToast
    let classes: TWClasses
    let concealed: Bool
    let reading: Bool
    let dismiss: (CNToast) -> Void
    let message: (CNToastContentContext) -> Message
    @State private var lifetime = CNToastLifetime()
    @State private var hovered = false
    @FocusState private var closeFocused: Bool
    @AccessibilityFocusState private var accessibleFocus: CNToastFocus?
    @FocusState private var actionFocused: UUID?
    @AccessibilityFocusState private var actionAccessible: UUID?
    private var paused: Bool {
        reading || hovered || closeFocused || accessibleFocus != nil || actionFocused != nil || actionAccessible != nil
    }
    var body: some View {
        let deadline = lifetime.deadline
        HStack(alignment: .top, spacing: 12) {
            message(CNToastContentContext(toast: toast, dismiss: { dismiss(toast) },
                                          focus: $actionFocused, accessibilityFocus: $actionAccessible))
            .frame(maxWidth: .infinity, alignment: .leading)
            .accessibilityFocused($accessibleFocus, equals: .message)
            Button { dismiss(toast) } label: { Image(systemName: "xmark") }
                .accessibilityLabel("Dismiss notification: \(toast.title)")
                .focusable(!concealed).focusEffectDisabled()
                .focused($closeFocused)
                .buttonStyle(.tw(cn("button-ghost", CNButtonSize.icon.classes), state: .init(isFocused: closeFocused)))
                .accessibilityFocused($accessibleFocus, equals: .dismiss)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
        .opacity(concealed ? 0 : 1)
        .tw(cn("toast max-w-[360]", classes))
        .transition(.opacity.combined(with: .move(edge: .bottom)))
        .accessibilityElement(children: .contain)
        .disabled(concealed)
        .onHover { hovered = $0 }
        .onChange(of: concealed) { _, concealed in
            if concealed { hovered = false; closeFocused = false; accessibleFocus = nil; actionFocused = nil; actionAccessible = nil }
        }
        .onChange(of: toast, initial: true) { _, current in
            lifetime.reset(duration: current.duration, paused: paused, at: .now)
            AccessibilityNotification.Announcement([current.title, current.message].compactMap { $0 }.joined(separator: ". ")).post()
        }
        .onChange(of: paused) { _, paused in
            if paused { lifetime.pause(at: .now) } else { lifetime.resume(at: .now) }
        }
        .task(id: deadline) {
            guard let deadline else { return }
            do { try await ContinuousClock().sleep(until: deadline) } catch { return }
            guard !Task.isCancelled, lifetime.deadline == deadline else { return }
            dismiss(toast)
        }
    }
}

On this page