Bouncy Letters Text
Letters that hop one after another in a repeating wave with squash and stretch.

- Text
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- bounce · hop · letters · keyframes · text animation · squash and stretch · playful · rebote · saltar · letras · fotogramas clave · animación de texto · divertido
Description
Section titled “Description”Letters that hop one after another, in a repeating wave.
Letters are laid out one by one, so it suits a short single line. With Reduce Motion on, it is plain text.
struct BouncyLettersTextDemo: View { var body: some View { ZStack { LinearGradient(colors: [.orange, .pink], startPoint: .topLeading, endPoint: .bottomTrailing) .ignoresSafeArea()
VStack(spacing: 56) { BouncyLettersText(text: "Boing!") .font(.system(.largeTitle, design: .rounded, weight: .black)) .scaleEffect(1.5)
BouncyLettersText(text: "Hop in sequence", stagger: 0.05) .font(.system(.title2, design: .rounded, weight: .bold)) } .foregroundStyle(.white) } }}API reference
Section titled “API reference”| Parameter | Type | Default | Description |
|---|---|---|---|
text | String | Required | |
stagger | Double | 0.08 | Seconds between one letter's hop and the next one's. |
pause | Double | 0.7 | Seconds of rest after the last letter lands. |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// Letters that hop one after another, in a repeating wave.////// Letters are laid out one by one, so it suits a short single line./// With Reduce Motion on, it is plain text.struct BouncyLettersText: View { let text: String /// Seconds between one letter's hop and the next one's. var stagger: Double = 0.08 /// Seconds of rest after the last letter lands. var pause: Double = 0.7
@ScaledMetric(relativeTo: .largeTitle) private var hopHeight = 28.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { if reduceMotion { Text(text) } else { let letters = Array(text)
HStack(spacing: 0) { ForEach(letters.indices, id: \.self) { index in letter(letters[index], index: index, count: letters.count) } } .accessibilityElement(children: .ignore) .accessibilityLabel(text) } }
/// Every letter's timeline has the same total length, so they stay in step as they repeat. private func letter(_ character: Character, index: Int, count: Int) -> some View { let firstLead = 0.001 let rise = 0.16 let fall = 0.16 let settle = 0.2 let period = firstLead + Double(count - 1) * stagger + rise + fall + settle + pause let lead = max(Double(index) * stagger, firstLead) let rest = period - lead - rise - fall - settle let height = hopHeight
return Text(String(character)) .accessibilityRepresentation { EmptyView() } .keyframeAnimator(initialValue: BouncyLetterPose(), repeating: true) { view, pose in view .scaleEffect(x: 2 - pose.stretch, y: pose.stretch, anchor: .bottom) .offset(y: pose.lift) } keyframes: { _ in KeyframeTrack(\.lift) { LinearKeyframe(0, duration: lead) CubicKeyframe(-height, duration: rise) CubicKeyframe(0, duration: fall) LinearKeyframe(0, duration: settle + rest) } KeyframeTrack(\.stretch) { LinearKeyframe(1, duration: lead) CubicKeyframe(1.14, duration: rise) CubicKeyframe(0.8, duration: fall) SpringKeyframe(1, duration: settle, spring: .bouncy) LinearKeyframe(1, duration: rest) } } }}
private struct BouncyLetterPose { var lift = 0.0 var stretch = 1.0}
// MARK: - Demo
struct BouncyLettersTextDemo: View { var body: some View { ZStack { LinearGradient(colors: [.orange, .pink], startPoint: .topLeading, endPoint: .bottomTrailing) .ignoresSafeArea()
VStack(spacing: 56) { BouncyLettersText(text: "Boing!") .font(.system(.largeTitle, design: .rounded, weight: .black)) .scaleEffect(1.5)
BouncyLettersText(text: "Hop in sequence", stagger: 0.05) .font(.system(.title2, design: .rounded, weight: .bold)) } .foregroundStyle(.white) } }}
#Preview { BouncyLettersTextDemo()}