Skip to content

Tapback Reaction Bubble

A message you press and hold to react to, with a bar of reactions above it and the chosen one kept as a badge on its corner.

Tapback Reaction Bubble, playing
  • Chat
  • iOS 18.0+
  • intermediate
Download .swift
Origin
Inspired by Tapback in Apple's Messages, written from scratch
Found by
tapback · message reaction · long press · chat bubble · emoji · react · imessage · tapback · reacción a mensaje · pulsación larga · burbuja de chat · emoji · reaccionar · mensajes

A message you press and hold to react to: it lifts, a bar of reactions opens above it, and the one you choose stays on its corner as a badge. Choose it again to take it away.

The caller owns isPicking, so it can close the bar from outside and dim the other messages. VoiceOver offers each reaction as an action of the message. With Reduce Motion on, nothing scales: the bar and the badge fade.

private struct TapbackReactionBubbleDemoMessage: Identifiable {
let id: Int
let text: String
let isMine: Bool
}
struct TapbackReactionBubbleDemo: View {
private let messages = [
TapbackReactionBubbleDemoMessage(id: 0, text: "Are we still on for the hike on Saturday?", isMine: false),
TapbackReactionBubbleDemoMessage(id: 1, text: "Yes! I’ll bring the snacks.", isMine: true),
TapbackReactionBubbleDemoMessage(id: 2, text: "Perfect. Meet at the trailhead at eight?", isMine: false),
TapbackReactionBubbleDemoMessage(id: 3, text: "Eight it is.", isMine: true),
]
@State private var reactions: [Int: String] = [1: "❤️"]
@State private var picking: Int?
var body: some View {
VStack(spacing: 18) {
Text("Press and hold a message")
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.bottom, 4)
ForEach(messages) { message in
let isOther = picking != nil && picking != message.id
TapbackReactionBubble(
reaction: Binding { reactions[message.id] } set: { reactions[message.id] = $0 },
isPicking: Binding { picking == message.id } set: { picking = $0 ? message.id : nil },
side: message.isMine ? .trailing : .leading
) {
Text(message.text)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.foregroundStyle(message.isMine ? .white : .primary)
.background(message.isMine ? AnyShapeStyle(.blue) : AnyShapeStyle(.fill.tertiary), in: .rect(cornerRadius: 20))
}
.frame(maxWidth: 280, alignment: message.isMine ? .trailing : .leading)
.frame(maxWidth: .infinity, alignment: message.isMine ? .trailing : .leading)
.opacity(isOther ? 0.3 : 1)
.allowsHitTesting(!isOther)
.zIndex(picking == message.id ? 1 : 0)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 80)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
// Catches the taps outside the open message, which close its bar.
Color(.systemBackground)
.ignoresSafeArea()
.onTapGesture { picking = nil }
}
.animation(.easeInOut(duration: 0.25), value: picking)
}
}
ParameterTypeDefaultDescription
reactionBinding<String?>Required
isPickingBinding<Bool>Required
reactionsNot written["❤️", "👍", "👎", "😂", "‼️", "❓"]
sideNot writtenHorizontalAlignment.leadingThe side of the screen the message sits on. The bar grows from that side and the badge goes to the other corner, toward the middle of the screen.
content() -> ContentRequired

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

TapbackReactionBubble.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
nonisolated enum TapbackReactionBubbleLogic {
/// The reaction a message has after one is chosen: choosing the one it had takes it away.
static func toggled(_ current: String?, choosing reaction: String) -> String? {
current == reaction ? nil : reaction
}
/// How long a reaction of the bar waits before it pops in, so they arrive in a quick wave
/// that a long bar does not stretch.
static func delay(for index: Int, step: Double = 0.03, limit: Double = 0.18) -> Double {
min(Double(max(index, 0)) * step, limit)
}
}
/// A message you press and hold to react to: it lifts, a bar of reactions opens above it, and the
/// one you choose stays on its corner as a badge. Choose it again to take it away.
///
/// The caller owns `isPicking`, so it can close the bar from outside and dim the other messages.
/// VoiceOver offers each reaction as an action of the message. With Reduce Motion on, nothing
/// scales: the bar and the badge fade.
struct TapbackReactionBubble<Content: View>: View {
@Binding var reaction: String?
@Binding var isPicking: Bool
var reactions = ["❤️", "👍", "👎", "😂", "‼️", "❓"]
/// The side of the screen the message sits on. The bar grows from that side and the badge
/// goes to the other corner, toward the middle of the screen.
var side = HorizontalAlignment.leading
@ViewBuilder var content: () -> Content
private let gap: CGFloat = 8
private let badgeOverhang = 0.65
@State private var isBarShown = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
// Plain values for the alignment guides, which may not capture a generic view: Xcode 26 rejects it.
let gap = gap
let overhang = badgeOverhang
let isLeading = side == .leading
content()
.overlay(alignment: Alignment(horizontal: side == .leading ? .trailing : .leading, vertical: .top)) {
// The guides go on a container that is always there: on a view that comes and goes
// with a transition they are not read.
ZStack {
if let reaction {
badge(reaction)
}
}
// Most of the badge hangs outside the corner, so it does not cover the message.
.alignmentGuide(.top) { $0.height * overhang }
.alignmentGuide(isLeading ? .trailing : .leading) { $0.width * (isLeading ? 1 - overhang : overhang) }
}
.scaleEffect(isPicking && !reduceMotion ? 1.04 : 1)
.overlay(alignment: Alignment(horizontal: side, vertical: .top)) {
ZStack {
if isPicking {
bar
.transition(reduceMotion ? .opacity : .scale(scale: 0.5, anchor: .bottom).combined(with: .opacity))
}
}
// The bottom of the bar sits a gap above the top of the message.
.alignmentGuide(.top) { $0[.bottom] + gap }
}
.zIndex(isPicking ? 1 : 0)
.onLongPressGesture(minimumDuration: 0.4) {
isPicking = true
}
.animation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.35, bounce: 0.3), value: isPicking)
.animation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.35, bounce: 0.5), value: reaction)
.sensoryFeedback(trigger: isPicking) { _, isPicking in
isPicking ? .impact(weight: .medium) : nil
}
.sensoryFeedback(.selection, trigger: reaction)
.accessibilityElement(children: .combine)
.accessibilityValue(reaction.map { "Reacted with \($0)" } ?? "")
.accessibilityActions {
ForEach(reactions, id: \.self) { option in
Button(option == reaction ? "Remove \(option)" : "React with \(option)") {
choose(option)
}
}
}
}
private func badge(_ reaction: String) -> some View {
Text(reaction)
.font(.footnote)
.padding(6)
.background(.regularMaterial, in: .circle)
.overlay { Circle().strokeBorder(.background, lineWidth: 2) }
.transition(reduceMotion ? .opacity : .scale(scale: 0.2).combined(with: .opacity))
.id(reaction)
.accessibilityHidden(true)
}
private var bar: some View {
HStack(spacing: 4) {
ForEach(Array(reactions.enumerated()), id: \.element) { index, option in
Button {
choose(option)
} label: {
Text(option)
.font(.title2)
.frame(minWidth: 40, minHeight: 40)
.background(option == reaction ? AnyShapeStyle(.tint.opacity(0.25)) : AnyShapeStyle(.clear), in: .circle)
}
.buttonStyle(.plain)
.scaleEffect(isBarShown || reduceMotion ? 1 : 0.4)
.animation(.spring(duration: 0.25, bounce: 0.45).delay(TapbackReactionBubbleLogic.delay(for: index)), value: isBarShown)
.accessibilityLabel(option == reaction ? "Remove \(option)" : "React with \(option)")
}
}
.padding(6)
.background(.regularMaterial, in: .capsule)
.shadow(color: .black.opacity(0.18), radius: 12, y: 4)
.fixedSize()
.onAppear { isBarShown = true }
.onDisappear { isBarShown = false }
}
private func choose(_ option: String) {
reaction = TapbackReactionBubbleLogic.toggled(reaction, choosing: option)
isPicking = false
}
}
// MARK: - Demo
private struct TapbackReactionBubbleDemoMessage: Identifiable {
let id: Int
let text: String
let isMine: Bool
}
struct TapbackReactionBubbleDemo: View {
private let messages = [
TapbackReactionBubbleDemoMessage(id: 0, text: "Are we still on for the hike on Saturday?", isMine: false),
TapbackReactionBubbleDemoMessage(id: 1, text: "Yes! I’ll bring the snacks.", isMine: true),
TapbackReactionBubbleDemoMessage(id: 2, text: "Perfect. Meet at the trailhead at eight?", isMine: false),
TapbackReactionBubbleDemoMessage(id: 3, text: "Eight it is.", isMine: true),
]
@State private var reactions: [Int: String] = [1: "❤️"]
@State private var picking: Int?
var body: some View {
VStack(spacing: 18) {
Text("Press and hold a message")
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.bottom, 4)
ForEach(messages) { message in
let isOther = picking != nil && picking != message.id
TapbackReactionBubble(
reaction: Binding { reactions[message.id] } set: { reactions[message.id] = $0 },
isPicking: Binding { picking == message.id } set: { picking = $0 ? message.id : nil },
side: message.isMine ? .trailing : .leading
) {
Text(message.text)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.foregroundStyle(message.isMine ? .white : .primary)
.background(message.isMine ? AnyShapeStyle(.blue) : AnyShapeStyle(.fill.tertiary), in: .rect(cornerRadius: 20))
}
.frame(maxWidth: 280, alignment: message.isMine ? .trailing : .leading)
.frame(maxWidth: .infinity, alignment: message.isMine ? .trailing : .leading)
.opacity(isOther ? 0.3 : 1)
.allowsHitTesting(!isOther)
.zIndex(picking == message.id ? 1 : 0)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 80)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
// Catches the taps outside the open message, which close its bar.
Color(.systemBackground)
.ignoresSafeArea()
.onTapGesture { picking = nil }
}
.animation(.easeInOut(duration: 0.25), value: picking)
}
}
#Preview {
TapbackReactionBubbleDemo()
}