Swift PiecesFree
Feedback

Reaction Toggle

A like or save toggle that presses in, floods its capsule with a solid color block from the symbol outward, bounces the symbol, sends out one fading halo, and can roll a count or float a confirmation pill.

Free · MIT + Commons ClauseiOS 17.0+likebookmarktogglereactionhapticcapsule
Type
ReactionToggle
Files
ReactionToggle.swift
Depends on
Nothing (Apple frameworks only)
Version
2.0.0

Reaction toggle with press feel, a circular block flood, a symbol bounce, one halo, and an optional count and pill.

Notes

  • At rest the toggle is a quiet capsule. Turning it on floods the capsule with a solid block that grows out of the symbol, swaps to the filled symbol in dark ink, bounces it with a small tilt, and sends one halo outward. Turning it off drains the block.
  • Style holds the block (fill), its ink, the rest capsule colors and the pill colors, with house palette defaults that adapt to light and dark. tint still works and overrides fill. Set isContained: false for a bare symbol that fills like a sticker under an ink outline.
  • The press state comes from a ButtonStyle, so it tracks the real touch: scale in stiffly, spring out on release.
  • count is the base value from your model; the view adds one while isOn so the number rolls immediately without a round trip. size scales with Dynamic Type and the hit area never drops below 44 pt.
  • confirmation floats a dark pill with a check above the toggle for 1.3 s.
  • Under Reduce Motion the flood crossfades, and there is no bounce or halo; the haptic still fires on the off-to-on edge only.

Usage

ReactionToggleExample()

Parameters

ParameterDescription
isOnBound reaction state.
systemImageOutline symbol name.
filledImageFilled symbol name; defaults to systemImage with .fill appended.
countOptional number shown beside the symbol; it rolls with numericText and offsets by one while isOn.
confirmationOptional pill text floated above the toggle when turned on, such as "Saved".
sizeSymbol point size; scales with Dynamic Type. The hit area is at least 44 pt.
tintOverrides the block color used when on. Defaults to style.fill.
styleColors and shape. .standard is a tangerine block with dark ink on a quiet capsule; set isContained to false for a bare symbol.

Source

ReactionToggle.swift
import SwiftUI

/// Reaction toggle with press feel, a circular block flood, a symbol bounce, one halo, and an optional count and pill.
///
/// - Parameters:
///   - isOn: Bound reaction state.
///   - systemImage: Outline symbol name.
///   - filledImage: Filled symbol name; defaults to `systemImage` with `.fill` appended.
///   - count: Optional number shown beside the symbol; it rolls with `numericText` and offsets by one while `isOn`.
///   - confirmation: Optional pill text floated above the toggle when turned on, such as "Saved".
///   - size: Symbol point size; scales with Dynamic Type. The hit area is at least 44 pt.
///   - tint: Overrides the block color used when on. Defaults to `style.fill`.
///   - style: Colors and shape. `.standard` is a tangerine block with dark ink on a quiet capsule; set `isContained` to `false` for a bare symbol.
public struct ReactionToggle: View {
    /// Colors and shape for the toggle. Defaults follow the Swift Pieces house palette and adapt to light and dark.
    public struct Style: Sendable {
        /// Block color that floods the capsule (or fills the symbol when bare) when on.
        public var fill: Color
        /// Symbol and count color on the block.
        public var ink: Color
        /// Capsule color while off.
        public var restFill: Color
        /// Symbol and count color while off.
        public var restInk: Color
        /// Confirmation pill background.
        public var pillFill: Color
        /// Confirmation pill text.
        public var pillInk: Color
        /// `true` draws a capsule that floods with `fill`; `false` shows a bare symbol that fills with `fill`.
        public var isContained: Bool

        public init(
            fill: Color = Style.tangerine,
            ink: Color = Style.blockInk,
            restFill: Color = Style.adaptive(0xEFEDE8, 0x262626),
            restInk: Color = Style.adaptive(0x141414, 0xF4F3EF),
            pillFill: Color = Style.adaptive(0x141414, 0xF4F3EF),
            pillInk: Color = Style.adaptive(0xF4F3EF, 0x141414),
            isContained: Bool = true
        ) {
            self.fill = fill
            self.ink = ink
            self.restFill = restFill
            self.restInk = restInk
            self.pillFill = pillFill
            self.pillInk = pillInk
            self.isContained = isContained
        }

        public static let standard = Style()

        public static let tangerine = Color(red: 1, green: 0x5B / 255, blue: 0x3A / 255)
        public static let sky = Color(red: 0x9C / 255, green: 0xC2 / 255, blue: 1)
        public static let butter = Color(red: 1, green: 0xD9 / 255, blue: 0x76 / 255)
        public static let sage = Color(red: 0xA9 / 255, green: 0xDC / 255, blue: 0xB7 / 255)
        public static let lilac = Color(red: 0xCD / 255, green: 0xB8 / 255, blue: 1)
        public static let blockInk = Color(red: 0x14 / 255, green: 0x14 / 255, blue: 0x14 / 255)

        /// A color that resolves to `light` or `dark` hex by the current appearance.
        public static func adaptive(_ light: UInt32, _ dark: UInt32) -> Color {
            Color(uiColor: UIColor { $0.userInterfaceStyle == .dark ? rgb(dark) : rgb(light) })
        }

        private static func rgb(_ hex: UInt32) -> UIColor {
            UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
        }
    }

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @Binding private var isOn: Bool
    @State private var flood: CGFloat
    @State private var burst = 0
    @State private var showsPill = false
    @ScaledMetric(relativeTo: .body) private var size: CGFloat = 24

    private let systemImage: String
    private let filledImage: String
    private let count: Int?
    private let confirmation: String?
    private let tint: Color?
    private let style: Style

    public init(isOn: Binding<Bool>, systemImage: String = "heart", filledImage: String? = nil, count: Int? = nil, confirmation: String? = nil, size: CGFloat = 24, tint: Color? = nil, style: Style = .standard) {
        _isOn = isOn
        _flood = State(initialValue: isOn.wrappedValue ? 1 : 0)
        _size = ScaledMetric(wrappedValue: size, relativeTo: .body)
        self.systemImage = systemImage
        self.filledImage = filledImage ?? "\(systemImage).fill"
        self.count = count
        self.confirmation = confirmation
        self.tint = tint
        self.style = style
    }

    private var fill: Color { tint ?? style.fill }
    private var box: CGFloat { size * 1.3 }
    private var height: CGFloat { max(44, size * 2) }
    private var leading: CGFloat { style.isContained ? (height - box) / 2 + 2 : 0 }
    private var total: Int? { count.map { $0 + (isOn ? 1 : 0) } }

    public var body: some View {
        Button {
            isOn.toggle()
        } label: {
            HStack(spacing: size * 0.28) {
                symbol
                if let total {
                    Text(total.formatted())
                        .font(.system(size: size * 0.7, weight: .semibold, design: .rounded))
                        .monospacedDigit()
                        .lineLimit(1)
                        .fixedSize()
                        .foregroundStyle(ink)
                        .contentTransition(.numericText(value: Double(total)))
                        .animation(.spring(duration: 0.4, bounce: 0.15), value: isOn)
                }
            }
            .padding(.leading, leading)
            .padding(.trailing, style.isContained ? (count == nil ? leading : height * 0.42) : 0)
            .frame(minWidth: 44, minHeight: height)
            .background { if style.isContained { capsule } }
            .contentShape(.capsule)
        }
        .buttonStyle(Press())
        .background { if !style.isContained, burst > 0 { Halo(color: fill, capsule: false).frame(width: box, height: box).id(burst) } }
        .overlay { if style.isContained, burst > 0 { Halo(color: fill, capsule: true).id(burst) } }
        .overlay(alignment: .top) { pill }
        .sensoryFeedback(.impact(flexibility: .soft), trigger: isOn) { _, on in on }
        .accessibilityLabel(confirmation ?? (systemImage.hasPrefix("bookmark") ? "Save" : "Like"))
        .accessibilityValue(total.map { $0.formatted() } ?? "")
        .accessibilityAddTraits(isOn ? .isSelected : [])
        .onChange(of: isOn) { _, on in
            if on {
                withAnimation(reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.5, bounce: 0.1)) { flood = 1 }
                if !reduceMotion { burst += 1 }
                guard confirmation != nil else { return }
                withAnimation(reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.4, bounce: 0.35)) { showsPill = true }
            } else {
                withAnimation(.easeOut(duration: 0.28)) {
                    flood = 0
                    showsPill = false
                }
            }
        }
        .task(id: showsPill) {
            guard showsPill else { return }
            try? await Task.sleep(for: .seconds(1.3))
            guard !Task.isCancelled else { return }
            withAnimation(.easeOut(duration: 0.25)) { showsPill = false }
        }
    }

    /// Ink follows the flood so the symbol never disappears into the block.
    private var ink: Color {
        style.isContained && isOn ? style.ink : style.restInk
    }

    /// Rest capsule with a solid circle that grows out of the symbol to fill it.
    private var capsule: some View {
        GeometryReader { proxy in
            let reach = hypot(proxy.size.width, proxy.size.height) * 1.05
            ZStack {
                Capsule().fill(style.restFill)
                Circle()
                    .fill(fill)
                    .frame(width: reach * 2, height: reach * 2)
                    .scaleEffect(max(flood, 0.001))
                    .position(x: leading + box / 2, y: proxy.size.height / 2)
            }
            .clipShape(.capsule)
        }
    }

    /// Contained: the symbol swaps to its filled form in ink. Bare: a filled block sits under an ink outline, like a sticker.
    private var symbol: some View {
        ZStack {
            if !style.isContained {
                Image(systemName: filledImage)
                    .foregroundStyle(fill)
                    .scaleEffect(flood)
                    .opacity(flood)
            }
            Image(systemName: isOn && style.isContained ? filledImage : systemImage)
                .foregroundStyle(ink)
                .contentTransition(.symbolEffect(.replace.downUp))
        }
        .font(.system(size: size, weight: .semibold))
        .frame(width: box, height: box)
            .keyframeAnimator(initialValue: Bounce(), trigger: burst) { view, value in
                view.scaleEffect(value.scale).rotationEffect(.degrees(value.tilt))
            } keyframes: { _ in
                KeyframeTrack(\.scale) {
                    CubicKeyframe(0.78, duration: 0.08)
                    SpringKeyframe(1.24, duration: 0.18, spring: .snappy)
                    SpringKeyframe(1, duration: 0.4, spring: .bouncy)
                }
                KeyframeTrack(\.tilt) {
                    CubicKeyframe(-12, duration: 0.14)
                    SpringKeyframe(0, duration: 0.45, spring: .bouncy)
                }
            }
    }

    @ViewBuilder
    private var pill: some View {
        if showsPill, let confirmation {
            HStack(spacing: 5) {
                Image(systemName: "checkmark").font(.caption2.weight(.heavy))
                Text(confirmation)
            }
            .font(.footnote.weight(.semibold))
            .foregroundStyle(style.pillInk)
            .padding(.horizontal, 12)
            .padding(.vertical, 7)
            .background(style.pillFill, in: .capsule)
            .shadow(color: .black.opacity(0.18), radius: 10, y: 5)
            .fixedSize()
            .offset(y: -height * 0.5 - 22)
            .transition(.scale(scale: 0.6, anchor: .bottom).combined(with: .opacity).combined(with: .offset(y: 8)))
            .allowsHitTesting(false)
            .accessibilityHidden(true)
        }
    }

    private struct Bounce {
        var scale: CGFloat = 1
        var tilt: Double = 0
    }

    /// Stiff press-in, bouncy release.
    private struct Press: ButtonStyle {
        func makeBody(configuration: Configuration) -> some View {
            configuration.label
                .scaleEffect(configuration.isPressed ? 0.92 : 1)
                .animation(configuration.isPressed ? .spring(duration: 0.12, bounce: 0) : .spring(duration: 0.45, bounce: 0.5), value: configuration.isPressed)
        }
    }

    /// One outline that expands from the toggle and fades; re-created per reaction via `.id`.
    private struct Halo: View {
        let color: Color
        let capsule: Bool
        @State private var fired = false

        var body: some View {
            Group {
                if capsule {
                    Capsule().strokeBorder(color, lineWidth: fired ? 1 : 3)
                        .padding(fired ? -12 : 0)
                } else {
                    Circle().strokeBorder(color, lineWidth: fired ? 0.5 : 2.5)
                        .scaleEffect(fired ? 1.8 : 0.7)
                }
            }
            .opacity(fired ? 0 : 1)
            .allowsHitTesting(false)
            .accessibilityHidden(true)
            .onAppear { withAnimation(.easeOut(duration: 0.6)) { fired = true } }
        }
    }
}

// MARK: - Example

/// The toggle itself, in its three shapes: a count, a confirmation pill, and the bare symbol.
private struct ReactionToggleExample: View {
    @State private var liked = false
    @State private var saved = true
    @State private var boosted = false

    var body: some View {
        HStack(spacing: 26) {
            ReactionToggle(isOn: $liked, count: 128, size: 36)
            ReactionToggle(isOn: $saved, systemImage: "bookmark", confirmation: "Saved", size: 36, style: .init(fill: ReactionToggle.Style.sky))
            ReactionToggle(isOn: $boosted, systemImage: "star", size: 36, style: .init(fill: ReactionToggle.Style.butter, isContained: false))
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(ReactionToggle.Style.adaptive(0xF3F2EE, 0x121212))
    }
}

#Preview("Light") {
    ReactionToggleExample()
}

#Preview("Dark") {
    ReactionToggleExample().preferredColorScheme(.dark)
}

Install

Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.

Terminal

$npx swiftpieces add ReactionToggle

Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected

>Add the Swift Pieces "Reaction Toggle" piece to my app

Or copy the source above into your app.

Building a whole app? See Swift Pieces Pro →