Skip to content

Karaoke Lyrics Text

Lyrics that start dim and light up from the first letter to the last as they are sung.

Karaoke Lyrics Text, playing
  • Text
  • iOS 18.0+
  • intermediate
Download .swift
Origin
Original work
Found by
karaoke · lyrics · sing along · text fill · highlight · music · text renderer · karaoke · letra · letras de canciones · cantar · relleno de texto · resaltado · música

Lyrics that light up as they are sung: the text starts dim and a brighter copy sweeps over it.

Set progress from 0 to 1 and animate it over the time the line takes. The text keeps its font, color and alignment, wraps like any other, and follows the direction of its language. With Reduce Motion on, each line lights up at once instead of filling.

struct KaraokeLyricsTextDemo: View {
private let lines = [
"City lights are calling",
"We run where the night begins",
"Hold on to the echo",
"Until the morning sings",
]
private let secondsPerLine = 2.2
@State private var current = 0
@State private var progress = 0.0
var body: some View {
VStack(alignment: .leading, spacing: 22) {
Label("Night Run", systemImage: "music.mic")
.font(.headline)
.foregroundStyle(.white.opacity(0.7))
.padding(.bottom, 8)
ForEach(lines.indices, id: \.self) { index in
KaraokeLyricsText(text: lines[index], progress: index < current ? 1 : (index == current ? progress : 0))
.font(.system(.largeTitle, design: .rounded, weight: .heavy))
.foregroundStyle(.white)
}
}
.padding(28)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(LinearGradient(colors: [.indigo, .purple, .pink], startPoint: .topLeading, endPoint: .bottomTrailing).ignoresSafeArea())
.task { await sing() }
}
private func sing() async {
while !Task.isCancelled {
for index in lines.indices {
current = index
progress = 0
withAnimation(.linear(duration: secondsPerLine)) { progress = 1 }
try? await Task.sleep(for: .seconds(secondsPerLine + 0.25))
guard !Task.isCancelled else { return }
}
try? await Task.sleep(for: .seconds(0.8))
current = 0
progress = 0
}
}
}
ParameterTypeDefaultDescription
textStringRequired
progressDoubleRequiredHow much of the text has been sung, from 0 to 1.
restOpacityDouble0.35The opacity of the part that has not been sung yet.

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

KaraokeLyricsText.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
nonisolated enum KaraokeLyricsTextMath {
/// How much of each line has been sung, from 0 to 1, when `progress` of the whole text has.
///
/// Lines fill in order and a wider line takes longer, so the light moves at one speed.
static func fills(widths: [Double], progress: Double) -> [Double] {
let lengths = widths.map { $0.isFinite ? max($0, 0) : 0 }
let total = lengths.reduce(0, +)
guard total > 0, progress.isFinite else { return Array(repeating: 0, count: widths.count) }
var remaining = min(max(progress, 0), 1) * total
return lengths.map { length in
guard length > 0 else { return 0 }
let sung = min(remaining, length)
remaining -= sung
return sung / length
}
}
/// The same fills without the sweep: a line lights up whole as soon as it starts.
static func wholeLines(_ fills: [Double]) -> [Double] {
fills.map { $0 > 0 ? 1 : 0 }
}
}
/// Lyrics that light up as they are sung: the text starts dim and a brighter copy sweeps over it.
///
/// Set `progress` from 0 to 1 and animate it over the time the line takes. The text keeps its font,
/// color and alignment, wraps like any other, and follows the direction of its language.
/// With Reduce Motion on, each line lights up at once instead of filling.
struct KaraokeLyricsText: View {
let text: String
/// How much of the text has been sung, from 0 to 1.
var progress: Double
/// The opacity of the part that has not been sung yet.
var restOpacity: Double = 0.35
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
Text(text)
.textRenderer(KaraokeLyricsTextRenderer(progress: progress, restOpacity: restOpacity, sweeps: !reduceMotion))
}
}
struct KaraokeLyricsTextRenderer: TextRenderer {
var progress: Double
var restOpacity: Double
var sweeps: Bool
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func draw(layout: Text.Layout, in context: inout GraphicsContext) {
let lines = Array(layout)
let filled = KaraokeLyricsTextMath.fills(widths: lines.map { Double($0.typographicBounds.rect.width) }, progress: progress)
let fills = sweeps ? filled : KaraokeLyricsTextMath.wholeLines(filled)
for (line, fill) in zip(lines, fills) {
var rest = context
rest.opacity = min(max(restOpacity, 0), 1)
rest.draw(line)
guard fill > 0 else { continue }
// The sung part starts where the line starts to be read, which is the right edge in a right-to-left script.
let bounds = line.typographicBounds.rect
let width = bounds.width * fill
let startsAtRight = line.first?.layoutDirection == .rightToLeft
let sung = CGRect(x: startsAtRight ? bounds.maxX - width : bounds.minX, y: bounds.minY, width: width, height: bounds.height)
var bright = context
bright.clip(to: Path(sung))
bright.draw(line)
}
}
}
// MARK: - Demo
struct KaraokeLyricsTextDemo: View {
private let lines = [
"City lights are calling",
"We run where the night begins",
"Hold on to the echo",
"Until the morning sings",
]
private let secondsPerLine = 2.2
@State private var current = 0
@State private var progress = 0.0
var body: some View {
VStack(alignment: .leading, spacing: 22) {
Label("Night Run", systemImage: "music.mic")
.font(.headline)
.foregroundStyle(.white.opacity(0.7))
.padding(.bottom, 8)
ForEach(lines.indices, id: \.self) { index in
KaraokeLyricsText(text: lines[index], progress: index < current ? 1 : (index == current ? progress : 0))
.font(.system(.largeTitle, design: .rounded, weight: .heavy))
.foregroundStyle(.white)
}
}
.padding(28)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(LinearGradient(colors: [.indigo, .purple, .pink], startPoint: .topLeading, endPoint: .bottomTrailing).ignoresSafeArea())
.task { await sing() }
}
private func sing() async {
while !Task.isCancelled {
for index in lines.indices {
current = index
progress = 0
withAnimation(.linear(duration: secondsPerLine)) { progress = 1 }
try? await Task.sleep(for: .seconds(secondsPerLine + 0.25))
guard !Task.isCancelled else { return }
}
try? await Task.sleep(for: .seconds(0.8))
current = 0
progress = 0
}
}
}
#Preview {
KaraokeLyricsTextDemo()
}