Draw Animated Text Effects With SwiftUI TextRenderer
Text is already a pretty capable SwiftUI view. It handles Dynamic Type, localization, line wrapping, truncation, text selection behavior in the right contexts, and the accessibility tree. The hard part starts when you want the text to draw in a custom way without giving all of that up.
TextRenderer is SwiftUI’s tool for that job. Instead of turning text into an image or stacking decorative views around it, you can tell SwiftUI how to draw the laid-out text. The renderer receives a Text.Layout, which exposes lines, runs, and run slices, and a GraphicsContext, which is the same drawing destination used by Canvas.
The examples below target iOS 18, iPadOS 18, and macOS 15 or newer. The API arrived with the aligned 2024 SwiftUI releases, including tvOS 18, visionOS 2, and watchOS 11. If your app supports older OS versions, keep this inside an availability-gated view and render the same Text without the custom renderer as the fallback.
Start with the smallest renderer
A minimal TextRenderer usually only has to implement draw(layout:in:):
import SwiftUI
struct PlainTextRenderer: TextRenderer {
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
for line in layout {
context.draw(line)
}
}
}
This renderer is intentionally boring. It walks through every line in the layout and asks the graphics context to draw the line. That gives you the default visual result, but it proves the shape of the API: SwiftUI lays the text out, then your renderer decides how the layout is drawn.
You attach a renderer with the textRenderer(_:) modifier:
Text("TextRenderer keeps this as real SwiftUI text.")
.font(.title)
.textRenderer(PlainTextRenderer())
The modifier applies to text views in the modified view tree. That means you can put it on one Text, a heading container, or a whole area of a screen when the effect is shared.
Mark the text you want to treat differently
Most effects should not apply to every glyph. For a reveal highlight, the useful pattern is to mark one part of a composed Text value and let the renderer find that mark later.
SwiftUI gives us TextAttribute for this. It is a small Hashable value that can be attached to Text and queried from the layout runs the renderer receives.
struct RevealHighlightAttribute: TextAttribute {}
extension Text {
func revealHighlight() -> Text {
customAttribute(RevealHighlightAttribute())
}
}
Now the app can build normal text, but interpolate an annotated Text value for the range that should get the effect:
let headline = Text(
"Build a renderer that keeps \(Text("text as text").revealHighlight())."
)
That is still a Text value. VoiceOver does not need to know about your drawing code to read the sentence, and Dynamic Type can still change the layout. The attribute is just extra information for the renderer.
Draw the highlight behind each marked run
The renderer can inspect each run for your custom attribute. A run is a chunk of placed glyphs that share layout and attributes. One source string can become more than one run after styling, localization, or line wrapping, so the renderer should be comfortable drawing the effect more than once.
This version draws a rounded highlight behind any run marked with RevealHighlightAttribute, then draws the run itself:
struct HighlightRevealRenderer: TextRenderer {
var progress: Double
var color: Color = .yellow
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
let revealProgress = min(max(progress, 0), 1)
for run in layout.flattenedRuns {
if run[RevealHighlightAttribute.self] != nil {
var highlightContext = context
drawHighlight(
behind: run,
progress: revealProgress,
in: &highlightContext
)
}
context.draw(run, options: .disablesSubpixelQuantization)
}
}
private func drawHighlight(
behind run: Text.Layout.Run,
progress: Double,
in context: inout GraphicsContext
) {
guard progress > 0 else { return }
let bounds = run.typographicBounds.rect.insetBy(dx: -4, dy: -2)
let revealedWidth = bounds.width * progress
let revealedMinX = run.layoutDirection == .rightToLeft
? bounds.maxX - revealedWidth
: bounds.minX
let revealedRect = CGRect(
x: revealedMinX,
y: bounds.minY,
width: revealedWidth,
height: bounds.height
)
context.fill(
Path(roundedRect: revealedRect, cornerRadius: 5),
with: .color(color.opacity(0.35))
)
}
}
private extension Text.Layout {
var flattenedRuns: [Text.Layout.Run] {
flatMap { line in
line.map { run in run }
}
}
}
There are a few details worth keeping.
First, the renderer draws the highlight before it draws the text. If you draw the text first, the highlight covers the glyphs instead of sitting behind them.
Second, the highlight uses a copy of the graphics context. GraphicsContext has value semantics, so changing a copy is a clean way to isolate the fill operation from the text drawing that follows. That habit matters more once you start adding filters, transforms, clips, opacity, or blend modes.
Third, context.draw(run, options: .disablesSubpixelQuantization) opts out of text subpixel quantization for the animated draw. Apple calls this option out as useful for preventing jitter while text moves or animates. Even though this example only animates the background width, it is a good option to know when your renderer starts translating individual lines, runs, or glyph slices.
Animate it from a view
Because TextRenderer inherits from Animatable, SwiftUI can interpolate progress for us. The view only decides when the reveal should be complete.
struct HighlightRevealExample: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isRevealed = false
var body: some View {
headline
.font(.system(.largeTitle, design: .rounded, weight: .semibold))
.lineSpacing(6)
.textRenderer(
HighlightRevealRenderer(
progress: isRevealed || reduceMotion ? 1 : 0,
color: .yellow
)
)
.onAppear {
let animation: Animation? = reduceMotion
? nil
: .easeOut(duration: 0.7)
withAnimation(animation) {
isRevealed = true
}
}
}
private var headline: Text {
Text(
"Build a renderer that keeps \(Text("text as text").revealHighlight())."
)
}
}
This is a small effect, but the architecture scales. The view owns the state. The Text owns the words and semantic annotations. The renderer owns drawing.
Keep the words responsible for meaning
It is tempting to make the highlight carry too much meaning. Try not to. Treat the effect as emphasis, not as the only way to understand the interface.
For accessibility, the nice thing about TextRenderer is that you are still rendering Text. The content can remain readable by assistive technologies, localizable as text, and responsive to Dynamic Type. The renderer should not replace those responsibilities with pixels.
I usually check these points before shipping a custom text effect:
- The sentence still makes sense if the renderer is removed.
- Reduced Motion either disables the animation or switches to the completed state.
- The highlighted color has enough contrast in light and dark mode.
- Large Dynamic Type sizes still produce sensible highlight rectangles when the text wraps.
- The effect is not required to understand validation errors, destructive actions, prices, or other important state.
There is also a performance angle. Renderers can run often because text may be laid out and redrawn as state changes. Keep the draw loop direct, avoid doing model work inside it, and prefer data that is already on the renderer.
Where to go next
Once the run-based version works, you can move down to Text.Layout.RunSlice for glyph-level or attachment effects. That is useful for more expressive transitions, but it is also easier to overdo. Line and run effects tend to hold up better across localization and Dynamic Type because they follow the layout instead of assuming a fixed string shape.
The important shift is that you no longer have to choose between custom drawing and real text. With TextRenderer, SwiftUI gives you the laid-out text structure and lets you draw the part that needs to be special.
Further reading:
- Apple documentation: https://developer.apple.com/documentation/swiftui/textrenderer
- Apple documentation: https://developer.apple.com/documentation/swiftui/textattribute
- Apple documentation: https://developer.apple.com/documentation/swiftui/text/layout
- WWDC24: https://developer.apple.com/videos/play/wwdc2024/10151/