Swift PiecesFree
AI

Thinking State

The "assistant is working" placeholder, laid out like the reply it becomes: a breathing mark with an elapsed-seconds label over three presentations on one clock: block-colored rising dots, a sheen over reply-shaped bars, or a label whose glyphs carry the sheen. Plus a modifier that sweeps any view.

Free · MIT + Commons ClauseiOS 17.0+loadingthinkingsheenplaceholderaielapsed
Type
ThinkingState
Files
ThinkingState.swift
Depends on
Nothing (Apple frameworks only)
Version
2.0.0

In a full screen · Swift Pieces Pro

Agent Task

An agent task screen with a live timeline of tool calls, durations and expandable results.

Placeholder for a reply that has not started. Every presentation mirrors the StreamingReply layout (mark and label, then content) so it resolves into the real message without a layout jump.

Notes

  • Every presentation shares the StreamingReply layout: a header (breathing ink mark, name and a THINKING · 3S elapsed label that counts up) and then content sized to a body line, so swapping ThinkingState for a StreamingReply does not shift anything. Set style.showsHeader = false for content only.
  • .dots rises three block-colored dots (tangerine, sky, lilac by default), .sheen sweeps a band across reply-shaped bars, and .text(label) sweeps the band through the label's glyphs.
  • One TimelineView drives the mark's breath, the dots and the sheen, so they stay phase-locked. duration sets the sweep period.
  • Pass tint to use one color for the dots, mark and sheen.
  • .thinkingState(_:tint:) masks a bright sweep to the view's own alpha, so it follows capsules, glyphs and text with no clipping setup, and fades out when isActive turns false. It reads best on solid blocks.
  • Style (ThinkingState.Style): dots, bar, sheen, mark, markInk, ink, text, muted, assistantName, showsHeader.
  • Under Reduce Motion the clock holds still: the band sits mid-view, the dots rest and the mark keeps a steady size; only the elapsed seconds update.
  • Need a live timeline of tool calls with durations and results? That is the Tool Execution Card screen in Swift Pieces Pro.

Usage

ThinkingStateExample().preferredColorScheme(.light)

Parameters

ParameterDescription
presentation.dots, .sheen (reply-shaped bars), or .text(label).
tintOptional single color for the dots, mark and sheen. nil uses the house blocks for the dots and a neutral sheen.
lineCountNumber of placeholder bars in the .sheen presentation.
durationSeconds per sheen sweep; the dots and mark breathe on the same clock.
styleColors, the assistant name and whether the header with elapsed seconds shows. Defaults to the house palette.

Source

ThinkingState.swift
import SwiftUI
import UIKit

/// Placeholder for a reply that has not started. Every presentation mirrors the `StreamingReply` layout (mark and label, then content) so it resolves into the real message without a layout jump.
///
/// - Parameters:
///   - presentation: `.dots`, `.sheen` (reply-shaped bars), or `.text(label)`.
///   - tint: Optional single color for the dots, mark and sheen. `nil` uses the house blocks for the dots and a neutral sheen.
///   - lineCount: Number of placeholder bars in the `.sheen` presentation.
///   - duration: Seconds per sheen sweep; the dots and mark breathe on the same clock.
///   - style: Colors, the assistant name and whether the header with elapsed seconds shows. Defaults to the house palette.
public struct ThinkingState: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var start = Date()

    private let presentation: Presentation
    private let tint: Color?
    private let lineCount: Int
    private let duration: Double
    private let style: Style

    public enum Presentation: Equatable, Sendable {
        case dots
        case sheen
        case text(String)
    }

    public init(_ presentation: Presentation = .sheen, tint: Color? = nil, lineCount: Int = 3, duration: Double = 2.2, style: Style = .standard) {
        self.presentation = presentation
        self.tint = tint
        self.lineCount = max(1, lineCount)
        self.duration = duration
        self.style = style
    }

    public var body: some View {
        TimelineView(.animation(minimumInterval: reduceMotion ? 1 : nil, paused: false)) { context in
            let t = reduceMotion ? 0.5 : context.date.timeIntervalSinceReferenceDate
            let sweep = reduceMotion ? 0.5 : t.truncatingRemainder(dividingBy: duration) / duration
            let breath = reduceMotion ? 0.5 : (sin(t * 2 * .pi / duration) + 1) / 2
            let elapsed = Int(context.date.timeIntervalSince(start))
            VStack(alignment: .leading, spacing: 10) {
                if style.showsHeader { header(breath: breath, elapsed: elapsed) }
                content(time: t, sweep: sweep)
            }
        }
        .padding(.vertical, 4)
        .padding(.trailing, 24)
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(label)
        .accessibilityAddTraits(.updatesFrequently)
    }

    private var label: String {
        if case .text(let text) = presentation { return text }
        return "Thinking"
    }

    /// The reply header while it works: a breathing mark, the name, and seconds so far.
    private func header(breath: Double, elapsed: Int) -> some View {
        HStack(spacing: 8) {
            ZStack {
                Circle()
                    .fill(tint ?? style.mark)
                    .scaleEffect(0.84 + 0.16 * breath)
                Image(systemName: "sparkle")
                    .font(.system(size: 11, weight: .bold))
                    .foregroundStyle(tint == nil ? style.markInk : style.ink)
                    .rotationEffect(.degrees(reduceMotion ? 0 : breath * 45))
            }
            .frame(width: 24, height: 24)
            Text(style.assistantName)
                .font(.subheadline.weight(.semibold))
                .foregroundStyle(style.text)
            Text("THINKING · \(elapsed)S")
                .font(.caption2.weight(.bold))
                .tracking(0.8)
                .monospacedDigit()
                .foregroundStyle(style.muted)
                .contentTransition(.numericText(value: Double(elapsed)))
        }
    }

    @ViewBuilder
    private func content(time: Double, sweep: Double) -> some View {
        switch presentation {
        case .dots:
            HStack(spacing: 6) {
                ForEach(0..<3, id: \.self) { index in
                    let phase = reduceMotion ? 0.5 : ((time * 0.9 - Double(index) * 0.16).truncatingRemainder(dividingBy: 1) + 1).truncatingRemainder(dividingBy: 1)
                    let lift = phase < 0.45 ? sin(phase / 0.45 * .pi) : 0
                    Circle()
                        .fill(tint ?? style.dots[index % max(style.dots.count, 1)])
                        .frame(width: 9, height: 9)
                        .scaleEffect(0.8 + 0.2 * lift)
                        .offset(y: -5 * lift)
                }
            }
            .frame(height: lineHeight)
        case .sheen:
            bars
                .overlay { ThinkingSheen(sweep: sweep, color: tint ?? style.sheen, strength: tint == nil ? 1 : 0.6).mask(bars) }
        case .text(let text):
            let label = Text(text).font(.body)
            label
                .foregroundStyle(style.muted)
                .overlay { ThinkingSheen(sweep: sweep, color: tint ?? style.text, strength: 1).mask(label) }
        }
    }

    /// Reply-shaped placeholder bars: full, slightly short, and a trailing short bar.
    private var bars: some View {
        VStack(alignment: .leading, spacing: 9) {
            ForEach(0..<lineCount, id: \.self) { index in
                Capsule()
                    .fill(style.bar)
                    .frame(height: lineHeight * 0.6)
                    .padding(.trailing, index == lineCount - 1 ? 110 : (index.isMultiple(of: 2) ? 0 : 32))
            }
        }
        .frame(minHeight: lineHeight)
    }

    private var lineHeight: CGFloat { UIFont.preferredFont(forTextStyle: .body).lineHeight }
}

public extension ThinkingState {
    /// Look of a `ThinkingState`. Start from `.standard` and change what you need.
    struct Style: Sendable {
        /// The three dots, in order, unless `tint` is passed.
        public var dots: [Color] = [Color(red: 1, green: 0, blue: 0), Color(red: 0.612, green: 0.761, blue: 1), Color(red: 0.804, green: 0.722, blue: 1)]
        /// Placeholder bar fill.
        public var bar: Color = Style.adaptive(0xE4E2DC, 0x262626)
        /// The band that sweeps the bars.
        public var sheen: Color = Style.adaptive(0xFFFFFF, 0x3A3937)
        /// Mark disc and its glyph.
        public var mark: Color = Style.adaptive(0x141414, 0xF4F3EF)
        public var markInk: Color = Style.adaptive(0xF3F2EE, 0x121212)
        /// Dark ink on a tinted mark.
        public var ink: Color = Color(red: 0.078, green: 0.078, blue: 0.078)
        /// Name and the swept label's highlight.
        public var text: Color = Style.adaptive(0x141414, 0xF4F3EF)
        /// Elapsed label and the resting label text.
        public var muted: Color = Style.adaptive(0x5C5A56, 0xA6A49F)
        /// Name shown in the header.
        public var assistantName: String = "Assistant"
        /// Show the mark, name and elapsed seconds above the content.
        public var showsHeader: Bool = true

        public init() {}

        /// The house palette: tangerine, sky and lilac dots over quiet bars.
        public static let standard = Style()

        private static func adaptive(_ light: UInt32, _ dark: UInt32) -> Color {
            Color(UIColor { traits in
                let hex = traits.userInterfaceStyle == .dark ? dark : light
                return UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
            })
        }
    }
}

public extension View {
    /// Sweeps a sheen across this view while `isActive` is true. Static under Reduce Motion.
    func thinkingState(_ isActive: Bool = true, tint: Color? = nil) -> some View {
        modifier(ThinkingSheenModifier(isActive: isActive, tint: tint))
    }
}

private struct ThinkingSheenModifier: ViewModifier {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    let isActive: Bool
    let tint: Color?

    func body(content: Content) -> some View {
        content.overlay {
            if isActive {
                TimelineView(.animation(paused: reduceMotion)) { context in
                    let sweep = reduceMotion ? 0.5 : context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 2.2) / 2.2
                    ThinkingSheen(sweep: sweep, color: tint ?? .white, strength: 0.55)
                }
                .mask(content)
                .blendMode(.plusLighter)
                .transition(.opacity)
            }
        }
        .animation(.easeOut(duration: 0.2), value: isActive)
    }
}

/// One soft band traveling leading to trailing. `sweep` is 0...1 across one pass.
private struct ThinkingSheen: View {
    let sweep: Double
    let color: Color
    let strength: Double

    var body: some View {
        let center = -0.4 + sweep * 1.8
        LinearGradient(
            stops: [
                .init(color: color.opacity(0), location: clamp(center - 0.25)),
                .init(color: color.opacity(strength), location: clamp(center)),
                .init(color: color.opacity(0), location: clamp(center + 0.25))
            ],
            startPoint: .leading,
            endPoint: .trailing
        )
        .allowsHitTesting(false)
    }

    private func clamp(_ value: Double) -> Double { min(max(value, 0), 1) }
}

// MARK: - Example

/// Three placeholders and a sweeping status capsule, as they sit in a chat.
private struct ThinkingStateExample: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 30) {
            ThinkingState(.dots)
            ThinkingState(.sheen, lineCount: 2)
            ThinkingState(.text("Reading the Q3 report"), style: { var s = ThinkingState.Style(); s.showsHeader = false; return s }())
            Label("Drafting reply", systemImage: "sparkle")
                .font(.subheadline.weight(.semibold))
                .foregroundStyle(Color(red: 0.078, green: 0.078, blue: 0.078))
                .padding(.horizontal, 16)
                .frame(height: 44)
                .background(Color(red: 1, green: 0.851, blue: 0.463), in: Capsule())
                .thinkingState()
        }
        .padding(24)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(red: 0.071, green: 0.071, blue: 0.071, alpha: 1) : UIColor(red: 0.953, green: 0.949, blue: 0.933, alpha: 1) }))
    }
}

#Preview("Light") {
    ThinkingStateExample().preferredColorScheme(.light)
}

#Preview("Dark") {
    ThinkingStateExample().preferredColorScheme(.dark)
}

Install

Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.

Terminal

$npx swiftpieces add ThinkingState

Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected

>Add the Swift Pieces "Thinking State" piece to my app

Or copy the source above into your app.

Building a whole app? See Swift Pieces Pro →