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:
- Select
Reset. The gray track should remain, with no gradient visible. - Select
25%. Only the early gradient colors should appear. A visible orange endpoint suggests that the gradient itself is being shortened. - Select
50%. With the supplied stops, the reveal boundary should reach the purple midpoint. - Select
Complete. The gradient should fill the track through orange. - Resize the window at 50%. The visible portion should remain half of the newly sized track.
- 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
- LinearGradient — Apple
- mask(alignment:_:) — Apple
- App — Apple
- ProgressViewStyle — Apple
- GeometryReader — Apple
- frame(width:height:alignment:) — Apple
- clipShape(_:style:) — Apple
- fractionCompleted — Apple
- init(value:total:label:currentValueLabel:) — Apple
- State — Apple
- animation(_:value:) — Apple
- init(timerInterval:countsDown:label:currentValueLabel:) — Apple