Components PulseToast
PulseToast
Transient feedback, artfully done.
interactive demo
Saved
Done.
Failed
Try again.
Heads up
Synced in background.
A live browser stand-in using the exact tokens, motion and API of the SwiftUI component.
Want all components at once?
Open the studio
Transient feedback, artfully done.
Sliding toasts with icons, tones and a graceful close animation. Feeds naturally into PulseToastCenter for queue management.
Success / error / warning / info entries
Auto-dismiss + duration control
Slide-in with spring
Works inside PulseToastCenter
Usage
PulseToast(entry: ToastEntry(
type: .success, title: "Saved", message: "Done.", tone: .success
))
PulseToastCenter().overlay() Install
$ pulse add toast Source files
Sources/PulseUI/Components/PulseToast/PulseToast.swift
Full source Sources/PulseUI/Components/PulseToast/PulseToast.swift
import SwiftUI
// MARK: - PulseToast
/// Ephemeral notification system with queue management and auto-dismiss.
/// Usage: `.pulseToast()` on the root view, then `PulseToastManager.shared.show(...)`.
public struct PulseToast: View {
@Environment(\.pulseTheme) private var theme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isAnimating = false
let entry: ToastEntry
let onDismiss: () -> Void
public init(
entry: ToastEntry,
appearImmediately: Bool = false,
onDismiss: @escaping () -> Void = {}
) {
self.entry = entry
self.onDismiss = onDismiss
self._isAnimating = State(initialValue: appearImmediately)
}
public var body: some View {
HStack(spacing: theme.spacing.sm) {
if let icon = entry.icon {
Image(systemName: icon)
.font(.subheadline.weight(.semibold))
.foregroundStyle(toneColor)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
if let title = entry.title {
Text(title)
.font(theme.typography.callout.font)
.fontWeight(.semibold)
.foregroundStyle(theme.colors.foreground)
.lineLimit(1)
}
if let message = entry.message {
Text(message)
.font(theme.typography.footnote.font)
.foregroundStyle(theme.colors.foregroundSecondary)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if entry.showsCloseButton {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.caption.weight(.medium))
.foregroundStyle(theme.colors.foregroundTertiary)
.frame(width: 24, height: 24)
}
.buttonStyle(PulseIconButtonStyle())
.accessibilityLabel("Dismiss notification")
}
}
.padding(.horizontal, theme.spacing.md)
.padding(.vertical, theme.spacing.sm)
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: toastRadius, style: .continuous)
.stroke(theme.colors.border.opacity(0.4), lineWidth: 0.5)
)
.clipShape(RoundedRectangle(cornerRadius: toastRadius, style: .continuous))
.shadow(color: theme.colors.foreground.opacity(0.12), radius: 16, y: 8)
.scaleEffect(isAnimating ? 1 : 0.92)
.opacity(isAnimating ? 1 : 0)
.offset(y: isAnimating ? 0 : 16)
.onAppear {
guard !reduceMotion else {
isAnimating = true
return
}
withAnimation(theme.motion.springBouncy) {
isAnimating = true
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel(entry.title ?? entry.message ?? "Notification")
}
private func dismiss() {
withAnimation(theme.motion.springSnappy) {
isAnimating = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + (reduceMotion ? 0 : 0.25)) {
onDismiss()
}
}
private var toastRadius: CGFloat { theme.radius.lg }
private var backgroundColor: Color {
if let tone = entry.tone {
return toneColor(tone).opacity(0.12)
}
return theme.colors.surfaceElevated
}
private var toneColor: Color {
entry.tone.map { toneColor($0) } ?? theme.colors.accent
}
private func toneColor(_ tone: PulseTone) -> Color {
switch tone {
case .neutral: return theme.colors.secondary
case .primary: return theme.colors.primary
case .accent: return theme.colors.accent
case .success: return theme.colors.success
case .warning: return theme.colors.warning
case .error: return theme.colors.error
case .info: return theme.colors.info
}
}
}
// MARK: - Toast Entry
public struct ToastEntry: Identifiable, Equatable {
public let id: UUID
public let type: ToastType
public let title: String?
public let message: String?
public let icon: String?
public let tone: PulseTone?
public let duration: TimeInterval
public let showsCloseButton: Bool
public init(
type: ToastType = .regular,
title: String? = nil,
message: String? = nil,
icon: String? = nil,
tone: PulseTone? = nil,
duration: TimeInterval = 3.0,
showsCloseButton: Bool = true
) {
self.id = UUID()
self.type = type
self.title = title
self.message = message
self.icon = icon ?? Self.defaultIcon(for: tone ?? .info)
self.tone = tone
self.duration = duration
self.showsCloseButton = showsCloseButton
}
public enum ToastType: Equatable {
case regular
case success
case error
case warning
case info
}
private static func defaultIcon(for tone: PulseTone) -> String {
switch tone {
case .success: return "checkmark.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .error: return "xmark.circle.fill"
case .info: return "info.circle.fill"
case .accent: return "sparkles"
default: return "bell.fill"
}
}
}
// MARK: - Toast View Modifier
public struct PulseToastModifier: ViewModifier {
@State private var toast: ToastEntry?
@State private var isPresented = false
public func body(content: Content) -> some View {
content
.overlay(alignment: .bottom) {
if let toast {
VStack {
PulseToast(entry: toast) {
dismissToast()
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
.zIndex(100)
}
}
.onAppear {
PulseToastCenter.shared.onShow = { newToast in
let existing = toast
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
self.toast = newToast
}
if let existing {
PulseToastCenter.shared.pending.append(existing)
}
isPresented = true
scheduleDismiss(for: newToast)
}
}
}
private func scheduleDismiss(for entry: ToastEntry) {
DispatchQueue.main.asyncAfter(deadline: .now() + entry.duration) {
if self.toast?.id == entry.id {
dismissToast()
}
}
}
private func dismissToast() {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
toast = nil
}
if !PulseToastCenter.shared.pending.isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
let next = PulseToastCenter.shared.pending.removeFirst()
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
self.toast = next
}
scheduleDismiss(for: next)
}
}
}
}
public extension View {
/// Attach the toast presentation system to your root view.
func pulseToast() -> some View {
modifier(PulseToastModifier())
}
}
// MARK: - Toast Center
@MainActor
public final class PulseToastCenter {
public static let shared = PulseToastCenter()
var pending: [ToastEntry] = []
var onShow: ((ToastEntry) -> Void)?
public func show(_ entry: ToastEntry) {
onShow?(entry)
}
public func show(title: String?, message: String? = nil, tone: PulseTone, duration: TimeInterval = 3) {
show(ToastEntry(title: title, message: message, tone: tone, duration: duration))
}
}