Emoji Reaction Picker
A long-press control that reveals a row of reaction emoji, magnifies the one under the finger and selects it on release.

- Buttons
- iOS 18.0+
- advanced
- Origin
- Original work
- Found by
- emoji · reactions · like · long press · picker · drag · social · emoji · reacciones · me gusta · pulsación larga · selector · arrastrar · red social
Description
Section titled “Description”A button that toggles the first reaction on tap and reveals every reaction on long press. Slide along the row to magnify one and lift the finger to pick it.
The row opens above the button's leading edge. Reduce Motion replaces the magnification with a highlight, and VoiceOver offers each reaction as an action.
struct EmojiReactionPickerDemo: View { @State private var reaction: EmojiReactionPickerOption?
var body: some View { VStack(alignment: .leading, spacing: 16) { RoundedRectangle(cornerRadius: 22, style: .continuous) .fill(.linearGradient(colors: [.orange, .pink, .purple], startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(height: 280) .overlay { Image(systemName: "sun.haze.fill") .font(.system(.largeTitle, weight: .bold)) .foregroundStyle(.white) } .accessibilityHidden(true)
Text("Sunset from the rooftop") .font(.title3.weight(.semibold))
EmojiReactionPicker(selection: $reaction) .padding(.top, 72) } .padding(20) .frame(maxWidth: .infinity, maxHeight: .infinity) }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
selection | Binding<EmojiReactionPickerOption?> | Required |
options | Not written | EmojiReactionPickerOption.defaults |
onSelect | (EmojiReactionPickerOption?) -> Void | { _ in } |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
struct EmojiReactionPickerOption: Identifiable, Equatable, Sendable { let emoji: String let name: LocalizedStringResource
var id: String { emoji }
static let defaults = [ EmojiReactionPickerOption(emoji: "👍", name: "Like"), EmojiReactionPickerOption(emoji: "❤️", name: "Love"), EmojiReactionPickerOption(emoji: "😂", name: "Haha"), EmojiReactionPickerOption(emoji: "😮", name: "Wow"), EmojiReactionPickerOption(emoji: "😢", name: "Sad"), ]}
enum EmojiReactionPickerMath { /// The cell under a horizontal position, clamped to the row. Nil when there is no row to hit. static func index(atX x: CGFloat, cellWidth: CGFloat, spacing: CGFloat, count: Int) -> Int? { guard count > 0, cellWidth > 0, x.isFinite else { return nil } let position = x / (cellWidth + max(spacing, 0)) return Int(min(max(position, 0), CGFloat(count - 1))) }}
/// A button that toggles the first reaction on tap and reveals every reaction on long press./// Slide along the row to magnify one and lift the finger to pick it.////// The row opens above the button's leading edge. Reduce Motion replaces the magnification/// with a highlight, and VoiceOver offers each reaction as an action.struct EmojiReactionPicker: View { private enum Press: Equatable { case idle case active(location: CGPoint?) }
@Binding var selection: EmojiReactionPickerOption? var options = EmojiReactionPickerOption.defaults var onSelect: (EmojiReactionPickerOption?) -> Void = { _ in }
@GestureState private var press = Press.idle /// Measured from the row, which reads its own size under its text size cap. @State private var cellSize = 46.0 @State private var triggerWidth: CGFloat = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.layoutDirection) private var layoutDirection
private let spacing = 6.0 private let padding = 8.0 private let gap = 12.0 /// How far from the row the finger can drift before lifting it cancels. private let tolerance = 70.0
private var isOpen: Bool { press != .idle && !options.isEmpty }
private var highlighted: Int? { guard case .active(let location?) = press else { return nil } return index(at: location) }
private var animation: Animation? { reduceMotion ? nil : .spring(duration: 0.28, bounce: 0.35) }
var body: some View { trigger .contentShape(.capsule) .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { triggerWidth = $0 } .onTapGesture(perform: toggle) .gesture(longPressAndSlide) .overlay(alignment: .topLeading) { if isOpen { row .padding(.bottom, gap) // A zero-height frame hangs the row above the button, whatever its height. .frame(height: 0, alignment: .bottom) .transition(reduceMotion ? .opacity : .scale(scale: 0.5, anchor: .bottomLeading).combined(with: .opacity)) } } .animation(animation, value: isOpen) .animation(animation, value: highlighted) .sensoryFeedback(.selection, trigger: highlighted) .accessibilityElement(children: .ignore) .accessibilityLabel("Reaction") .accessibilityValue(selection?.name ?? "None") .accessibilityAddTraits(.isButton) .accessibilityAction(.default, toggle) .accessibilityActions { ForEach(options) { option in Button(option.name) { choose(option) } } } }
private var trigger: some View { HStack(spacing: 8) { if let selection { Text(selection.emoji) Text(selection.name) } else { Image(systemName: "hand.thumbsup") Text("React") } } .font(.headline) .foregroundStyle(selection == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(.primary)) .padding(.horizontal, 18) .padding(.vertical, 12) .background(.quaternary, in: Capsule()) }
private var row: some View { EmojiReactionPickerRow(options: options, highlighted: highlighted, spacing: spacing, padding: padding) // The row cannot wrap under the finger, so it stops growing. .dynamicTypeSize(...DynamicTypeSize.xxxLarge) .fixedSize() .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { cellSize = $0 - padding * 2 } }
private var longPressAndSlide: some Gesture { LongPressGesture(minimumDuration: 0.3) .sequenced(before: DragGesture(minimumDistance: 0)) .updating($press) { value, state, _ in switch value { case .first(true): state = .active(location: nil) case .second(true, let drag): state = .active(location: drag?.location) default: state = .idle } } .onEnded { value in guard case .second(true, let drag?) = value, let index = index(at: drag.location), options.indices.contains(index) else { return } choose(options[index]) } }
/// Positions are relative to the button, and the row's first cell starts at its leading edge. private func index(at location: CGPoint) -> Int? { let rowHeight = cellSize + padding * 2 guard location.y > -(gap + rowHeight + tolerance), location.y < cellSize + tolerance else { return nil } // Gestures report screen points, but the row mirrors in right-to-left layouts. let x = layoutDirection == .rightToLeft ? triggerWidth - location.x : location.x return EmojiReactionPickerMath.index(atX: x - padding, cellWidth: cellSize, spacing: spacing, count: options.count) }
private func toggle() { guard let first = options.first else { return } let new = selection == nil ? first : nil selection = new onSelect(new) }
private func choose(_ option: EmojiReactionPickerOption) { selection = option onSelect(option) }}
private struct EmojiReactionPickerRow: View { let options: [EmojiReactionPickerOption] let highlighted: Int? let spacing: Double let padding: Double
@ScaledMetric(relativeTo: .title) private var cellSize = 46.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { HStack(spacing: spacing) { ForEach(Array(options.enumerated()), id: \.offset) { index, option in let isHighlighted = index == highlighted
Text(option.emoji) .font(.title) .frame(width: cellSize, height: cellSize) .background { if isHighlighted && reduceMotion { Circle().fill(.tint.opacity(0.3)) } } .scaleEffect(isHighlighted && !reduceMotion ? 1.55 : 1, anchor: .bottom) .zIndex(isHighlighted ? 1 : 0) } } .padding(padding) .background(.regularMaterial, in: Capsule()) .shadow(color: .black.opacity(0.18), radius: 14, y: 6) }}
// MARK: - Demo
struct EmojiReactionPickerDemo: View { @State private var reaction: EmojiReactionPickerOption?
var body: some View { VStack(alignment: .leading, spacing: 16) { RoundedRectangle(cornerRadius: 22, style: .continuous) .fill(.linearGradient(colors: [.orange, .pink, .purple], startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(height: 280) .overlay { Image(systemName: "sun.haze.fill") .font(.system(.largeTitle, weight: .bold)) .foregroundStyle(.white) } .accessibilityHidden(true)
Text("Sunset from the rooftop") .font(.title3.weight(.semibold))
EmojiReactionPicker(selection: $reaction) .padding(.top, 72) } .padding(20) .frame(maxWidth: .infinity, maxHeight: .infinity) }}
#Preview { EmojiReactionPickerDemo()}