Skip to content
Strikethrough Reveal preview
An animated render of the SwiftUI source on this page.

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

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27