Plume is a Swift package for building particle effects with CAEmitterLayer and using them from SwiftUI or UIKit. The public API is centered around a single Plume value that combines an emitter and one or more particle cells.
- iOS 12+
- Swift Package Manager
- UIKit or SwiftUI
Add Plume as a Swift package dependency, then import it where needed:
import PlumeThe package has three main pieces:
Plume, the top-level effect modelPlumeViewandPlumeUIView, the SwiftUI and UIKit renderers- a set of convenience extensions for quickly building emitters, cells, and preset motion values
The public API is organized around Plume as the root type. Its purpose is to show the main model pieces you compose when building an effect.
Plume
|_ Emitter
|_ Cell
| |_ Contents
| |_ Lifetime
| |_ Spin
| |_ Scale
| |_ Acceleration
| |_ Velocity
| |_ Angle
At the center of the package is Plume, which combines:
- one
Plume.Emitter - one or more
Plume.Cellvalues
To create an emitter, use the built-in factory methods:
Plume.Emitter.point(birthRate:)Plume.Emitter.line(birthRate:)Plume.Emitter.circle(birthRate:)Plume.Emitter.rectangle(birthRate:)
Here is a small example that builds a plume from SF Symbols-backed images:
import UIKit
import Plume
let images = [
UIImage(systemName: "star.fill")!,
UIImage(systemName: "circle.fill")!,
UIImage(systemName: "seal.fill")!
]
let plume = Plume(
emitter: .circle(birthRate: 24),
cells: .make(
from: images,
lifetime: .normal,
spin: .normal,
scale: .small,
acceleration: .gravity,
velocity: .standard,
angle: .radial
)
)Use PlumeView when the effect belongs inside a SwiftUI hierarchy. The view is trigger-based: when the trigger value changes, the underlying PlumeUIView emits again.
import SwiftUI
import UIKit
import Plume
struct CelebrationView: View {
@State private var trigger = 0
private let plume = Plume(
emitter: .line(birthRate: 18),
cells: .make(
from: [
UIImage(systemName: "star.fill")!,
UIImage(systemName: "triangle.fill")!
],
lifetime: .normal,
spin: .lively,
scale: .small,
acceleration: .gravityLight,
velocity: .lively,
angle: .topHemisphere
)
)
var body: some View {
ZStack {
Button("Celebrate") {
trigger += 1
}
PlumeView(plume: plume, trigger: trigger)
.allowsHitTesting(false)
}
}
}Use this approach when the plume effect should be attached to a specific screen or view tree.
Use PlumeUIView when you want direct UIKit control. Create the view, size it to your container, add it to the hierarchy, and call emit().
import UIKit
import Plume
final class CelebrationViewController: UIViewController {
private let plume = Plume(
emitter: .rectangle(birthRate: 20),
cells: .make(
from: [
UIImage(systemName: "sparkle")!,
UIImage(systemName: "circle.fill")!
],
lifetime: .normal,
spin: .normal,
scale: .small,
acceleration: .gravity,
velocity: .standard,
angle: .bottomHemisphere
)
)
private lazy var plumeView = PlumeUIView(plume: plume)
override func viewDidLoad() {
super.viewDidLoad()
plumeView.frame = view.bounds
plumeView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(plumeView)
}
func celebrate() {
plumeView.emit()
}
}The package includes a few helpers to make common effects easier to express:
Array.make(from:)for turning arrays ofUIImage,CGImage, orImageResourcevalues into[Plume.Cell]Plume.Emitterpresets such as.point(birthRate:),.line(birthRate:),.circle(birthRate:), and.rectangle(birthRate:)Plume.Cell.Accelerationpresets such as.zero,.gravity,.gravityLight,.lift,.upLeft, and.downRightPlume.Cell.Anglepresets such as.up,.down,.topHemisphere, and.radialPlume.Cell.Lifetimepresets such as.instant,.normal,.long, and.continuousPlume.Cell.Scalepresets such as.tiny,.small,.normal,.large, and.massivePlume.Cell.Spinpresets such as.none,.gentle,.normal,.lively, and.chaoticPlume.Cell.Velocitypresets such as.zero,.gentle,.standard,.lively, and.explosive
Plume includes a lightweight DTO-based decoding layer for configuration-driven effects.
This is useful when your app wants to keep effect tuning outside of Swift source, for example in bundled JSON files or remote configuration.
Example JSON:
{
"emitter": {
"shape": "circle",
"mode": "surface",
"birthRate": 24
},
"cell": {
"contents": [
{ "url": "https://example.com/confetti/star.png" },
{ "url": "https://example.com/confetti/circle.png" }
],
"lifetime": { "base": 2.5, "range": 0.8 },
"spin": { "base": 1.5, "range": 0.6 },
"scale": { "base": 1.0, "range": 0.2 },
"acceleration": { "x": 0, "y": 300 },
"velocity": { "base": 100, "range": 25 },
"angle": { "base": 0, "range": 1.57 }
}
}Decoded values map as follows:
Plume.DataTransferObjectdecodes one emitter and one template cellPlume.Cell.DataTransferObjectdecodes an array of remote image URLs plus shared motion configuration- each
cell.contentsentry decodes one remote image URL Plume.Emitter.ShapeandPlume.Emitter.Modedecode from string values such as"circle"and"surface"- scalar types such as
Acceleration,Angle,Lifetime,Scale,Spin, andVelocityalso decode directly
import Foundation
import Plume
let data = Data(json.utf8)
let plume = try await Plume(from: data)Behavior notes:
- Each successfully decoded image becomes one
Plume.Cellusing the shared template cell configuration. - The factory downloads all images concurrently.
- The method throws if any download task fails.
- The API is marked
@available(iOS 17.0, *).
Use the existing UIImage, CGImage, or ImageResource overloads when your particle assets are already local.
If you prefer flatter names, the package exposes typealiases for the most common nested types:
typealias PlumeCell = Plume.Cell
typealias PlumeEmitter = Plume.Emitter
typealias CellAcceleration = Plume.Cell.Acceleration
typealias CellContents = Plume.Cell.Contents
typealias CellAngle = Plume.Cell.Angle
typealias CellLifetime = Plume.Cell.Lifetime
typealias CellScale = Plume.Cell.Scale
typealias CellSpin = Plume.Cell.Spin
typealias CellVelocity = Plume.Cell.Velocity- Use
PlumeViewfor SwiftUI screens. - Use
PlumeUIViewfor UIKit screens and custom view hierarchies. - Use direct
Plumeconstruction when you want precise control over emitter behavior.
PlumeViewis trigger-driven and emits when thetriggerinput changes.PlumeUIViewis non-interactive by default and is intended to sit on top of other content.Plume.Emitter.ModeandPlume.Emitter.Shapeare implementation details; the public entry point is the emitter factory API.- Most motion/value types are currently intended to be used through their preset constants rather than direct initialization.
- Remote URL-backed cell creation is asynchronous and currently available on iOS 17 and later.


