SwiftUI symbolEffect: The Complete Guide to Animated SF Symbols in iOS 26

Animate SF Symbols in SwiftUI with the symbolEffect modifier. Covers bounce, pulse, variableColor, the new iOS 26 drawOn and wiggle effects, plus fixes for when animations silently break.

SwiftUI symbolEffect Guide (iOS 26)

Updated: June 27, 2026

The SwiftUI symbolEffect modifier animates SF Symbols with a single line of code in iOS 17 and later, and iOS 26 adds the new .drawOn, .drawOff, .breathe, .wiggle, and .rotate effects from SF Symbols 7. In practice, you apply .symbolEffect(.bounce, value: trigger) to any Image(systemName:) and SwiftUI animates the glyph's stroke, fill, or layers (no Core Animation or custom shapes required). This guide covers every built-in effect, when to use each one, the API differences across iOS 17/18/26, and the small pitfalls that quietly break the animation in production.

  • .symbolEffect(_:options:value:) animates SF Symbols when the value changes. For indefinite animations you can omit value and use .symbolEffect(.pulse).
  • iOS 17 introduced bounce, pulse, variableColor, scale, appear, disappear, and replace. iOS 18 added rotate and breathe. iOS 26 plus SF Symbols 7 add drawOn, drawOff, and a refined wiggle with direction control.
  • Use .contentTransition(.symbolEffect(.replace)) when swapping one symbol for another (for example, play and pause), not the bounce or pulse effects.
  • .symbolRenderingMode(.hierarchical) or .palette is required for layer-based effects like variableColor and breathe to look correct.
  • Wrap your modifier in if #available(iOS 18.0, *) when shipping to iOS 17. The bounce-only API silently ignores newer effect names instead of erroring at compile time.
  • Reduce Motion is honored automatically by repeating effects, but one-shot effects still play, so check UIAccessibility.isReduceMotionEnabled if you want full opt-out.

What is the SwiftUI symbolEffect modifier?

The symbolEffect modifier is a SwiftUI view modifier added in iOS 17 that animates the rendering of an SF Symbol without you writing any animation code. It accepts a value conforming to the SymbolEffect protocol: types like BounceSymbolEffect, PulseSymbolEffect, VariableColorSymbolEffect, and the newer WiggleSymbolEffect, BreatheSymbolEffect, and RotateSymbolEffect. Internally, SF Symbols ship as multi-layer vector files and the system animates the layers (or the path itself for draw-on effects) along curves Apple designed in the SF Symbols app.

Two important details. First, the modifier only applies to Image(systemName:), Label using a system image, and a few other Apple symbol containers. Passing it to an arbitrary view is a no-op. Second, effects fall into three categories: discrete (bounce, rotate, wiggle, which play once per trigger), indefinite (pulse, variableColor, breathe, which repeat until removed), and transition (appear, disappear, replace, drawOn, drawOff, which change the symbol's presence). Honestly, mixing those mental models up is the number one source of "my animation doesn't run" tickets I've helped folks debug.

Every built-in SF Symbol effect in iOS 26

Below is the full effect catalog as of June 2026, with the iOS version each one shipped in. Effect names are static members on the SymbolEffect protocol, so you can use dot-shorthand: .bounce, .pulse, .variableColor.iterative, and so on.

EffectCategoryAvailableBest for
.bounceDiscreteiOS 17+Acknowledging a tap or completed action
.pulseIndefiniteiOS 17+Drawing attention (recording, live status)
.variableColorIndefiniteiOS 17+Wi-Fi/cell signal, loading states with layers
.scaleDiscreteiOS 17+Selection/highlight toggles
.appear / .disappearTransitioniOS 17+Inserting and removing symbols cleanly
.replaceTransitioniOS 17+Swapping one symbol for another (play and pause)
.rotateDiscrete / IndefiniteiOS 18+Refresh icons, settings gears
.breatheIndefiniteiOS 18+Calm "waiting" states, ambient indicators
.wiggleDiscreteiOS 18+ (refined iOS 26)Errors, invalid input, attention without alarm
.drawOn / .drawOffTransitioniOS 26+Stroke-based "writing in" of a symbol

Apple's SymbolEffect protocol reference lists each type's modifier options (such as .byLayer vs .wholeSymbol), and the free SF Symbols app lets you preview every effect on every glyph before you touch Xcode. That's invaluable, because plenty of glyphs simply don't have the layer data needed for a given effect and fall back to a single-layer fade.

How do I animate SF Symbols in SwiftUI?

The simplest case is a one-shot bounce when a counter changes. Apply the modifier and pass the trigger value. SwiftUI re-runs the animation every time the value mutates.

import SwiftUI

struct LikeButton: View {
    @State private var likes = 0

    var body: some View {
        Button {
            likes += 1
        } label: {
            Label("\(likes)", systemImage: "heart.fill")
                .symbolEffect(.bounce, value: likes)
                .font(.title2)
                .foregroundStyle(.pink)
        }
    }
}

For indefinite effects you don't need a trigger. Just attach the modifier and SwiftUI loops the animation until the view leaves the hierarchy or the effect is removed. The classic example is a recording indicator:

struct RecordingIndicator: View {
    @State private var isRecording = false

    var body: some View {
        Image(systemName: "record.circle")
            .symbolEffect(.pulse, options: .repeating, isActive: isRecording)
            .foregroundStyle(.red)
            .font(.system(size: 44))
            .onTapGesture { isRecording.toggle() }
    }
}

The isActive parameter is the cleanest way to start and stop a repeating effect. Toggling it animates the wind-down rather than snapping the symbol back to its resting state. Avoid the temptation to conditionally apply the modifier with an if branch; that swaps the underlying view identity and SwiftUI won't animate the transition. (I hit this exact bug shipping a recording UI last month and it took me an embarrassing 20 minutes to remember which side of the rule I was on.)

What are the new SF Symbols effects in iOS 26?

SF Symbols 7, shipped alongside iOS 26 at WWDC 2026, added three notable effects plus a redesigned set of options on existing ones. The headline additions:

  • .drawOn and .drawOff: animate the stroke of a symbol on or off, similar to a hand drawing it. Use these instead of .appear/.disappear when the symbol has clear stroke geometry (arrows, checkmarks, signatures).
  • Refined .wiggle: iOS 26 adds directional variants like .wiggle.byLayer, .wiggle.clockwise, and .wiggle.left, letting you express form-validation shakes in fewer lines.
  • SymbolEffectStrategy: a new behind-the-scenes type that lets compound effects (for example, a replace followed by a bounce) sequence cleanly without you orchestrating them manually.

A practical draw-on example for a confirmation checkmark:

struct ConfirmationCheck: View {
    @State private var confirmed = false

    var body: some View {
        VStack(spacing: 20) {
            if confirmed {
                Image(systemName: "checkmark.circle.fill")
                    .font(.system(size: 80))
                    .foregroundStyle(.green)
                    .symbolEffect(.drawOn, options: .nonRepeating, isActive: confirmed)
                    .transition(.symbolEffect(.drawOn))
            }
            Button("Confirm") {
                withAnimation { confirmed.toggle() }
            }
        }
    }
}

The new strategy API also makes it easy to compose effects. Where iOS 18 forced you to chain modifiers (and pray the timing lined up), iOS 26 lets you write .symbolEffect(.replace.byLayer.downUp, options: .speed(1.2)) and get the directional, per-layer variant in a single call. Apple covered the full set in the WWDC 2026 "What's new in SF Symbols 7" session.

Replacing symbols with .contentTransition

When the symbol itself changes (for example, a media controller flipping between play.fill and pause.fill), you don't want .bounce. You want a replace transition, which morphs the source glyph into the destination using SF Symbols' layer matching. There are two ways to apply it:

struct PlayPauseButton: View {
    @State private var isPlaying = false

    var body: some View {
        Button {
            withAnimation { isPlaying.toggle() }
        } label: {
            Image(systemName: isPlaying ? "pause.fill" : "play.fill")
                .contentTransition(.symbolEffect(.replace.downUp))
                .font(.system(size: 60))
        }
    }
}

The .replace family takes optional direction (.upUp, .downUp, .offUp) and a per-layer toggle (.byLayer). For symbols that share layer counts (most fill-style pairs), .byLayer produces the cleanest morph; individual layers cross-fade rather than the whole glyph dissolving.

If you're coming from animated state in UIKit, the mental model is similar to working with a custom UIImage.SymbolConfiguration applied through setSymbolImage(_:contentTransition:), only SwiftUI runs the entire pipeline declaratively. For more on declarative state animation in SwiftUI, see our SwiftUI animations guide.

Symbol rendering modes and palette colors

Several effects (variableColor, breathe, certain palette-aware replace transitions) only look right when the symbol is rendered in a multi-color mode. SwiftUI exposes four rendering modes via .symbolRenderingMode(_:):

  • .monochrome (default): single foreground color.
  • .hierarchical: tinted layers at decreasing opacity from one base color.
  • .palette: explicit color per layer via .foregroundStyle(_, _, _).
  • .multicolor: Apple's authored colors (a battery's red warning, a calendar's red date).

A common pitfall: applying .variableColor to a monochrome symbol does nothing visible, because variable color steps through layer opacities and there's only one layer. Always pair the effect with a layered rendering mode:

Image(systemName: "wifi")
    .symbolRenderingMode(.hierarchical)
    .foregroundStyle(.blue)
    .symbolEffect(.variableColor.iterative.reversing)

The .iterative option steps through layers one at a time (good for signal-strength indicators); .cumulative turns each layer on in sequence (good for upload progress). Both accept .reversing to play back-and-forth.

Why is symbolEffect not working?

So, here are the five issues that account for almost every "my symbolEffect doesn't animate" question on the Apple forums:

  1. The value didn't change. For discrete effects with a value: parameter, SwiftUI compares with ==. Toggling a bool from false to false (which happens if your action runs twice in one frame) skips the animation. Use a monotonically increasing counter or UUID() if in doubt.
  2. The symbol has no layer data for the effect. Older SF Symbols (pre-version 4) and custom symbols often lack the layer annotations needed for .breathe or .variableColor. Open the symbol in the SF Symbols app and check the Animation tab. If the effect previews as a flat fade, it will look the same in your app.
  3. Conditional modifier branches. if/else around .symbolEffect creates two different views with different identities; SwiftUI tears down the old one and builds a new one, skipping the animation. Use isActive: instead.
  4. Low Power Mode or Reduce Motion. Indefinite animations are paused automatically; this is intentional, not a bug.
  5. Wrong modifier. If you want to swap symbols, you need .contentTransition(.symbolEffect(.replace)), not .symbolEffect(.replace). The former hooks into SwiftUI's content transition system; the latter is a no-op on a value change that swaps the underlying glyph.

Accessibility, Reduce Motion, and Dynamic Type

Animated symbols intersect with three accessibility systems. Reduce Motion (Settings → Accessibility → Motion) automatically dampens indefinite effects in iOS 18+ and disables them entirely in many iOS 26 configurations. Discrete effects still play once because Apple considers a single bounce informational, not decorative. You can opt out manually:

@Environment(\.accessibilityReduceMotion) private var reduceMotion

Image(systemName: "bell.fill")
    .symbolEffect(.pulse, isActive: !reduceMotion)

VoiceOver does not announce symbol effects; they're decorative by default. If the animation conveys state (recording vs idle), add .accessibilityLabel reflecting that state so screen-reader users get the same information. Dynamic Type scales the symbol's size, and the effect scales with it; the wiggle radius, for instance, grows proportionally so large-text users see the same relative motion. For a deeper look at building inclusive SwiftUI views, see our SwiftUI accessibility guide.

Backporting to iOS 17 and earlier

If your app's minimum is iOS 16, you can't use .symbolEffect at all. Fall back to .scaleEffect + withAnimation. For iOS 17 minimums where you want to use iOS 18+ effects opportunistically:

Image(systemName: "arrow.triangle.2.circlepath")
    .modifier(RotateOrFallback(isActive: isRefreshing))

struct RotateOrFallback: ViewModifier {
    let isActive: Bool

    func body(content: Content) -> some View {
        if #available(iOS 18.0, *) {
            content.symbolEffect(.rotate, options: .repeating, isActive: isActive)
        } else {
            content
                .rotationEffect(.degrees(isActive ? 360 : 0))
                .animation(isActive ? .linear(duration: 1).repeatForever(autoreverses: false) : .default,
                           value: isActive)
        }
    }
}

For iOS 26-only effects like .drawOn, an @available wrapper plus .transition(.opacity) as the fallback is usually enough. The visual difference on older OSes is minor compared to maintaining a hand-rolled stroke animation. If you're also targeting older devices and need to share view code between platforms, our @Observable macro guide covers the related concern of how to write state objects that compile cleanly across iOS 16 and iOS 26.

Frequently Asked Questions

What's the difference between bounce and pulse on SF Symbols?

.bounce is discrete; it plays once each time its value parameter changes, ideal for acknowledging a tap. .pulse is indefinite; it loops continuously while attached, ideal for ongoing states like recording or live indicators. They take different parameters for that reason: bounce needs value:, and pulse usually takes isActive:.

Does symbolEffect work on iOS 16?

No. The symbolEffect modifier requires iOS 17 or later. For iOS 16 you must approximate effects with scaleEffect, rotationEffect, and withAnimation. Use #available(iOS 17.0, *) in a ViewModifier to branch cleanly between paths.

Can I use symbolEffect on custom SF Symbols?

Yes, provided the custom symbol was exported from SF Symbols 4 or later with the appropriate layer annotations. Layer-based effects (variableColor, breathe, draw-on) require the symbol to have explicit per-layer paths; older custom symbols fall back to a single-layer fade. Re-export from SF Symbols 7 to unlock the latest effects.

Why does my replace transition fall back to a fade?

The two symbols being swapped don't share a matching layer structure. SF Symbols' replace algorithm cross-fades layer pairs; when the source and destination glyphs have different layer counts, it gives up and dissolves the whole symbol. Pick semantically-paired glyphs (play.fill and pause.fill, heart and heart.fill) or use .replace.wholeSymbol explicitly to skip the matching attempt.

How do I stop an indefinite symbolEffect cleanly?

Toggle the isActive: parameter to false. SwiftUI animates the wind-down to the resting state rather than snapping. Conditionally applying the modifier with an if branch breaks the animation because it changes the view's identity, so always use isActive: for runtime control.

Editorial Team
About the Author Editorial Team

Our team of expert writers and editors.