Floating Reactions Emitter
Emoji that float up from the tap point with a random drift and fade out.

- Effects
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- reactions · emoji · floating · live · hearts · tap · particles · reacciones · emoji · flotante · directo · corazones · toque · partículas
Description
Section titled “Description”Wraps content so that a tap sends an emoji floating up from that point.
With Reduce Motion on, the emoji stays where it was tapped and fades out.
struct FloatingReactionsEmitterDemo: View { var body: some View { FloatingReactionsEmitter { ZStack { LinearGradient(colors: [.purple, .pink, .orange], startPoint: .top, endPoint: .bottom)
VStack(spacing: 14) { Image(systemName: "dot.radiowaves.left.and.right") .font(.largeTitle) .accessibilityHidden(true)
Text("Live now") .font(.system(.largeTitle, design: .rounded, weight: .bold))
Text("Tap anywhere to react") .font(.body) .opacity(0.85) } .foregroundStyle(.white) .multilineTextAlignment(.center) .padding(24) } } .ignoresSafeArea() }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
emojis | [String] | ["❤️", "🔥", "👏", "😂", "🎉"] |
lifetime | Double | 1.8 |
rise | CGFloat | 320 |
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
enum FloatingReactionsEmitterMath { /// How far a reaction has moved from its origin, weaving side to side as it rises. static func offset(progress: Double, rise: CGFloat, drift: CGFloat, swayPhase: Double) -> CGSize { let clamped = min(max(progress, 0), 1) let sway = sin(clamped * 3 * .pi + swayPhase) * 16 * clamped return CGSize(width: drift * clamped + sway, height: -rise * clamped) }
/// Solid for most of the way, then fading out. static func opacity(progress: Double) -> Double { let clamped = min(max(progress, 0), 1) return clamped < 0.6 ? 1 : (1 - clamped) / 0.4 }
static func scale(progress: Double) -> Double { 0.4 + 0.6 * min(max(progress, 0) / 0.15, 1) }}
private struct FloatingReactionsEmitterReaction: Identifiable { let id = UUID() let emoji: String let origin: CGPoint let start: Date let drift: CGFloat let swayPhase: Double}
/// Wraps content so that a tap sends an emoji floating up from that point.////// With Reduce Motion on, the emoji stays where it was tapped and fades out.struct FloatingReactionsEmitter<Content: View>: View { var emojis: [String] = ["❤️", "🔥", "👏", "😂", "🎉"] var lifetime: Double = 1.8 var rise: CGFloat = 320 @ViewBuilder var content: () -> Content
@State private var reactions: [FloatingReactionsEmitterReaction] = [] @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.layoutDirection) private var layoutDirection
private let maximumReactions = 40 private var duration: Double { lifetime > 0 ? lifetime : 1 }
var body: some View { GeometryReader { proxy in content() .frame(width: proxy.size.width, height: proxy.size.height) .contentShape(.rect) .gesture(SpatialTapGesture().onEnded { value in // Gestures report screen points, but `position` mirrors in right-to-left layouts. let x = layoutDirection == .rightToLeft ? proxy.size.width - value.location.x : value.location.x emit(from: CGPoint(x: x, y: value.location.y)) }) .overlay { TimelineView(.animation(paused: reactions.isEmpty)) { timeline in ZStack { ForEach(reactions) { reaction in bubble(for: reaction, at: timeline.date) } } } .allowsHitTesting(false) .accessibilityHidden(true) } .overlay(alignment: .bottom) { Color.clear .frame(height: 44) .allowsHitTesting(false) .accessibilityElement() .accessibilityLabel("Send reaction") .accessibilityAddTraits(.isButton) .accessibilityAction { emit(from: CGPoint(x: proxy.size.width / 2, y: proxy.size.height * 0.75)) } } } .task(id: reactions.last?.id) { guard !reactions.isEmpty else { return }
try? await Task.sleep(for: .seconds(duration + 0.1)) guard !Task.isCancelled else { return }
let now = Date.now reactions.removeAll { now.timeIntervalSince($0.start) >= duration } } }
private func bubble(for reaction: FloatingReactionsEmitterReaction, at date: Date) -> some View { let progress = date.timeIntervalSince(reaction.start) / duration let offset = reduceMotion ? .zero : FloatingReactionsEmitterMath.offset(progress: progress, rise: rise, drift: reaction.drift, swayPhase: reaction.swayPhase)
return Text(reaction.emoji) .font(.largeTitle) .scaleEffect(reduceMotion ? 1 : FloatingReactionsEmitterMath.scale(progress: progress)) .opacity(FloatingReactionsEmitterMath.opacity(progress: progress)) .position(x: reaction.origin.x + offset.width, y: reaction.origin.y + offset.height) }
private func emit(from location: CGPoint) { guard let emoji = emojis.randomElement() else { return }
reactions.append( FloatingReactionsEmitterReaction( emoji: emoji, origin: location, start: .now, drift: .random(in: -70...70), swayPhase: .random(in: 0...(2 * .pi)) ) ) if reactions.count > maximumReactions { reactions.removeFirst(reactions.count - maximumReactions) } }}
// MARK: - Demo
struct FloatingReactionsEmitterDemo: View { var body: some View { FloatingReactionsEmitter { ZStack { LinearGradient(colors: [.purple, .pink, .orange], startPoint: .top, endPoint: .bottom)
VStack(spacing: 14) { Image(systemName: "dot.radiowaves.left.and.right") .font(.largeTitle) .accessibilityHidden(true)
Text("Live now") .font(.system(.largeTitle, design: .rounded, weight: .bold))
Text("Tap anywhere to react") .font(.body) .opacity(0.85) } .foregroundStyle(.white) .multilineTextAlignment(.center) .padding(24) } } .ignoresSafeArea() }}
#Preview { FloatingReactionsEmitterDemo()}