Emoji Picker
A grid of emoji grouped by category, with tabs that jump between sections and a row of recent picks.

- Pickers
- iOS 18.0+
- intermediate
- Origin
- Original work
- Found by
- emoji ยท picker ยท grid ยท smileys ยท keyboard ยท recent ยท reactions ยท emoji ยท selector ยท cuadrรญcula ยท caritas ยท teclado ยท recientes ยท reacciones
Description
Section titled โDescriptionโA grid of emoji grouped by category, with tabs and a row of the ones used last.
With Reduce Motion on, tabs jump to their section without animating.
struct EmojiPickerDemo: View { @State private var recents: [String] = [] @State private var chosen = "๐" @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { VStack(spacing: 0) { ZStack { LinearGradient(colors: [.yellow, .orange, .pink], startPoint: .topLeading, endPoint: .bottomTrailing) .ignoresSafeArea(edges: .top)
Text(chosen) .font(.system(.largeTitle, design: .rounded)) .scaleEffect(2.4) .contentTransition(.opacity) .id(chosen) .transition(reduceMotion ? .identity : .scale(scale: 0.6).combined(with: .opacity)) } .frame(height: 170) .accessibilityHidden(true)
EmojiPicker(recents: $recents) { emoji in withAnimation(reduceMotion ? nil : .bouncy) { chosen = emoji } } } }}API reference
Section titled โAPI referenceโ| Parameter | Type | Default |
|---|---|---|
recents | Binding<[String]> | Required |
categories | [EmojiPickerCategory] | EmojiPickerCategory.standard |
recentLimit | Int | 12 |
onSelect | (String) -> Void | Required |
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 EmojiPickerCategory: Identifiable, Hashable, Sendable { let id: String let title: String let tabLabel: String let systemImage: String let emoji: [String]
init(title: String, tabLabel: String, systemImage: String, emoji: String) { id = tabLabel self.title = title self.tabLabel = tabLabel self.systemImage = systemImage self.emoji = emoji.map(String.init) }
static let standard = [ EmojiPickerCategory(title: "Smileys & People", tabLabel: "Smileys", systemImage: "face.smiling", emoji: "๐๐๐๐๐๐
๐๐คฃ๐๐๐๐๐๐ฅฐ๐๐๐๐คช๐๐คฉ๐ฅณ๐๐๐ข๐ญ๐ค๐ก๐คฏ๐ฑ๐ค๐ค๐ด๐๐๐๐๐๐ช๐โ๏ธ"), EmojiPickerCategory(title: "Animals & Nature", tabLabel: "Animals", systemImage: "pawprint", emoji: "๐ถ๐ฑ๐ญ๐น๐ฐ๐ฆ๐ป๐ผ๐จ๐ฏ๐ฆ๐ฎ๐ท๐ธ๐ต๐๐ง๐ฆ๐ฆ๐ฆ๐ฆ๐๐ข๐๐ฌ๐ณ๐ธ๐ป๐ณ๐ต๐๐๐โญ๏ธ๐ฅ๐ง"), EmojiPickerCategory(title: "Food & Drink", tabLabel: "Food", systemImage: "fork.knife", emoji: "๐๐๐๐๐๐๐๐๐๐ฅญ๐๐ฅฅ๐ฅ๐
๐ฝ๐ฅ๐ฅ๐ฅ๐ง๐ณ๐๐๐๐ฎ๐ฃ๐๐ฐ๐ง๐ซ๐ฉ๐ชโ๏ธ๐ต๐บ๐ท๐ฅ"), EmojiPickerCategory(title: "Travel & Places", tabLabel: "Travel", systemImage: "airplane", emoji: "๐๐๐๐๏ธ๐๐๐๐ฒ๐ต๐โ๏ธ๐๐ธ๐โต๏ธ๐ข๐บ๏ธ๐๏ธ๐๏ธ๐๏ธโบ๏ธ๐ ๐ฐ๐ผ๐ฝ๐ก๐๐
๐"), EmojiPickerCategory(title: "Everyday Objects", tabLabel: "Objects", systemImage: "lightbulb", emoji: "โ๏ธ๐ฑ๐ปโจ๏ธ๐ฅ๏ธ๐ท๐ฅ๐บ๐ปโฐ๐ฆ๐ก๐โ๏ธ๐โ๏ธ๐๐๐จ๐งฐ๐๐๐๐จ๐ธ๐ง๐ฎโฝ๏ธ๐๐พ"), EmojiPickerCategory(title: "Signs & Symbols", tabLabel: "Symbols", systemImage: "heart", emoji: "โค๏ธ๐งก๐๐๐๐๐ค๐ค๐๐๐ฏโ
โโญ๏ธโ๏ธโโ ๏ธโป๏ธ๐๐ตโโโก๏ธโฌ
๏ธโฌ๏ธโฌ๏ธ๐"), EmojiPickerCategory(title: "World Flags", tabLabel: "Flags", systemImage: "flag", emoji: "๐๐ฉ๐ณ๏ธ๐ด๐ณ๏ธโ๐๐ช๐ธ๐ซ๐ท๐ฉ๐ช๐ฎ๐น๐ต๐น๐ฌ๐ง๐ฎ๐ช๐บ๐ธ๐จ๐ฆ๐ฒ๐ฝ๐ง๐ท๐ฆ๐ท๐ฏ๐ต๐ฐ๐ท๐จ๐ณ๐ฎ๐ณ๐ฆ๐บ๐ฟ๐ฆ"), ]}
/// A grid of emoji grouped by category, with tabs and a row of the ones used last.////// With Reduce Motion on, tabs jump to their section without animating.struct EmojiPicker: View { @Binding var recents: [String] var categories: [EmojiPickerCategory] = EmojiPickerCategory.standard var recentLimit = 12 let onSelect: (String) -> Void
@State private var visibleIDs: [String] = [] @State private var isAtEnd = false /// The tab that was tapped stays chosen until the grid moves again. A short section near /// the end cannot reach the top, and would lose its tab to the one above it. @State private var tappedID: String? @State private var isJumping = false /// A scroll view reader cannot jump to a section the lazy stack has not built yet. This can. @State private var position = ScrollPosition(idType: String.self) @ScaledMetric(relativeTo: .title) private var cellSize = 44.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion
private let edgePadding: CGFloat = 12
/// The first section in sight, or the last one once the grid is at its end, which a short /// section there would never reach otherwise. private var activeID: String? { let visible = categories.filter { visibleIDs.contains($0.id) } return isAtEnd ? visible.last?.id : visible.first?.id }
private var animation: Animation? { reduceMotion ? nil : .smooth(duration: 0.35) }
var body: some View { VStack(spacing: 0) { if !recents.isEmpty { recentRow }
tabBar Divider() grid } .animation(reduceMotion ? nil : .snappy, value: recents) }
private var recentRow: some View { ScrollView(.horizontal) { HStack(spacing: 4) { ForEach(recents, id: \.self, content: emojiButton) } .padding(.horizontal, edgePadding) } .scrollIndicators(.hidden) .padding(.vertical, 6) .accessibilityElement(children: .contain) .accessibilityLabel("Recent") }
private var tabBar: some View { ScrollView(.horizontal) { HStack(spacing: 2) { ForEach(categories) { category in let isActive = category.id == (tappedID ?? activeID ?? categories.first?.id)
Button { tappedID = category.id isJumping = true withAnimation(animation, completionCriteria: .logicallyComplete) { position.scrollTo(id: category.id, anchor: .top) } completion: { isJumping = false } } label: { Image(systemName: category.systemImage) .font(.title3) .foregroundStyle(isActive ? Color.accentColor : Color.secondary) .frame(minWidth: cellSize, minHeight: cellSize) .background(isActive ? Color.accentColor.opacity(0.15) : .clear, in: .rect(cornerRadius: 12)) } .buttonStyle(.plain) .accessibilityLabel(category.tabLabel) .accessibilityAddTraits(isActive ? .isSelected : []) } } .padding(.horizontal, edgePadding) .padding(.vertical, 6) } .scrollIndicators(.hidden) }
private var grid: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 18) { ForEach(categories) { category in VStack(alignment: .leading, spacing: 8) { Text(category.title) .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) .accessibilityAddTraits(.isHeader)
LazyVGrid(columns: [GridItem(.adaptive(minimum: max(cellSize, 1)), spacing: 4)], spacing: 4) { ForEach(category.emoji, id: \.self, content: emojiButton) } } .id(category.id) } } .scrollTargetLayout() .padding(edgePadding) } .scrollPosition($position, anchor: .top) .onScrollPhaseChange { _, phase in if phase == .interacting || (phase == .animating && !isJumping) { tappedID = nil } } .onScrollTargetVisibilityChange(idType: String.self, threshold: 0.01) { ids in visibleIDs = ids } .onScrollGeometryChange(for: Bool.self) { geometry in geometry.visibleRect.maxY >= geometry.contentSize.height - 1 } action: { _, atEnd in isAtEnd = atEnd } }
private func emojiButton(_ emoji: String) -> some View { Button { recents = EmojiPickerRecents.adding(emoji, to: recents, limit: recentLimit) onSelect(emoji) } label: { Text(emoji) .font(.title) .frame(minWidth: cellSize, minHeight: cellSize) .contentShape(.rect) } .buttonStyle(.plain) }}
enum EmojiPickerRecents { /// Puts the emoji first, without repeating it, and keeps at most `limit` of them. static func adding(_ emoji: String, to recents: [String], limit: Int) -> [String] { Array(([emoji] + recents.filter { $0 != emoji }).prefix(max(limit, 0))) }}
// MARK: - Demo
struct EmojiPickerDemo: View { @State private var recents: [String] = [] @State private var chosen = "๐" @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { VStack(spacing: 0) { ZStack { LinearGradient(colors: [.yellow, .orange, .pink], startPoint: .topLeading, endPoint: .bottomTrailing) .ignoresSafeArea(edges: .top)
Text(chosen) .font(.system(.largeTitle, design: .rounded)) .scaleEffect(2.4) .contentTransition(.opacity) .id(chosen) .transition(reduceMotion ? .identity : .scale(scale: 0.6).combined(with: .opacity)) } .frame(height: 170) .accessibilityHidden(true)
EmojiPicker(recents: $recents) { emoji in withAnimation(reduceMotion ? nil : .bouncy) { chosen = emoji } } } }}
#Preview { EmojiPickerDemo()}