
Strikethrough Reveal
From syxUI — written for both platforms, not translated between them.
A task striking itself out — line drawn, colour drained, one small dip.
Text Effects · SwiftUI · Flutter · iOS 17 · Flutter 3.27
- text
- strikethrough
- complete
- task
- checklist
- reveal
- animation
The actual source
StrikethroughReveal.swift
// Strikethrough Reveal · syxUI · https://syxui.dev/components/strikethrough-reveal
// Free in any project. Keep this line and credit syxUI where a person can read it.
import SwiftUI
/// A task striking itself out — line drawn, colour drained, one small dip.
///
/// `a` is a single 0...1 amount for how struck-through the text is. Every
/// sub-effect — the line's width, the fade lag, the saturation drain, the
/// settle dip — is a pure function of that one number, which is what makes
/// `done: false` retract for free: it is the same formulas run toward a
/// smaller target, not a second code path. Do NOT use `.strikethrough()`: it
/// is binary and cannot be animated, which is the mistake this component
/// exists to avoid.
struct StrikethroughReveal: View {
var text: String = "Ship the component"
var done: Bool = true
var duration: Double = 320
var lineColor: Color = Color(red: 0.541, green: 0.561, blue: 0.596)
var fade: Double = 0.45
var tick: Bool = true
/// Preview override. When set, renders that point of the strike.
var progress: Double? = nil
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var animated: Double = 0
private var a: Double {
if reduceMotion { return done ? 1 : 0 }
return progress ?? animated
}
var body: some View {
HStack(spacing: 10) {
if tick {
tickMark
.frame(width: 16, height: 16)
.accessibilityHidden(true)
}
Text(text)
.font(.system(size: 20, weight: .medium))
.opacity(1 - (1 - fade) * lag)
.saturation(1 - 0.85 * lag)
.overlay(alignment: .leading) {
GeometryReader { g in
Capsule()
.fill(lineColor)
.frame(width: g.size.width * a, height: 1.6)
.position(x: g.size.width * a / 2, y: g.size.height / 2)
}
.accessibilityHidden(true)
}
}
.scaleEffect(dipScale)
.onAppear(perform: start)
.onChange(of: done) { _, _ in start() }
.accessibilityElement(children: .ignore)
.accessibilityLabel(text)
.accessibilityValue(done ? "Done" : "Not done")
}
/// The text's own fade lags the line: it only starts once the stroke is
/// 40% of the way across, so the strike visibly leads the fade instead of
/// everything dimming at once.
private var lag: Double { min(max((a - 0.4) / 0.6, 0), 1) }
private var dipScale: Double {
guard a > 0, a < 1 else { return 1 }
// Approximates `.spring(response: 0.30, dampingFraction: 0.70)`: a
// quick dip to 0.985 that overshoots slightly on the way back before
// settling, well before the stroke itself finishes.
let t = min(a / 0.55, 1)
let decay = exp(-4.2 * t)
let wobble = cos(7.5 * t)
return 1 - 0.015 * decay * wobble
}
private var tickMark: some View {
Path { path in
path.move(to: CGPoint(x: 1, y: 8))
path.addLine(to: CGPoint(x: 6, y: 13))
path.addLine(to: CGPoint(x: 15, y: 2))
}
.trim(from: 0, to: a)
.stroke(lineColor, style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round))
}
private func start() {
guard progress == nil, !reduceMotion else { return }
withAnimation(.easeInOut(duration: duration / 1000)) {
animated = done ? 1 : 0
}
}
}
#Preview {
StrikethroughReveal()
.frame(width: 380, height: 120)
.padding(24)
}
iOS 17 · No dependencies
SwiftUI note. The dip is a closed-form approximation of a spring, not a real `.spring()` call — it has to be a pure function of `a` so `progress` can sample it deterministically for the preview.
Dependencies
- SwiftUI
- No external dependencies
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


