Compose Advanced Graphics Effects With SwiftUI
The interesting part of SwiftUI graphics effects is not making a view look busy. It is choosing the one piece of the interface that should feel alive, keeping the rest of the screen readable, and making the effect easy to turn down when the user or the hardware asks you to.
Apple’s WWDC26 session 322, “Compose advanced graphics effects with SwiftUI”, focuses on that lower-level part of the SwiftUI graphics stack: Shader, ShaderLibrary, .colorEffect, .distortionEffect, .layerEffect, stitchable Metal functions, SwiftUI::Layer, TimelineView(.animation), time-driven effects, noise, warping, maxSampleOffset, and the layout/alignment tools you need to compose the result back into a real interface.
The example below is a now-playing screen with synchronized transcript rows. The artwork/header gets a subtle layer effect driven by playback time. The transcript remains normal SwiftUI text. The active row uses layout state and alignment guides, not a shader pretending to understand typography.
One beta caveat before the code: the snippets follow the WWDC26 beta API shape Apple is showing for this SDK cycle. Treat exact shader function signatures, generated ShaderLibrary member names, and argument labels as something to verify against the SDK you are building with. The architecture is the important part: pass small values into a stitchable function, declare an honest maxSampleOffset, and keep the effect boundary narrow.
Model the transcript like app state
Start with ordinary data. The graphics effect should not own playback state, row selection, or transcript timing.
import Observation
import SwiftUI
struct TranscriptLine: Identifiable, Hashable {
let id: String
let startsAt: TimeInterval
let speaker: String
let text: String
init(id: String, startsAt: TimeInterval, speaker: String, text: String) {
self.id = id
self.startsAt = startsAt
self.speaker = speaker
self.text = text
}
}
struct Episode {
var title: String
var subtitle: String
var artworkName: String
var duration: TimeInterval
var transcript: [TranscriptLine]
}
That gives the view a simple job: render the player, find the active line, and keep the scroll position close to that line.
@MainActor
@Observable
final class PlaybackState {
var elapsed: TimeInterval = 0
var isPlaying = true
func activeLineID(in lines: [TranscriptLine]) -> TranscriptLine.ID? {
lines.last { line in
line.startsAt <= elapsed
}?.id
}
}
In a shipping audio app, elapsed would come from your player engine. In a preview or prototype, a timer can advance it. The important boundary is that the shader receives time as input. It does not become the source of truth for playback.
Build the screen first
Here is the shape of the screen without the shader details yet. The effect is limited to the artwork/header. The transcript is a regular ScrollView so it can keep selection, accessibility, Dynamic Type, and text layout behavior.
import SwiftUI
struct NowPlayingTranscriptView: View {
let episode: Episode
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var playback = PlaybackState()
@State private var lastCenteredLineID: TranscriptLine.ID?
var body: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 0) {
NowPlayingHeader(
episode: episode,
elapsed: playback.elapsed,
isPlaying: playback.isPlaying,
reduceMotion: reduceMotion
)
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 28)
TranscriptList(
lines: episode.transcript,
activeID: playback.activeLineID(in: episode.transcript)
)
.padding(.horizontal, 20)
.padding(.bottom, 32)
}
}
.background(Color(.systemBackground))
.onChange(of: playback.activeLineID(in: episode.transcript)) { _, id in
guard let id else { return }
guard id != lastCenteredLineID else { return }
lastCenteredLineID = id
withAnimation(reduceMotion ? nil : .snappy(duration: 0.35)) {
proxy.scrollTo(id, anchor: .center)
}
}
}
}
}
This is intentionally not a full-screen shader. Most now-playing screens spend more time idle than animating between states. The album art can have a little motion while audio plays; the controls and transcript should stay calm and readable.
Apply a layer effect to the artwork
layerEffect is the right modifier when the shader needs to sample the rendered view. The shader receives a SwiftUI::Layer, samples nearby pixels, and returns a new color for each output position.
This header passes playback time and a small strength value into the shader. When Reduce Motion is enabled, it pauses the animated timeline and disables the layer effect, leaving the original artwork.
struct NowPlayingHeader: View {
let episode: Episode
let elapsed: TimeInterval
let isPlaying: Bool
let reduceMotion: Bool
var body: some View {
VStack(alignment: .leading, spacing: 18) {
TimelineView(.animation(paused: reduceMotion || !isPlaying)) { _ in
let phase = reduceMotion ? 0 : elapsed
let strength: Float = reduceMotion || !isPlaying ? 0 : 7
Image(episode.artworkName)
.resizable()
.scaledToFill()
.frame(height: 280)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(alignment: .bottomLeading) {
playerText
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.linearGradient(
colors: [.black.opacity(0), .black.opacity(0.64)],
startPoint: .top,
endPoint: .bottom
))
}
.modifier(
ArtworkPulseLayerEffect(
time: phase,
strength: strength
)
)
}
}
}
private var playerText: some View {
VStack(alignment: .leading, spacing: 4) {
Text(episode.title)
.font(.title2.weight(.semibold))
.foregroundStyle(.white)
Text(episode.subtitle)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.78))
}
}
}
Use one clock for the visual phase. In a real player, prefer the playback clock; use TimelineView(.animation) to ask SwiftUI for display-linked redraws while playback is active. If your player exposes a stable render clock, use that instead of mixing wall-clock time with playback time.
The modifier keeps the shader call in one place:
struct ArtworkPulseLayerEffect: ViewModifier {
var time: TimeInterval
var strength: Float
func body(content: Content) -> some View {
content.layerEffect(
ShaderLibrary.artworkPulse(
.float(Float(time)),
.float(strength)
),
maxSampleOffset: CGSize(width: CGFloat(strength), height: 0),
isEnabled: strength > 0
)
}
}
maxSampleOffset matters. It tells SwiftUI how far the shader may sample away from the current pixel. If the shader samples up to 7 points horizontally, do not claim .zero. If the effect can sample 40 points away, do not leave the visual clipped by declaring 7. Keep the number honest and small.
Write the stitchable Metal function
Add a Metal file to the target, for example NowPlayingEffects.metal. The exact generated ShaderLibrary symbol depends on the function name and the current SDK, so verify the final spelling in your project.
#include <metal_stdlib>
#include <SwiftUI/SwiftUI.h>
using namespace metal;
[[ stitchable ]]
half4 artworkPulse(
float2 position,
SwiftUI::Layer layer,
float time,
float strength
) {
float slowWave = sin((position.y * 0.018) + (time * 1.4));
float fineWave = sin((position.x * 0.031) - (time * 2.1));
float offsetX = (slowWave * 0.65 + fineWave * 0.35) * strength;
float2 samplePosition = position + float2(offsetX, 0.0);
half4 color = layer.sample(samplePosition);
half lift = half(0.025 * max(strength, 0.0));
return half4(
min(color.rgb + lift, half3(1.0)),
color.a
);
}
This is a layer effect, not a procedural background. The shader samples the artwork that SwiftUI already rendered, nudges the sample position horizontally, and adds a tiny light lift. You can replace the sine waves with noise or domain warping, but keep the same discipline: compute a bounded offset, sample the layer, and return a color.
The matching maxSampleOffset above is based on strength because the shader never moves farther than roughly that many points horizontally. If you add vertical motion, update both dimensions. If you add a second lookup farther away, update the limit again.
Keep transcript layout out of the shader
The active transcript line needs layout, selection, and scroll state. SwiftUI already has better tools for that than a shader.
This row aligns timestamps and text with a custom alignment guide. It also keeps the active indicator as normal layout, so Dynamic Type and localization can change the row height without breaking the effect.
private struct TranscriptTextColumn: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[.leading]
}
}
private extension HorizontalAlignment {
static let transcriptText = HorizontalAlignment(TranscriptTextColumn.self)
}
struct TranscriptList: View {
let lines: [TranscriptLine]
let activeID: TranscriptLine.ID?
var body: some View {
LazyVStack(alignment: .transcriptText, spacing: 14) {
ForEach(lines) { line in
TranscriptRow(
line: line,
isActive: line.id == activeID
)
.id(line.id)
}
}
}
}
struct TranscriptRow: View {
let line: TranscriptLine
let isActive: Bool
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(timestamp)
.font(.caption.monospacedDigit())
.foregroundStyle(isActive ? .primary : .secondary)
.frame(width: 54, alignment: .trailing)
VStack(alignment: .leading, spacing: 4) {
Text(line.speaker)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text(line.text)
.font(.body)
.foregroundStyle(isActive ? .primary : .secondary)
.fixedSize(horizontal: false, vertical: true)
}
.alignmentGuide(.transcriptText) { dimensions in
dimensions[.leading]
}
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background {
if isActive {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.accentColor.opacity(0.12))
}
}
}
.accessibilityElement(children: .combine)
.accessibilityAddTraits(isActive ? [.isSelected] : [])
}
private var timestamp: String {
let minutes = Int(line.startsAt) / 60
let seconds = Int(line.startsAt) % 60
return "\(minutes):\(seconds < 10 ? "0" : "")\(seconds)"
}
}
This is the part that makes the screen feel production-shaped. The shader does one visual job. The transcript remains a list of real rows with real text, stable IDs, an active state, and predictable alignment.
Add a small color or distortion effect only when it helps
colorEffect and distortionEffect are useful siblings, but they solve different problems.
Use colorEffect when the shader only needs the current pixel color:
[[ stitchable ]]
half4 playbackTint(float2 position, half4 color, float amount) {
half3 warm = half3(1.0, 0.92, 0.78);
return half4(mix(color.rgb, color.rgb * warm, half(amount)), color.a);
}
Use distortionEffect when the shader only needs to return a new source position:
[[ stitchable ]]
float2 lyricRipple(float2 position, float time, float strength) {
float wave = sin(position.y * 0.04 + time * 3.0);
return position + float2(wave * strength, 0.0);
}
For the now-playing header, layerEffect is a better fit because the effect samples the already-composited artwork and overlay. For transcript text, I would usually avoid all three. Selection, contrast, VoiceOver, and Dynamic Type matter more than motion there.
Respect reduced motion with a boring fallback
The fallback should be simple, not a second complex effect. In this example, Reduce Motion pauses the timeline, disables the shader, and keeps the same layout.
struct MotionAwareArtwork: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let imageName: String
let isPlaying: Bool
var body: some View {
TimelineView(.animation(paused: reduceMotion || !isPlaying)) { context in
let time = reduceMotion
? 0
: context.date.timeIntervalSinceReferenceDate
Image(imageName)
.resizable()
.scaledToFill()
.layerEffect(
ShaderLibrary.artworkPulse(
.float(Float(time)),
.float(reduceMotion ? 0 : 6)
),
maxSampleOffset: reduceMotion
? .zero
: CGSize(width: 6, height: 0),
isEnabled: !reduceMotion && isPlaying
)
}
}
}
If the effect communicates real state, keep that state visible in a non-motion form too: text, symbols, progress, selection, or layout. In this screen, the active transcript row, play controls, and elapsed time should all work when the shader is gone.
Performance guardrails
The most expensive version of this idea is a continuously animated full-screen layer effect that samples far outside its bounds. Avoid that until you have measured it and know it is worth the cost.
For this kind of screen, I would use these rules:
- Keep
maxSampleOffsethonest. It is part of the rendering contract, not a decorative parameter. - Prefer a small effect region. Artwork, a visualizer strip, or a header is easier to justify than the whole scroll view.
- Do not animate continuously when static state is enough. Pause
TimelineView(.animation)when playback is paused or the view is not visible. - Test on device. Simulator smoothness is not a substitute for real GPU, thermal, ProMotion, and Low Power Mode behavior.
- Keep text legible. If a shader makes transcript lines shimmer, selection and reading speed usually get worse.
- Respect Reduce Motion with a plain fallback. Turning the strength down to zero is often better than inventing a different animation.
- Make the non-shader UI complete. The app should still communicate playback, active line, progress, and controls without the effect.
That is the real value of SwiftUI’s graphics effects. You can reach down to Metal for the one surface that benefits from it, then come back up to SwiftUI for layout, accessibility, scroll state, and app logic.
Further reading:
- WWDC26 Compose advanced graphics effects with SwiftUI: https://developer.apple.com/videos/play/wwdc2026/322/
- Apple documentation: https://developer.apple.com/documentation/swiftui/shader
- Apple documentation: https://developer.apple.com/documentation/swiftui/view/layereffect(_:maxsampleoffset:isenabled:)
- Apple documentation: https://developer.apple.com/documentation/swiftui/timelineview