SwiftUI TextRenderer in iOS 26: Custom Text Animations and Per-Glyph Effects

SwiftUI TextRenderer in iOS 26 gives you glyph-, run-, and line-level control over Text drawing. Learn the protocol, three working effects, and the Dynamic Type edges that trip most people up.

SwiftUI TextRenderer iOS 26 Guide

Updated: September 4, 2026

SwiftUI TextRenderer is a protocol you implement to intercept how any Text view draws itself, giving you glyph-, run-, and line-level control over rendering. You get a Text.Layout plus a mutable GraphicsContext, and you decide what actually lands on screen. In iOS 26 it's the cleanest way to build per-glyph animations, animated highlights, and text morphs without dropping down to Core Text. This guide covers the protocol, the layout hierarchy, three working effects, and the Dynamic Type and RTL edges that trip most people up.

  • TextRenderer requires a single method, draw(layout:in:), and is applied with the .textRenderer(_:) modifier on any Text view.
  • Text.Layout is a three-level hierarchy: lines contain runs (chunks with the same styling), and runs contain RunSlices down to individual glyphs.
  • Conforming to Animatable and exposing an animatableData value lets SwiftUI interpolate your renderer with the same springs you already use elsewhere.
  • TextAttribute is the SwiftUI-native replacement for NSAttributedString keys. Attach one to a slice of a Text and read it back in the renderer.
  • Set .disablesSubpixelQuantization = true on the context whenever glyphs move; without it, subpixel snapping produces jitter at floating-point offsets.
  • Prefer run-level effects over glyph-level ones for anything user-visible in production. They survive localization, Dynamic Type, and line wrapping without special-casing.

What is TextRenderer in SwiftUI?

TextRenderer is a SwiftUI protocol (introduced at WWDC24 and fully cemented in iOS 26) that lets you take over the drawing step for a Text view. Where AttributedString lets you style characters, TextRenderer lets you draw them: offset a glyph, tint a run, mask a line, clip a shape, or interpolate the whole layout under an animation curve.

The protocol has exactly one requirement:

protocol TextRenderer: Animatable {
    func draw(layout: Text.Layout, in context: inout GraphicsContext)
}

You attach it with .textRenderer(_:):

Text("Motion is meaning")
    .font(.system(size: 44, weight: .bold, design: .rounded))
    .textRenderer(MyRenderer(progress: progress))

Because the protocol inherits from Animatable, SwiftUI can drive your renderer with any standard animation. The same .spring(response: 0.4, dampingFraction: 0.75) I reach for on every hero transition works here too. That single detail is what makes TextRenderer feel native rather than an escape hatch: it's not a canvas hack, it's a first-class participant in SwiftUI's animation system. Honestly, in my experience, that's the difference between text motion that reads as polish and text motion that reads as a demo.

The Text.Layout hierarchy: lines, runs, and glyphs

Everything you do inside a TextRenderer starts with Text.Layout. It's a sequence of lines; each line is a sequence of runs; each run is a sequence of RunSlice values that ultimately map to glyphs. That three-tier structure matters more than it looks, because most of the decisions you'll make about a text effect are really decisions about which tier to iterate.

Layout
├── Line 1
│   ├── Run 1.1  (regular weight, black)
│   │   ├── Slice ("H")
│   │   ├── Slice ("i")
│   └── Run 1.2  (bold, accent color)
│       ├── Slice ("!")
└── Line 2
    └── Run 2.1
        ├── Slice ("t"), Slice ("h"), Slice ("e"), …

A run is a chunk of glyphs that share styling (think one attribute region), and a single localized string will often become several runs after wrapping. A line follows layout: it changes with Dynamic Type, width, and locale. And a glyph is not always a codepoint. For ligatures, complex scripts, and emoji it can be one glyph representing several characters, which is why the API talks about slices rather than string indices.

Every level exposes a typographicBounds giving you the rect, ascent, descent, and origin. You draw a layout by iterating and calling context.draw(_:) on the elements you actually want to render. Anything you don't draw simply doesn't appear:

func draw(layout: Text.Layout, in context: inout GraphicsContext) {
    for line in layout {
        for run in line {
            context.draw(run)
        }
    }
}

That plain loop is a no-op renderer — it reproduces the default output. Everything interesting starts by mutating context before each draw call, or by drawing extra content between them.

Your first TextRenderer: an animated highlight

Let me start with the effect I ship most often: a sweeping highlight that wipes behind a heading. It's per-line, which means it survives long titles that wrap and reflow with Dynamic Type without me having to special-case anything.

struct HighlightRenderer: TextRenderer {
    var progress: Double        // 0...1, driven by an animation
    var color: Color = .yellow

    var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }

    func draw(layout: Text.Layout, in context: inout GraphicsContext) {
        for line in layout {
            let bounds = line.typographicBounds.rect
            // Grow the highlight from left to right as progress advances.
            let highlight = CGRect(
                x: bounds.minX,
                y: bounds.minY,
                width: bounds.width * progress,
                height: bounds.height
            )
            var behind = context
            behind.fill(
                Path(roundedRect: highlight, cornerRadius: 4),
                with: .color(color.opacity(0.35))
            )
            // Draw the text on top of the highlight.
            context.draw(line)
        }
    }
}

Two habits are worth stealing from that snippet. I always make a copy of the context (var behind = context) before drawing decorations, because GraphicsContext has value semantics and any transform, clip, or opacity I apply won't leak into the text draw that follows. And I always draw the text after the decoration, not before. Otherwise the highlight sits on top and swallows the glyphs.

Driving it is just an @State and an animation:

struct HeadingView: View {
    @State private var progress: Double = 0

    var body: some View {
        Text("Ship the story, not the specs")
            .font(.system(size: 32, weight: .bold, design: .rounded))
            .textRenderer(HighlightRenderer(progress: progress))
            .onAppear {
                withAnimation(.spring(response: 0.55, dampingFraction: 0.9).delay(0.15)) {
                    progress = 1
                }
            }
    }
}

Response 0.55 and damping 0.9 is a deliberately gentle curve. A highlight that overshoots reads as bouncy and cheap. If you're pairing this with a haptic, a single .sensoryFeedback(.impact(weight: .light), trigger: progress) on the parent view keeps the moment tactile without stealing focus from whatever the highlight is trying to sell. For the animation timing side of this, my SwiftUI animations guide has the response and damping numbers I use for most transitions.

How do you animate text per glyph in SwiftUI?

You animate text per glyph in SwiftUI by iterating the Text.Layout down to RunSlice level inside a TextRenderer, translating the context by an index-derived offset before drawing each slice. The renderer conforms to Animatable so a single progress value drives every glyph together.

Here's the wave I use for splash headers. Each letter rises on a phased sine curve as progress goes from 0 to 1:

struct WaveRenderer: TextRenderer {
    var progress: Double            // 0...1
    var amplitude: CGFloat = 18
    var wavelength: Double = 0.35    // glyphs before the wave repeats

    var animatableData: Double {
        get { progress }
        set { progress = newValue }
    }

    func draw(layout: Text.Layout, in context: inout GraphicsContext) {
        // Subpixel snapping causes jitter when glyphs move fractional amounts.
        var context = context
        context.disablesSubpixelQuantization = true

        // Flatten to a single glyph stream so index-based phase is stable
        // across runs (mixed weights) within one line.
        let glyphs = layout.flatMap { line in line.flatMap { run in run } }
        let total = max(glyphs.count - 1, 1)

        for (index, slice) in glyphs.enumerated() {
            let phase = Double(index) / Double(total) / wavelength
            let wave = sin((progress + phase) * .pi * 2)
            let lift = -amplitude * CGFloat(wave) * CGFloat(progress)

            var copy = context
            copy.translateBy(x: 0, y: lift)
            copy.draw(slice)
        }
    }
}

Two things earn their keep here. First, disablesSubpixelQuantization = true. Without it, glyphs at fractional y offsets get snapped to whole pixels and the motion visibly steps instead of gliding. Second, I flatten glyphs to a single sequence so mixed weights in one line share a stable index. If I iterated line → run → slice with a per-run counter, the phase would reset at every bold word and the wave would look broken.

Driving it from a repeating animation lands the classic loading-title effect:

@State private var t: Double = 0

Text("Loading data")
    .font(.system(size: 28, weight: .semibold, design: .rounded))
    .textRenderer(WaveRenderer(progress: t))
    .onAppear {
        withAnimation(.linear(duration: 1.6).repeatForever(autoreverses: false)) {
            t = 1
        }
    }

A few pragmatic notes from shipping this in production. On a 120Hz ProMotion display, glyph-level offsets cost real GPU cycles once the string gets long, so I cap wave effects at ~24 glyphs and switch to run-level motion for anything longer. I also gate the animation on @Environment(\.accessibilityReduceMotion) and fall back to a fade for people who've opted out. And if this is loading state, I strongly prefer pairing it with a subtle continuous haptic (an .ahap pattern from Core Haptics) over adding more motion. Motion carries attention; haptics confirm progress. Different jobs.

Targeting specific words with TextAttribute

Everything so far treats the entire Text uniformly. When you want an effect on only the accent word (the pattern that used to require NSAttributedString in UIKit), the tool is the TextAttribute protocol. You define a marker attribute, attach it to a slice using customAttribute(_:) inside a Text composition, and read it back in the renderer.

// 1. Declare the attribute.
struct PulseAttribute: TextAttribute {}

// 2. Attach it to part of a Text.
let phrase: Text = Text("Design that ") +
    Text("feels").customAttribute(PulseAttribute()) +
    Text(" right")

// 3. Read it in the renderer, per run.
struct PulseRenderer: TextRenderer {
    var progress: Double
    var animatableData: Double {
        get { progress } set { progress = newValue }
    }

    func draw(layout: Text.Layout, in context: inout GraphicsContext) {
        for line in layout {
            for run in line {
                if run[PulseAttribute.self] != nil {
                    // Scale the marked run about its own center.
                    let bounds = run.typographicBounds.rect
                    let scale = 1 + 0.08 * sin(progress * .pi * 2)
                    var copy = context
                    copy.translateBy(x: bounds.midX, y: bounds.midY)
                    copy.scaleBy(x: scale, y: scale)
                    copy.translateBy(x: -bounds.midX, y: -bounds.midY)
                    copy.draw(run)
                } else {
                    context.draw(run)
                }
            }
        }
    }
}

Two design choices matter here. I read the attribute at the run level, not the slice level, because that's where SwiftUI has already grouped consecutive characters sharing that attribute, so one lookup gives me the whole marked span. And I scale around the bounds' center rather than the origin so the word breathes in place instead of drifting.

Because TextAttribute composes cleanly with SwiftUI's Text concatenation, the same phrase reflows through localization without breaking the effect. The accent stays on the semantically marked word even if the translation reorders it. That's the property that makes this feel like the right SwiftUI-native replacement for old NSAttributedString keys. If you're coming from that world, my SwiftUI AttributedString guide explains where each tool fits.

Dynamic Type, RTL, and localization

The single biggest mistake I see with TextRenderer is baking in string-length assumptions. A wave that indexes into "Hello" won't survive being translated to a language with different word count, script direction, or ligature behavior, and it definitely won't survive Dynamic Type bumping the layout onto a new line.

Three habits keep effects safe:

  1. Iterate what the layout gives you, not what you typed. Never hardcode "there are 5 glyphs." Loop the layout and let it tell you how many runs and slices exist at the current size and locale.
  2. Prefer run- and line-level effects over glyph-level ones for shipped features. Runs follow attributed regions, lines follow layout; both track localization and Dynamic Type natively. Glyph animation is beautiful in demos and expensive in bugs.
  3. Test at the two extremes of accessibility Dynamic Type. Set the preview environment to .dynamicTypeSize(.accessibility5) and to .xSmall, both. A wave that looks great at the default size can overshoot a two-line wrap at AX5.

For right-to-left scripts, Text.Layout already places glyphs in visual order, so a wave that phases by index reads left-to-right in English and right-to-left in Arabic and Hebrew, with no branching needed. The one thing to watch is that typographicBounds.rect.minX is the leading edge, not the reading start, so any effect that "sweeps in from the start" should key off the layout's own reading direction via @Environment(\.layoutDirection) rather than assuming minX. Also gate anything animated on @Environment(\.accessibilityReduceMotion). I use the same pattern I described in my contentTransition guide.

Performance: subpixel quantization, ProMotion, and CPU cost

TextRenderer runs on every frame of an animation, which means everything you do in draw(layout:in:) is on the critical path. In practice I've hit three cliffs that are worth knowing before you ship.

Subpixel snapping. Set context.disablesSubpixelQuantization = true whenever glyphs move by fractional pixels. Without it SwiftUI snaps each glyph to the nearest pixel row on every frame, and a smooth 60 or 120Hz motion becomes a visibly stepped one. The cost is negligible; skipping it is the single most common "why does my animation look janky?" answer.

Per-glyph iteration on long strings. Iterating RunSlice at 120Hz across a 200-character paragraph will stall the main thread on older devices. If the string is long, animate the line or the run instead of the slice. When I do need per-glyph motion on a long string, I limit the "active" window and animate only glyphs within, say, 6 indices of the current progress cursor, leaving the rest alone.

Overuse of GraphicsContext operations. Each context copy is cheap, but each context.filter, blur, or blend mode isn't. Those are backed by real Metal work. Profile with Time Profiler and the SwiftUI template in Instruments before assuming a slow scroll is layout's fault. For a walkthrough of the SwiftUI Instruments template, see my Xcode Instruments guide.

TextRenderer vs contentTransition vs AttributedString

These three APIs get conflated more often than any others in SwiftUI's text stack, mostly because their marketing overlaps. In practice they solve genuinely different problems, and picking the wrong one costs you a week.

DimensionTextRenderercontentTransitionAttributedString
Primary jobCustom drawing of TextTransition between two Text valuesStyling characters within Text
Runs on every frameYesOnly during transitionNo, static styling
Per-glyph controlYes (via RunSlice)Numeric/symbol onlyNo
Handles value changesManual via progressAutomatic on state changeN/A
Ideal use caseHighlights, waves, morphsCounters, tickers, symbol swapsMixed weights, colors, links
Available sinceiOS 18 / macOS 15iOS 16 / macOS 13iOS 15 / macOS 12

My rule of thumb: if the same string is on screen and I want it to do something visual, that's TextRenderer. If two different strings need to swap smoothly (a live counter, a status label), that's contentTransition, which I covered in depth in the iOS 26 contentTransition guide. If nothing is animating and I just need bold accent words or inline color, that's AttributedString. The three compose fine: a highlighted heading can use AttributedString for the accent word, TextRenderer for the wipe, and contentTransition when the whole heading changes.

Frequently Asked Questions

Is TextRenderer available on iOS 17?

No. TextRenderer and the .textRenderer(_:) modifier require iOS 18 or later (macOS 15, watchOS 11, tvOS 18). Everything in this guide runs on iOS 26 with the same signatures. For pre-iOS 18 fallbacks, layer an AttributedString for styling and a manual @State-driven overlay for motion.

Can TextRenderer be used with Dynamic Type?

Yes, and it should be. Text.Layout is recomputed at every Dynamic Type size before draw(layout:in:) is called, so effects that iterate the layout survive size changes automatically. Test at .dynamicTypeSize(.accessibility5) to catch effects that assume a single line.

Does TextRenderer support right-to-left languages?

Yes. Text.Layout places glyphs in visual order for both LTR and RTL scripts, so index-based effects like a wave phase naturally reverse for Arabic and Hebrew without any extra code. If your effect keys off minX, wrap it in an @Environment(\.layoutDirection) check so it stays anchored to the reading start.

How is TextRenderer different from AttributedString?

AttributedString defines what a Text is: its content, weight, color, links. TextRenderer defines how a Text is drawn, the pixels that hit the screen every frame. They compose: attribute the string, then hand it to a renderer that reads the attributes back via TextAttribute.

Why do my animated glyphs look jittery?

Ninety percent of the time it's subpixel quantization. Set context.disablesSubpixelQuantization = true at the top of draw(layout:in:) whenever glyphs move by fractional pixel amounts. Without it, each glyph snaps to the nearest pixel row on every frame, producing visible stepping instead of smooth motion.

Diana Kowalski
About the Author Diana Kowalski

Mobile UX engineer translating design intent into pixel-perfect SwiftUI. Has strong opinions about haptics.