Touch Particle Trail
A trail of fading particles that follows the finger while it drags.

- Effects
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- particles · trail · touch · finger · drag · sparkles · canvas · partículas · estela · toque · dedo · arrastrar · destellos · lienzo
Description
Section titled “Description”Leaves a trail of fading particles behind the finger while it drags over the content.
The content keeps receiving its own touches. With Reduce Motion on, the particles stay where they were left and only fade.
struct TouchParticleTrailDemo: View { var body: some View { TouchParticleTrail { ZStack { LinearGradient( colors: [Color(red: 0.05, green: 0.03, blue: 0.16), .black], startPoint: .top, endPoint: .bottom )
VStack(spacing: 10) { Image(systemName: "hand.draw.fill") .font(.largeTitle) .accessibilityHidden(true) Text("Drag anywhere") .font(.title2.bold()) } .foregroundStyle(.white.opacity(0.75)) } .ignoresSafeArea() } .ignoresSafeArea() }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
colors | [Color] | [.cyan, .purple, .pink] |
lifetime | Double | 0.9 |
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
/// Leaves a trail of fading particles behind the finger while it drags over the content.////// The content keeps receiving its own touches. With Reduce Motion on, the/// particles stay where they were left and only fade.struct TouchParticleTrail<Content: View>: View { var colors: [Color] = [.cyan, .purple, .pink] var lifetime = 0.9 @ViewBuilder var content: () -> Content
@State private var emitter = TouchParticleTrailEmitter() @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { content() .overlay { TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: emitter.particles.isEmpty)) { context in Canvas { canvas, _ in canvas.blendMode = .plusLighter let palette = colors.isEmpty ? [Color.white] : colors for particle in emitter.particles { guard let frame = particle.frame(at: context.date) else { continue } let color = palette[particle.colorIndex % palette.count] let glow = frame.rect.insetBy(dx: -frame.rect.width * 0.8, dy: -frame.rect.height * 0.8) canvas.fill(Path(ellipseIn: glow), with: .color(color.opacity(frame.opacity * 0.25))) canvas.fill(Path(ellipseIn: frame.rect), with: .color(color.opacity(frame.opacity))) } } .onChange(of: context.date) { _, now in if emitter.isFinished(at: now) { emitter.removeAll() } } } .allowsHitTesting(false) .accessibilityHidden(true) } .simultaneousGesture( DragGesture(minimumDistance: 0) .onChanged { value in emitter.emit(at: value.location, now: .now, lifetime: lifetime, drifts: !reduceMotion) } .onEnded { _ in emitter.endStroke() } ) }}
struct TouchParticleTrailParticle { var position: CGPoint var velocity: CGVector var birth: Date var lifetime: Double var size: CGFloat var colorIndex: Int
struct Frame { var rect: CGRect var opacity: Double }
func isExpired(at now: Date) -> Bool { now.timeIntervalSince(birth) >= lifetime }
/// Where the particle is drawn at a given moment, or `nil` once it has faded out. func frame(at now: Date) -> Frame? { let age = now.timeIntervalSince(birth) guard age >= 0, age < lifetime, lifetime > 0 else { return nil }
let progress = age / lifetime let side = size * (1 - progress * 0.6) return Frame( rect: CGRect( x: position.x + velocity.dx * age - side / 2, y: position.y + velocity.dy * age - side / 2, width: side, height: side ), opacity: 1 - progress ) }}
struct TouchParticleTrailEmitter { private(set) var particles: [TouchParticleTrailParticle] = [] private var lastPoint: CGPoint? private var lastEmission: Date?
private let capacity = 240 private let spacing: CGFloat = 9 private let maximumPerEvent = 16 private let strokeGap = 0.12
/// Adds particles between the last point and this one, so a fast drag leaves no gaps. /// /// A long pause starts a new stroke. That also covers a gesture the system /// cancelled, which never reports its end. mutating func emit(at point: CGPoint, now: Date, lifetime: Double, drifts: Bool) { guard point.x.isFinite, point.y.isFinite else { return } let lifetime = max(lifetime, 0.05) particles.removeAll { $0.isExpired(at: now) }
let previous = lastEmission.map { now.timeIntervalSince($0) <= strokeGap } == true ? lastPoint : nil lastEmission = now
var count = 1 if let previous { let distance = hypot(point.x - previous.x, point.y - previous.y) guard distance >= spacing else { return } count = Int(min(distance / spacing, CGFloat(maximumPerEvent))) }
for step in 1...count { let progress = CGFloat(step) / CGFloat(count) let position = previous.map { CGPoint(x: $0.x + (point.x - $0.x) * progress, y: $0.y + (point.y - $0.y) * progress) } ?? point particles.append(makeParticle(at: position, now: now, lifetime: lifetime, drifts: drifts)) } lastPoint = point
if particles.count > capacity { particles.removeFirst(particles.count - capacity) } }
/// Particles are in birth order and share a lifetime, so the last one is the last to go. func isFinished(at now: Date) -> Bool { particles.last?.isExpired(at: now) ?? false }
/// Lifting the finger ends the stroke, so the next touch is not joined to this one. mutating func endStroke() { lastPoint = nil lastEmission = nil }
mutating func removeAll() { particles.removeAll() lastPoint = nil lastEmission = nil }
private func makeParticle( at position: CGPoint, now: Date, lifetime: Double, drifts: Bool ) -> TouchParticleTrailParticle { let angle = Double.random(in: 0..<(2 * .pi)) let speed = drifts ? Double.random(in: 10...70) : 0 return TouchParticleTrailParticle( position: position, velocity: CGVector(dx: cos(angle) * speed, dy: sin(angle) * speed), birth: now, lifetime: lifetime, size: .random(in: 7...15), colorIndex: .random(in: 0..<1_000) ) }}
// MARK: - Demo
struct TouchParticleTrailDemo: View { var body: some View { TouchParticleTrail { ZStack { LinearGradient( colors: [Color(red: 0.05, green: 0.03, blue: 0.16), .black], startPoint: .top, endPoint: .bottom )
VStack(spacing: 10) { Image(systemName: "hand.draw.fill") .font(.largeTitle) .accessibilityHidden(true) Text("Drag anywhere") .font(.title2.bold()) } .foregroundStyle(.white.opacity(0.75)) } .ignoresSafeArea() } .ignoresSafeArea() }}
#Preview { TouchParticleTrailDemo()}