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.
- 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.
Styleholds the block (fill), itsink, the rest capsule colors and the pill colors, with house palette defaults that adapt to light and dark.tintstill works and overridesfill. SetisContained: falsefor 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. countis the base value from your model; the view adds one whileisOnso the number rolls immediately without a round trip.sizescales with Dynamic Type and the hit area never drops below 44 pt.confirmationfloats 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
| Parameter | Description |
|---|---|
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. |
Source
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
Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected
Or copy the source above into your app.
Building a whole app? See Swift Pieces Pro →
Rating Scrub
A star rating you tap or scrub, with soft rounded stars that fill as solid blocks colored by the score, the star under your finger lifting, a staggered spring settle, and a light numeral with a label chip that morph as you go.
Skeleton Loader
Solid placeholder bones and a self-masking modifier that turns any layout, color blocks included, into one quiet shape sweeping a soft diagonal highlight on a shared clock, then hands off as content unblurs and rises row by row.