Skip to content

Icon Switch Toggle Style

A switch toggle style whose knob carries a symbol for each state, like a checkmark and a cross, that swaps as it slides.

Icon Switch Toggle Style, playing
Download .swift
Origin
Original work
Found by
switch · toggle · toggle style · icon · symbol · knob · on off · settings · interruptor · toggle · estilo de toggle · icono · símbolo · encendido apagado · ajustes

A switch whose knob carries a symbol for each state, a checkmark and a cross by default, that swaps as the knob slides. The state reads without relying on the track color alone. The whole row is tappable, and VoiceOver reads it as a standard switch.

With Reduce Motion on, the knob slides without bouncing and the symbols crossfade.

struct IconSwitchToggleStyleDemo: View {
@State private var autoUpdates = true
@State private var darkMode = false
@State private var sound = true
@State private var location = false
var body: some View {
VStack(spacing: 8) {
Toggle("Automatic Updates", isOn: $autoUpdates)
.toggleStyle(.iconSwitch)
Toggle("Dark Mode", isOn: $darkMode)
.toggleStyle(.iconSwitch(on: "moon.fill", off: "sun.max.fill", color: .indigo))
Toggle("Sound", isOn: $sound)
.toggleStyle(.iconSwitch(on: "speaker.wave.2.fill", off: "speaker.slash.fill", color: .orange))
Toggle("Share Location", isOn: $location)
.toggleStyle(.iconSwitch(on: "location.fill", off: "location.slash.fill", color: .blue))
}
.font(.body.weight(.medium))
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 24, style: .continuous))
.padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(uiColor: .systemGroupedBackground).ignoresSafeArea())
}
}
ParameterTypeDefault
onSymbolString"checkmark"
offSymbolString"xmark"
colorColor.green

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

IconSwitchToggleStyle.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
/// A switch whose knob carries a symbol for each state, a checkmark and a cross
/// by default, that swaps as the knob slides. The state reads without relying
/// on the track color alone. The whole row is tappable, and VoiceOver reads it
/// as a standard switch.
///
/// With Reduce Motion on, the knob slides without bouncing and the symbols crossfade.
struct IconSwitchToggleStyle: ToggleStyle {
var onSymbol = "checkmark"
var offSymbol = "xmark"
var color: Color = .green
func makeBody(configuration: Configuration) -> some View {
Button {
configuration.isOn.toggle()
} label: {
HStack(spacing: 16) {
configuration.label
.frame(maxWidth: .infinity, alignment: .leading)
IconSwitchToggleStyleTrack(
isOn: configuration.isOn,
symbol: configuration.isOn ? onSymbol : offSymbol,
color: color
)
}
.frame(minHeight: 44)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityRepresentation {
Toggle(isOn: configuration.$isOn) {
configuration.label
}
}
}
}
extension ToggleStyle where Self == IconSwitchToggleStyle {
static var iconSwitch: IconSwitchToggleStyle { IconSwitchToggleStyle() }
static func iconSwitch(on onSymbol: String, off offSymbol: String, color: Color = .green) -> IconSwitchToggleStyle {
IconSwitchToggleStyle(onSymbol: onSymbol, offSymbol: offSymbol, color: color)
}
}
private struct IconSwitchToggleStyleTrack: View {
let isOn: Bool
let symbol: String
let color: Color
@ScaledMetric(relativeTo: .body) private var height = 32.0
@Environment(\.isEnabled) private var isEnabled
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let inset = 3.0
var body: some View {
let knob = height - inset * 2
Capsule()
.fill(isOn ? color : Color.secondary.opacity(0.35))
.frame(width: height * 1.8, height: height)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()
.fill(.white)
.shadow(color: .black.opacity(0.2), radius: 2, y: 1)
.overlay {
Image(systemName: symbol)
.font(.system(size: knob * 0.45, weight: .bold))
.foregroundStyle(isOn ? color : Color.gray)
.contentTransition(reduceMotion ? .opacity : .symbolEffect(.replace))
}
.frame(width: knob, height: knob)
.padding(inset)
}
.opacity(isEnabled ? 1 : 0.4)
.animation(reduceMotion ? .easeInOut(duration: 0.15) : .spring(duration: 0.35, bounce: 0.35), value: isOn)
.accessibilityHidden(true)
}
}
// MARK: - Demo
struct IconSwitchToggleStyleDemo: View {
@State private var autoUpdates = true
@State private var darkMode = false
@State private var sound = true
@State private var location = false
var body: some View {
VStack(spacing: 8) {
Toggle("Automatic Updates", isOn: $autoUpdates)
.toggleStyle(.iconSwitch)
Toggle("Dark Mode", isOn: $darkMode)
.toggleStyle(.iconSwitch(on: "moon.fill", off: "sun.max.fill", color: .indigo))
Toggle("Sound", isOn: $sound)
.toggleStyle(.iconSwitch(on: "speaker.wave.2.fill", off: "speaker.slash.fill", color: .orange))
Toggle("Share Location", isOn: $location)
.toggleStyle(.iconSwitch(on: "location.fill", off: "location.slash.fill", color: .blue))
}
.font(.body.weight(.medium))
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 24, style: .continuous))
.padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(uiColor: .systemGroupedBackground).ignoresSafeArea())
}
}
#Preview {
IconSwitchToggleStyleDemo()
}