Skip to content

Gauge Chart

A half-moon gauge with colored zones and a needle that swings to the value.

Gauge Chart, playing
Download .swift
Origin
Original work
Found by
gauge · dial · speedometer · meter · needle · zones · dashboard · medidor · dial · velocímetro · indicador · aguja · zonas · panel

A half-moon gauge for a panel: a dial split into colored zones and a needle that swings to the value. For a small control inside a row, SwiftUI's own Gauge is the one to use.

VoiceOver reads the value and the name of its zone, so the reading does not depend on the color. With Reduce Motion on, the needle goes to its place without swinging.

struct GaugeChartDemo: View {
private let zones = [
GaugeChartZone(name: "Good", upTo: 50, color: .green),
GaugeChartZone(name: "Moderate", upTo: 100, color: .yellow),
GaugeChartZone(name: "Unhealthy", upTo: 150, color: .orange),
GaugeChartZone(name: "Hazardous", upTo: 200, color: .red),
]
private let readings: [(place: String, value: Double)] = [("Harbor", 32), ("Old Town", 118), ("Ring Road", 171), ("Hillside", 74)]
@State private var index = 0
var body: some View {
let reading = readings[index]
let zone = GaugeChartMath.zone(for: reading.value, in: zones)
VStack(spacing: 18) {
VStack(spacing: 4) {
Text("Air Quality")
.font(.title2.bold())
Text(reading.place)
.font(.subheadline)
.foregroundStyle(.secondary)
.contentTransition(.opacity)
}
GaugeChart(value: reading.value, range: 0...200, zones: zones, label: "Air quality index")
.padding(.horizontal, 12)
VStack(spacing: 2) {
Text(reading.value, format: .number)
.font(.system(size: 52, weight: .bold, design: .rounded))
.contentTransition(.numericText())
Text(zone?.name ?? "")
.font(.headline)
.foregroundStyle(.secondary)
}
.accessibilityHidden(true)
Button("Next Station", systemImage: "arrow.right") {
withAnimation(.snappy) { index = (index + 1) % readings.count }
}
.buttonStyle(.bordered)
}
.padding(24)
.background(.background, in: .rect(cornerRadius: 24))
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
}
}
ParameterTypeDefault
valueDoubleRequired
rangeClosedRange<Double>0...100
zones[GaugeChartZone]Required
labelString"Gauge"
valueText(Double) -> String{ $0.formatted(.number.precision(.fractionLength(0))) }

Copy this code into your project, or download the file.

GaugeChart.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
nonisolated struct GaugeChartZone: Identifiable, Sendable {
var name: String
/// Where the zone ends. It starts where the one before it ends, or at the start of the range.
var upTo: Double
var color: Color
var id: String { name }
}
nonisolated enum GaugeChartMath {
/// How far along the dial a value is, from 0 at its start to 1 at its end.
static func share(of value: Double, in range: ClosedRange<Double>) -> Double {
let length = range.upperBound - range.lowerBound
guard length > 0, value.isFinite else { return 0 }
return min(max((value - range.lowerBound) / length, 0), 1)
}
/// The turn of the needle in degrees: -90 at the start of the dial, 0 straight up, 90 at its end.
static func needleAngle(share: Double) -> Double {
-90 + 180 * min(max(share, 0), 1)
}
/// The zone a value falls in: the first one that ends at or after it, or the last one for a
/// value beyond them all.
static func zone(for value: Double, in zones: [GaugeChartZone]) -> GaugeChartZone? {
let sorted = zones.sorted { $0.upTo < $1.upTo }
return sorted.first { value <= $0.upTo } ?? sorted.last
}
/// Where each zone starts and ends along the dial, as shares from 0 to 1.
static func spans(of zones: [GaugeChartZone], in range: ClosedRange<Double>) -> [(zone: GaugeChartZone, start: Double, end: Double)] {
var start = 0.0
return zones.sorted { $0.upTo < $1.upTo }.compactMap { zone in
let end = share(of: zone.upTo, in: range)
defer { start = max(start, end) }
return end > start ? (zone, start, end) : nil
}
}
}
/// A half-moon gauge for a panel: a dial split into colored zones and a needle that swings to
/// the value. For a small control inside a row, SwiftUI's own `Gauge` is the one to use.
///
/// VoiceOver reads the value and the name of its zone, so the reading does not depend on the
/// color. With Reduce Motion on, the needle goes to its place without swinging.
struct GaugeChart: View {
var value: Double
var range: ClosedRange<Double> = 0...100
let zones: [GaugeChartZone]
var label = "Gauge"
var valueText: (Double) -> String = { $0.formatted(.number.precision(.fractionLength(0))) }
private let lineWidth: CGFloat = 26
private let gap = 0.004
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
let share = GaugeChartMath.share(of: value, in: range)
let zone = GaugeChartMath.zone(for: value, in: zones)
GeometryReader { proxy in
let width = proxy.size.width
ZStack(alignment: .bottom) {
ZStack {
ForEach(GaugeChartMath.spans(of: zones, in: range), id: \.zone.id) { span in
// A circle starts at three o'clock. Half a turn makes it start at nine, where the dial does.
Circle()
.inset(by: lineWidth / 2)
.trim(from: span.start / 2 + gap, to: span.end / 2 - gap)
.stroke(span.zone.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
.rotationEffect(.degrees(180))
}
}
.frame(width: width, height: width)
.frame(height: width / 2, alignment: .top)
.clipped()
Capsule()
.fill(.primary)
.frame(width: 5, height: width / 2 - lineWidth - 10)
.rotationEffect(.degrees(GaugeChartMath.needleAngle(share: share)), anchor: .bottom)
.animation(reduceMotion ? nil : .spring(duration: 0.9, bounce: 0.35), value: share)
Circle()
.fill(.primary)
.frame(width: 18, height: 18)
.offset(y: 9)
}
}
.aspectRatio(2, contentMode: .fit)
// A dial reads from left to right in every language, like a speedometer.
.environment(\.layoutDirection, .leftToRight)
.padding(.bottom, 9)
.accessibilityElement()
.accessibilityLabel(label)
.accessibilityValue([valueText(value), zone?.name].compactMap { $0 }.joined(separator: ", "))
}
}
// MARK: - Demo
struct GaugeChartDemo: View {
private let zones = [
GaugeChartZone(name: "Good", upTo: 50, color: .green),
GaugeChartZone(name: "Moderate", upTo: 100, color: .yellow),
GaugeChartZone(name: "Unhealthy", upTo: 150, color: .orange),
GaugeChartZone(name: "Hazardous", upTo: 200, color: .red),
]
private let readings: [(place: String, value: Double)] = [("Harbor", 32), ("Old Town", 118), ("Ring Road", 171), ("Hillside", 74)]
@State private var index = 0
var body: some View {
let reading = readings[index]
let zone = GaugeChartMath.zone(for: reading.value, in: zones)
VStack(spacing: 18) {
VStack(spacing: 4) {
Text("Air Quality")
.font(.title2.bold())
Text(reading.place)
.font(.subheadline)
.foregroundStyle(.secondary)
.contentTransition(.opacity)
}
GaugeChart(value: reading.value, range: 0...200, zones: zones, label: "Air quality index")
.padding(.horizontal, 12)
VStack(spacing: 2) {
Text(reading.value, format: .number)
.font(.system(size: 52, weight: .bold, design: .rounded))
.contentTransition(.numericText())
Text(zone?.name ?? "")
.font(.headline)
.foregroundStyle(.secondary)
}
.accessibilityHidden(true)
Button("Next Station", systemImage: "arrow.right") {
withAnimation(.snappy) { index = (index + 1) % readings.count }
}
.buttonStyle(.bordered)
}
.padding(24)
.background(.background, in: .rect(cornerRadius: 24))
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
}
}
#Preview {
GaugeChartDemo()
}