How to build a color picker in SwiftUI
SwiftUI's built-in ColorPicker view presents Apple's system color wheel in a single line of code — just bind it to a @State var color: Color and you're done. Pass supportsOpacity: false if you only need solid colors.
import SwiftUI
struct TLDRColorPickerView: View {
@State private var selectedColor: Color = .blue
var body: some View {
Form {
ColorPicker("Accent color", selection: $selectedColor)
RoundedRectangle(cornerRadius: 12)
.fill(selectedColor)
.frame(height: 60)
}
}
}
Full implementation
The example below wires ColorPicker into a settings-style Form, persists the chosen color to AppStorage via its hex string representation, and live-previews the result on a swatch. A reset button demonstrates programmatic color mutation, and the supportsOpacity toggle shows how to gate the alpha channel at runtime.
import SwiftUI
// MARK: - Color persistence helpers
extension Color {
/// Encode to a CSS-style hex string, e.g. "#FF6B00FF"
var hexString: String {
let ui = UIColor(self)
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
ui.getRed(&r, green: &g, blue: &b, alpha: &a)
return String(format: "#%02X%02X%02X%02X",
Int(r * 255), Int(g * 255),
Int(b * 255), Int(a * 255))
}
/// Decode from a hex string produced by hexString above
init(hexString: String) {
let hex = hexString.trimmingCharacters(in: .init(charactersIn: "#"))
guard hex.count == 8,
let value = UInt64(hex, radix: 16) else {
self = .blue; return
}
self.init(
red: Double((value >> 24) & 0xFF) / 255,
green: Double((value >> 16) & 0xFF) / 255,
blue: Double((value >> 8) & 0xFF) / 255,
opacity: Double(value & 0xFF) / 255
)
}
}
// MARK: - Main view
struct ColorPickerSettingsView: View {
// Persisted as a hex string so it survives app restarts
@AppStorage("accentColorHex") private var accentColorHex: String = Color.blue.hexString
@State private var selectedColor: Color = .blue
@State private var supportsOpacity: Bool = true
private let defaultColor: Color = .blue
var body: some View {
NavigationStack {
Form {
// ── Live swatch ──────────────────────────────────────
Section {
RoundedRectangle(cornerRadius: 16)
.fill(selectedColor)
.frame(maxWidth: .infinity)
.frame(height: 100)
.overlay(
Text("Preview")
.font(.headline)
.foregroundStyle(.white.opacity(0.85))
)
.listRowInsets(.init())
.accessibilityLabel("Color preview swatch")
}
// ── Picker ───────────────────────────────────────────
Section("Pick a color") {
ColorPicker(
"Accent color",
selection: $selectedColor,
supportsOpacity: supportsOpacity
)
.accessibilityHint("Opens the system color picker sheet")
Toggle("Allow transparency", isOn: $supportsOpacity)
}
// ── Actions ──────────────────────────────────────────
Section {
Button("Reset to default") {
selectedColor = defaultColor
}
.foregroundStyle(.red)
}
}
.navigationTitle("Color Picker")
.navigationBarTitleDisplayMode(.inline)
// Persist whenever the binding changes
.onChange(of: selectedColor) { _, newColor in
accentColorHex = newColor.hexString
}
// Restore persisted color on appear
.onAppear {
selectedColor = Color(hexString: accentColorHex)
}
}
}
}
// MARK: - Preview
#Preview {
ColorPickerSettingsView()
}
How it works
-
ColorPicker binding —
ColorPicker("Accent color", selection: $selectedColor, supportsOpacity: supportsOpacity)is the only API call needed. SwiftUI renders an interactive color swatch that opens Apple's system color wheel sheet when tapped. The$selectedColortwo-way binding is updated in real time as the user drags the hue/saturation wheel or types a hex value. -
Opacity control — The
supportsOpacityparameter (defaulting totrue) shows or hides the opacity/alpha slider at the bottom of the picker sheet. TheTogglein the form passes this value through live, so you can see the alpha slider appear and disappear without restarting. -
Persistence via AppStorage —
Colorisn't directlyAppStorage-compatible, so thehexStringhelper converts it to aStringfor storage. The.onChange(of: selectedColor)modifier (using the new two-argument iOS 17 closure) writes on every change, while.onAppearrestores it on launch. -
Live preview swatch — The
RoundedRectangle().fill(selectedColor)redraws whenever the binding changes, giving instant visual feedback without any extra state management. -
Reset button — Programmatically assigning
selectedColor = defaultColorinside aButtonaction shows thatColoris a plain value type — mutations propagate to the picker swatch immediately.
Variants
Label-free swatch button (inline style)
Use a Label with hidden text when you want only the color swatch to appear — common in toolbars or grids where space is tight.
struct SwatchOnlyPicker: View {
@State private var color: Color = .orange
var body: some View {
HStack {
Text("Highlight color")
.font(.subheadline)
Spacer()
ColorPicker(
selection: $color,
supportsOpacity: false
) {
// Empty label — the swatch IS the control
EmptyView()
}
.labelsHidden()
.frame(width: 32, height: 32)
.accessibilityLabel("Highlight color picker")
}
.padding()
}
}
Multiple color pickers in a list
When you need several named color slots (e.g., a theme builder), use a ForEach over an array of @Binding-compatible items. With SwiftData you can store Color as a String attribute using the same hexString helper above, then reconstruct colors in a computed property. Each row in the list gets its own independent ColorPicker with a distinct label so VoiceOver can distinguish them.
Common pitfalls
-
iOS 14 minimum, not iOS 17 —
ColorPickerwas introduced in iOS 14, but the two-argument.onChange(of:)closure used here requires iOS 17. If you lower your deployment target, revert to the single-argument variant or add an#availableguard. -
Color is not Codable out of the box — Trying to store a
Colordirectly inAppStorage,UserDefaults, or a SwiftData model will fail at runtime. Always convert to aString(hex) orData(viaNSKeyedArchiver) before persisting. -
Performance on every drag event — The binding fires on every tiny hue change while the user drags. Avoid expensive side-effects (network calls, heavy SwiftData writes) directly inside
.onChange; debounce with aTask/try await Task.sleeppattern or use.onDisappearof the picker sheet instead. -
VoiceOver reads swatch colors poorly — The default accessibility description for the swatch is generic. Add a meaningful
.accessibilityLabeland.accessibilityValue(e.g., the hex string or a localised color name) so low-vision users know what's selected.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a color picker in SwiftUI for iOS 17+. Use ColorPicker with a @State Color binding. Persist the chosen color to AppStorage as a hex string. Support toggling opacity/alpha on and off. Make it accessible (VoiceOver labels and values). Add a #Preview with realistic sample data.
In Soarias's Build phase, paste this prompt into the active session to scaffold the component directly into your feature branch — Soarias keeps the generated file in context so follow-up tweaks like "disable opacity by default" apply immediately without re-explaining the codebase.
Related
FAQ
Does this work on iOS 16?
ColorPicker itself is available from iOS 14+. The two-argument .onChange(of:perform:) syntax used in this guide requires iOS 17. If you target iOS 16, replace it with the single-closure form: .onChange(of: selectedColor) { newColor in … }. Everything else compiles unchanged.
Can I restrict the color picker to a specific palette?
No — Apple's system ColorPicker always shows the full wheel and cannot be limited to a predefined palette. If you need a restricted palette (e.g., 8 brand swatches), build a custom grid of Circle() views with a selectedColor binding and add a checkmark overlay for the active selection. This is a common pattern for theme-selection UIs.
What's the UIKit equivalent?
UIKit uses UIColorPickerViewController presented modally, with your controller adopting UIColorPickerViewControllerDelegate to receive colorPickerViewControllerDidSelectColor(_:) callbacks. SwiftUI's ColorPicker wraps this internally — so they produce identical sheets, but the SwiftUI version removes all the delegation boilerplate.
Last reviewed: 2026-05-11 by the Soarias team.