Skip to content
Components PulseToggle

PulseToggle

iOS, checkbox or pill — one API.

Form
interactive demo
Airplane ModeFly without flying.
Bluetooth2 devices connected
NotificationsBadge style
On

A live browser stand-in using the exact tokens, motion and API of the SwiftUI component.

iOS, checkbox or pill — one API.

Three switch idioms from a single binding. Tint, size and auxiliary checkmark styling included. Accessibility traits wired out of the box.

iOS, checkbox and pill styles
Custom tint and sizes
Scrollable description text
Full accessibility traits
Usage
PulseToggle("Airplane Mode", isOn: $airplane, style: .ios)
PulseToggle("Agree to terms", isOn: $consent, style: .checkbox)
PulseToggle("Pill", isOn: $enabled, style: .pill)
Install
$ pulse add toggle

Source files

Sources/PulseUI/Components/PulseToggle/PulseToggle.swift
Full source Sources/PulseUI/Components/PulseToggle/PulseToggle.swift
import SwiftUI

// MARK: - PulseToggle

/// Premium animated toggle switch with custom styling, haptic feedback, and accessibility.
public struct PulseToggle: View {
    @Environment(\.pulseTheme) private var theme
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    @Binding var isOn: Bool
    let style: ToggleStyle
    let size: ToggleSize
    let tint: Color?
    let label: String?
    let description: String?

    @State private var knobOffset: CGFloat = 0

    public init(
        _ label: String? = nil,
        isOn: Binding<Bool>,
        description: String? = nil,
        style: ToggleStyle = .ios,
        size: ToggleSize = .md,
        tint: Color? = nil
    ) {
        self.label = label
        self._isOn = isOn
        self.description = description
        self.style = style
        self.size = size
        self.tint = tint
    }

    public var body: some View {
        HStack(spacing: theme.spacing.md) {
            if let label {
                VStack(alignment: .leading, spacing: 2) {
                    Text(label)
                        .font(theme.typography.callout.font)
                        .foregroundStyle(theme.colors.foreground)
                    if let description {
                        Text(description)
                            .font(theme.typography.footnote.font)
                            .foregroundStyle(theme.colors.foregroundSecondary)
                    }
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .accessibilityHidden(true)
            }

            toggleView
                .onTapGesture {
                    toggle()
                }
                .accessibilityLabel(label ?? "Toggle")
                .accessibilityValue(isOn ? "On" : "Off")
                .accessibilityAddTraits(isOn ? .isSelected : [])
                .accessibilityHint(description ?? "")
        }
        .contentShape(Rectangle())
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(label ?? "Toggle")
        .accessibilityValue(isOn ? "On" : "Off")
        .accessibilityActions {
            Button(isOn ? "Turn off" : "Turn on") { toggle() }
        }
    }

    @ViewBuilder
    private var toggleView: some View {
        switch style {
        case .ios:
            iosToggle
        case .checkbox:
            checkboxToggle
        case .pill:
            pillToggle
        }
    }

    // MARK: - iOS Style

    private var iosToggle: some View {
        ZStack(alignment: isOn ? .trailing : .leading) {
            Capsule()
                .fill(backgroundFill)
                .frame(width: size.toggleWidth, height: size.toggleHeight)
                .overlay(
                    Capsule()
                        .strokeBorder(theme.colors.border.opacity(0.3), lineWidth: 0.5)
                )

            Circle()
                .fill(.white)
                .shadow(color: .black.opacity(0.2), radius: 2, y: 1)
                .frame(width: size.knobDimension, height: size.knobDimension)
                .padding(size.padding)
                .scaleEffect(isOn ? 1.0 : 0.9)
        }
        .frame(width: size.toggleWidth, height: size.toggleHeight)
        .animation(reduceMotion ? .none : theme.motion.springBouncy, value: isOn)
        .contentShape(Capsule())
    }

    private var backgroundFill: Color {
        if isOn {
            return tint ?? theme.colors.accent
        }
        return theme.colors.backgroundTertiary.opacity(0.8)
    }

    // MARK: - Checkbox Style

    private var checkboxToggle: some View {
        ZStack {
            RoundedRectangle(cornerRadius: size.knobDimension / 4, style: .continuous)
                .fill(isOn ? (tint ?? theme.colors.accent) : theme.colors.backgroundTertiary)
                .frame(width: size.checkboxSize, height: size.checkboxSize)
                .overlay(
                    RoundedRectangle(cornerRadius: size.knobDimension / 4, style: .continuous)
                        .strokeBorder(theme.colors.borderStrong, lineWidth: 1)
                )

            if isOn {
                Image(systemName: "checkmark")
                    .font(.system(size: size.checkboxSize * 0.55, weight: .bold))
                    .foregroundStyle(.white)
                    .transition(.scale.combined(with: .opacity))
            }
        }
        .frame(width: size.checkboxSize, height: size.checkboxSize)
        .contentShape(Rectangle())
        .animation(reduceMotion ? .none : theme.motion.springBouncy, value: isOn)
    }

    // MARK: - Pill Style

    private var pillToggle: some View {
        HStack(spacing: 6) {
            Circle()
                .fill(isOn ? .white : theme.colors.foregroundTertiary.opacity(0.5))
                .frame(width: 14, height: 14)

            Text(isOn ? "On" : "Off")
                .font(theme.typography.caption.font)
                .fontWeight(.semibold)
                .foregroundStyle(isOn ? .white : theme.colors.foregroundSecondary)
        }
        .padding(.horizontal, 10)
        .frame(height: 28)
        .background(
            Capsule()
                .fill(isOn ? (tint ?? theme.colors.accent) : theme.colors.backgroundTertiary)
        )
        .overlay(
            Capsule()
                .strokeBorder(isOn ? Color.clear : theme.colors.border, lineWidth: 1)
        )
        .contentShape(Capsule())
        .animation(reduceMotion ? .none : theme.motion.spring, value: isOn)
    }

    // MARK: - Actions

    private func toggle() {
        withAnimation(reduceMotion ? .none : theme.motion.springBouncy) {
            isOn.toggle()
        }
        PulseHaptic.impact(isOn ? .medium : .light)
    }
}

// MARK: - Styles

public enum ToggleStyle: Sendable {
    case ios
    case checkbox
    case pill
}

// MARK: - Sizes

public enum ToggleSize: Sendable {
    case sm
    case md
    case lg

    var toggleWidth: CGFloat {
        switch self {
        case .sm: return 34
        case .md: return 50
        case .lg: return 60
        }
    }

    var toggleHeight: CGFloat {
        switch self {
        case .sm: return 21
        case .md: return 30
        case .lg: return 36
        }
    }

    var knobDimension: CGFloat {
        switch self {
        case .sm: return 17
        case .md: return 26
        case .lg: return 32
        }
    }

    var padding: CGFloat {
        switch self {
        case .sm: return 2
        case .md: return 2
        case .lg: return 2
        }
    }

    var checkboxSize: CGFloat {
        switch self {
        case .sm: return 18
        case .md: return 22
        case .lg: return 28
        }
    }
}