Skip to content

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.

Emoji Reaction Picker, playing
Download .swift
Origin
Original work
Found by
emoji · reactions · like · long press · picker · drag · social · emoji · reacciones · me gusta · pulsación larga · selector · arrastrar · red social

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)
}
}
ParameterTypeDefault
selectionBinding<EmojiReactionPickerOption?>Required
optionsNot writtenEmojiReactionPickerOption.defaults
onSelect(EmojiReactionPickerOption?) -> Void{ _ in }

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

EmojiReactionPicker.swift
// 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()
}