To reveal a SwiftUI gradient progress bar without compressing its colors, keep the gradient as wide as the complete track and animate a leading-aligned mask. This macOS example implements that behavior in a custom ProgressViewStyle.

The task is to build a resizable desktop progress indicator for a synthetic activity. At 25% completion, it should reveal the first quarter of a fixed color sequence. Later colors should appear as progress advances, while existing colors stay in place.

Why do the colors compress when the progress bar gets shorter?

Apple documents that LinearGradient maps its start and end points into the bounding rectangle of the shape it fills. Consequently, giving a gradient a width proportional to progress also changes the space across which its colors are distributed. A quarter-width gradient still contains its entire color sequence. LinearGradient — Apple Developer Documentation

For a reveal effect, separate two dimensions: the gradient’s width is the full track width, while the visible width is the track width multiplied by the completed fraction. This is a design derived from the documented gradient behavior, rather than a separate progress-bar API.

Consider an illustrative 320-point track with blue, purple and orange stops at normalized positions of 0, 0.5 and 1. The purple midpoint remains at 160 points throughout the reveal:

Progress Full gradient width Mask width Visible result
0% 320 points 0 points Track only
25% 320 points 80 points First quarter of the gradient
50% 320 points 160 points Gradient through its purple midpoint
100% 320 points 320 points Complete color sequence

This gives you a useful diagnostic: at 25%, the trailing orange stop should remain hidden. If it already appears at the advancing edge, inspect whether the progress-dependent width is being applied to the gradient.

Build a complete macOS gradient progress demo

Use a macOS SwiftUI app target with a deployment target of macOS 12 or later. The alignment-aware mask(alignment:_:) overload used below is available from macOS 12. Apple describes it as applying another view’s alpha channel to the masked view. mask(alignment:_:) — Apple Developer Documentation

Replace the generated app entry-point file with this complete program. The generated content-view file can remain unused. Keep exactly one @main declaration in the target, as required by Apple’s app entry-point contract. App — Apple Developer Documentation

import SwiftUI

@main
struct GradientRevealDemoApp: App {
    var body: some Scene {
        WindowGroup {
            GradientRevealDemo()
        }
    }
}

struct GradientRevealDemo: View {
    @State private var progress = 0.25
    @State private var isIndeterminate = false

    var body: some View {
        VStack(alignment: .leading, spacing: 20) {
            ProgressView(
                value: isIndeterminate ? nil : progress,
                total: 1.0
            ) {
                Text("Sample activity")
            } currentValueLabel: {
                if isIndeterminate {
                    Text("In progress")
                } else {
                    Text("\(Int((progress * 100).rounded()))%")
                }
            }
            .progressViewStyle(GradientRevealStyle())

            Slider(value: $progress, in: 0...1, step: 0.01) {
                Text("Sample completion")
            }
            .disabled(isIndeterminate)

            HStack {
                Button("Reset") { progress = 0 }
                Button("25%") { progress = 0.25 }
                Button("50%") { progress = 0.5 }
                Button("Complete") { progress = 1 }
            }
            .disabled(isIndeterminate)

            Toggle("Indeterminate", isOn: $isIndeterminate)
        }
        .padding(24)
        .frame(minWidth: 360, idealWidth: 520, minHeight: 240)
    }
}

@MainActor
struct GradientRevealStyle: ProgressViewStyle {
    func makeBody(configuration: Configuration) -> some View {
        let fraction = configuration.fractionCompleted.flatMap {
            $0.isFinite ? min(max($0, 0), 1) : nil
        }

        VStack(alignment: .leading, spacing: 8) {
            configuration.label

            if let fraction = fraction {
                GeometryReader { geometry in
                    ZStack(alignment: .leading) {
                        Rectangle()
                            .fill(Color.gray.opacity(0.2))

                        LinearGradient(
                            stops: [
                                .init(color: .blue, location: 0),
                                .init(color: .purple, location: 0.5),
                                .init(color: .orange, location: 1)
                            ],
                            startPoint: .leading,
                            endPoint: .trailing
                        )
                        .frame(width: geometry.size.width, height: 14)
                        .mask(alignment: .leading) {
                            Rectangle()
                                .frame(
                                    width: geometry.size.width
                                        * CGFloat(fraction),
                                    height: 14
                                )
                                .animation(
                                    .easeInOut(duration: 0.25),
                                    value: fraction
                                )
                        }
                    }
                    .frame(width: geometry.size.width, height: 14)
                    .clipShape(Capsule())
                }
                .frame(height: 14)
            } else {
                ProgressView()
                    .progressViewStyle(.circular)
            }

            configuration.currentValueLabel
        }
    }
}

The custom style implements makeBody(configuration:), the documented extension point for a progress view’s appearance. It renders both supplied labels and reads the completion fraction from the configuration. Keeping the styling in this protocol preserves the caller’s ProgressView interface. ProgressViewStyle — Apple Developer Documentation

The sample starts at 25%. Use the buttons for repeatable checkpoints, drag the slider to inspect intermediate values, and resize the window to inspect the layout. These controls change synthetic local state; they do not represent measured work. The source was inspected, and the width calculations were checked with Python, but the SwiftUI program was not compiled or run in the research environment.

How the layout keeps the gradient stable

The GeometryReader measures the space allocated to the indicator. Apple describes it as a container whose content depends on its own size and coordinate space, with a flexible preferred size. Applying .frame(height: 14) outside it gives this otherwise flexible container an explicit height within the vertical stack. GeometryReader — Apple Developer Documentation

Inside that container, the gradient receives the complete measured width. Only the rectangle inside the mask receives the multiplied width. When progress changes, the gradient’s frame stays constant for a given window size.

The mask’s .leading alignment is essential: it positions the smaller mask against the gradient’s leading edge. A frame’s own alignment parameter positions content inside that frame; it does not replace the alignment between the mask and the view being masked. Apple documents those as separate alignment responsibilities. mask(alignment:_:) — Apple Developer Documentation, frame(width:height:alignment:) — Apple Developer Documentation

The opaque rectangle reveals the gradient wherever the rectangle exists. Its hue does not determine the revealed colors because masking uses alpha. Replacing it with a transparent rectangle would hide the gradient. Keep the gray track outside the mask so it remains visible at zero progress.

The final capsule clips the combined track and revealed gradient to the same outer silhouette. Apple documents clipping as preserving the portions covered by the clipping shape. This example deliberately keeps a straight advancing edge until it reaches the capsule’s rounded end; a capsule-shaped reveal mask would produce a rounded advancing edge. clipShape(_:style:) — Apple Developer Documentation

Resizing the window changes the full track width, so the gradient is recalculated across that new width. “Stable colors” here means stable positions relative to the track during progress updates, rather than fixed screen coordinates across window resizing.

Preserve progress values, types and lifecycle behavior

The style receives fractionCompleted as an optional Double. Apple defines its numeric range as 0 through 1 and uses nil for indeterminate progress or progress relative to a date interval. Converting that fraction to CGFloat at the width calculation makes the numeric boundary explicit. fractionCompleted — Apple Developer Documentation

The example clamps finite fractions defensively and routes a missing or nonfinite fraction to a standard indeterminate indicator. That nonfinite handling is an explicit policy of this sample. It avoids interpreting an unavailable fraction as zero completion. The inner indicator selects .circular explicitly so it uses a built-in style.

At the call site, provide a finite completed amount and a finite, positive total. The value-based initializer accepts nil for indeterminate progress and documents pinning out-of-range values to its bounds. The sample uses a total of 1 and constrains its slider to the same interval. init(value:total:label:currentValueLabel:) — Apple Developer Documentation

The parent view owns the progress state, while the style derives its rendering from the configuration. SwiftUI updates dependent views when state changes. This example therefore needs no timer, appearance callback or background task to maintain its visual state. State — Apple Developer Documentation

Animation belongs to the reveal rectangle. The value-scoped modifier applies an animation when the monitored fraction changes; the chosen quarter-second duration is an illustrative presentation choice. It does not calculate task progress. Remove the modifier if immediate updates better suit your interface. animation(_:value:) — Apple Developer Documentation

There is a documented boundary for timer-driven progress: Apple says date-relative progress views do not support custom styles. For this gradient appearance, use the numeric progress initializer and have the activity owner supply its completed fraction. If you need Apple’s automatic date-relative updates, use that initializer with its supported presentation. init(timerInterval:countsDown:label:currentValueLabel:) — Apple Developer Documentation

Check the reveal with a short diagnostic sequence

Use these checks after building the macOS target. They distinguish color mapping problems from alignment and sizing problems:

  1. Select Reset. The gray track should remain, with no gradient visible.
  2. Select 25%. Only the early gradient colors should appear. A visible orange endpoint suggests that the gradient itself is being shortened.
  3. Select 50%. With the supplied stops, the reveal boundary should reach the purple midpoint.
  4. Select Complete. The gradient should fill the track through orange.
  5. Resize the window at 50%. The visible portion should remain half of the newly sized track.
  6. Enable Indeterminate. The numeric fill should give way to the built-in indicator while the task and current-value labels remain present.

If the reveal grows outward from the center, check .mask(alignment: .leading). If the indicator takes excessive vertical space, check the frame outside GeometryReader. If changing progress moves every color stop, check that only the mask uses the completion multiplier.

The reusable rule is simple: size the gradient for the complete track, then use a separately sized mask to reveal progress. Keeping those responsibilities separate makes partial completion, resizing and animation easier to reason about while retaining the progress view’s documented configuration.

Sources