I recently shipped a free macOS app called Wisp — a menu bar utility that
adds gentle, glowing particles between your wallpaper and your desktop icons.
Nothing revolutionary, just a visual texture I missed. But building it turned
into a tour of some underused Apple APIs I want to share.
🌐 wisp.gruffix.ru · 💾 GitHub · MIT
The challenge
I wanted ~1,000 particles drifting smoothly on a 4K display at roughly 1 % CPU
idle. A naive approach — a Timer that loops over particle structs and draws
into a CALayer each frame — would collapse on any real-world Mac. So I went
straight for CAEmitterLayer, Apple's GPU-accelerated particle system. Everybody
uses it on iOS for confetti; almost nobody talks about using it on macOS for
ambient backgrounds.
Anatomy of a CAEmitter
CAEmitterLayer spawns CAEmitterCell instances and animates each on the GPU.
You configure the cell once, and the layer handles birth rate, lifetime, velocity
with variance, scale, color, alpha, rotation — all interpolated per particle.
No per-frame work from your side.
Here's a minimal setup:
import QuartzCore
let emitter = CAEmitterLayer()
emitter.emitterShape = .rectangle
emitter.emitterMode = .surface
emitter.emitterPosition = CGPoint(x: screen.width / 2, y: screen.height / 2)
emitter.emitterSize = screen.size
emitter.renderMode = .additive // layered glow
let cell = CAEmitterCell()
cell.contents = particleTexture // a soft radial gradient CGImage
cell.birthRate = 14
cell.lifetime = 10
cell.lifetimeRange = 3
cell.velocity = 8
cell.velocityRange = 18
cell.emissionRange = .pi * 2 // spawn in any direction
cell.yAcceleration = -1 // drift upward
cell.scale = 0.05
cell.scaleRange = 0.02
cell.alphaSpeed = -0.058 // fade out
cell.color = UIColor(...) // main particle color
emitter.emitterCells = [cell]
hostLayer.addSublayer(emitter)
That's already better than any timer loop. But one uniform cell looks artificial.
Five-tier layering
The trick to a natural "atmosphere" look is running multiple cells with very
different parameters in the same emitter. Each cell represents a depth layer:
| Cell | Birth rate | Size | Lifetime | Purpose |
|---|---|---|---|---|
| aura | 2 | big | 14 s | Diffuse background glow |
| near | 5 | medium | 8 s | Foreground blobs |
| mote | 14 | small | 10 s | Mid-depth sparkle |
| spark | 22 | tiny | 12 s | Bright points |
| dot | 35 | pixel | 16 s | Starfield texture |
Because all five run in parallel with .additive blending, overlapping
particles brighten naturally into warm/cool color zones — the same effect
photographers get with anamorphic lens flares.
func buildCells(for palette: [Color]) -> [CAEmitterCell] {
palette.flatMap { color in
[
makeCell(.aura, color: color, texture: softBlob),
makeCell(.near, color: color, texture: glow),
makeCell(.mote, color: color.lighter(0.3), texture: glow),
makeCell(.spark, color: color, texture: core),
makeCell(.dot, color: color.lighter(0.5), texture: hardDot),
]
}
}
Procedural particle textures
The emitter needs bitmap contents per cell. Shipping five PNGs per theme
would bloat the app. Instead I generate them at launch with CGContext:
enum ParticleImage {
static let glow: CGImage = radial(size: 64, falloff: 1.0)
static let core: CGImage = radial(size: 32, falloff: 0.4)
static let blob: CGImage = radial(size: 96, falloff: 1.0)
static let dot: CGImage = radial(size: 8, falloff: 0.25)
private static func radial(size: Int, falloff: CGFloat) -> CGImage {
let ctx = CGContext(data: nil, width: size, height: size,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue
| CGBitmapInfo.byteOrder32Little.rawValue)!
let center = CGPoint(x: CGFloat(size)/2, y: CGFloat(size)/2)
let gradient = CGGradient(
colorsSpace: nil,
colors: [CGColor(red:1,green:1,blue:1,alpha:1),
CGColor(red:1,green:1,blue:1,alpha:0)] as CFArray,
locations: [0, falloff]
)!
ctx.drawRadialGradient(gradient,
startCenter: center, startRadius: 0,
endCenter: center, endRadius: CGFloat(size)/2, options: [])
return ctx.makeImage()!
}
}
Four single-channel soft radial gradients — .additive blending colors them
in at runtime from each cell's color property. Zero bundle weight, infinite
palette variations.
Placing the window below icons
This was the fiddly part. Wisp needs to render above the wallpaper but
below the desktop icons, so icons pass through the effect cleanly. macOS
exposes a window level just for this:
window.level = NSWindow.Level(rawValue:
Int(CGWindowLevelForKey(.desktopWindow)) + 1)
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
window.ignoresMouseEvents = true
window.backgroundColor = .clear
window.isOpaque = false
desktopWindow + 1 sits between the wallpaper and the icon layer. Combined
with canJoinAllSpaces, the window follows you across every Space and
full-screen without re-spawning.
For multi-monitor, I create one window per NSScreen and re-create them on
didChangeScreenParametersNotification.
Being a respectful macOS citizen
A few touches the audience of this blog will appreciate:
-
Pause on battery / sleep / screen lock — subscribe to
NSWorkspace.screensDidSleepNotificationandsessionDidResignActiveNotification, callemitter.birthRate = 0. Free ~1 % CPU back. -
Respect
NSWindow.SharingType.none— lets users opt to exclude the particles from screen recordings and screenshots. One line. - Zero sandboxing footprint — no filesystem access, no network, no entitlements beyond the default. The only network call the app makes is to Sparkle's appcast for update checks.
The result
~1,200 particles at 1 % CPU idle on an M2 Pro. No frame drops. No heat. The
whole app bundle is 4 MB because there are no shipped images — everything is
generated.
If you want to poke at the source, it's MIT on GitHub:
github.com/GruFFix/Wisp
If you just want the app:
wisp.gruffix.ru (free, macOS 14+).

Top comments (0)