Skip to content

Emoji Picker

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

Emoji Picker, playing
Download .swift
Origin
Original work
Found by
emoji ยท picker ยท grid ยท smileys ยท keyboard ยท recent ยท reactions ยท emoji ยท selector ยท cuadrรญcula ยท caritas ยท teclado ยท recientes ยท reacciones

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 }
}
}
}
}
ParameterTypeDefault
recentsBinding<[String]>Required
categories[EmojiPickerCategory]EmojiPickerCategory.standard
recentLimitInt12
onSelect(String) -> VoidRequired

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

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