Skip to content

Bouncy Letters Text

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

Bouncy Letters Text, playing
  • Text
  • iOS 18.0+
  • intermediate
Download .swift
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

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)
}
}
}
ParameterTypeDefaultDescription
textStringRequired
staggerDouble0.08Seconds between one letter's hop and the next one's.
pauseDouble0.7Seconds of rest after the last letter lands.

Copy this code into your project, or download the file.

BouncyLettersText.swift
// 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()
}