Emoji picker
Emoji in groups with a search box, moved through with the arrow keys, each one named for screen readers.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add emoji-pickerpnpm dlx ermineui add emoji-pickeryarn dlx ermineui add emoji-pickerbunx ermineui add emoji-pickerThat copies 3 files into src/components/ui/emoji-picker/. Files you already have are kept.
Use
import { EmojiPicker, emojiCategories, searchEmoji } from '@/components/ui/emoji-picker'<EmojiPicker v-model:recent="recent" @select="(emoji) => (message += emoji)" />
<ImageCropper ref="cropper" v-model="crop" :src="pictureUrl" round />
<Button @click="async () => upload(await cropper.crop({ width: 512 }))">Save</Button>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
EmojiPicker.vue | src/components/ui/emoji-picker/EmojiPicker.vue |
emoji.ts | src/components/ui/emoji-picker/emoji.ts |
index.ts | src/components/ui/emoji-picker/index.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
EmojiPicker.vue 265 lines
<script setup lang="ts">
import { computed, nextTick, ref, useId, watch } from 'vue'
import { emojiCategories, searchEmoji, type Emoji, type EmojiCategory } from './emoji'
const props = withDefaults(
defineProps<{
categories?: readonly EmojiCategory[]
/** Accessible name of the whole picker. */
label?: string
searchLabel?: string
/** What to say when the search finds nothing. */
emptyLabel?: string
/** How many of the emoji last chosen to keep at the top. 0 keeps none. */
recentCount?: number
}>(),
{ categories: () => emojiCategories, label: 'Emoji', searchLabel: 'Search emoji', emptyLabel: 'No emoji found.', recentCount: 16 },
)
const emit = defineEmits<{
/** An emoji was chosen. */
select: [emoji: string, name: string]
}>()
/** The emoji chosen lately, newest first. Bind it and keep it in storage to remember them between visits. */
const recent = defineModel<string[]>('recent', { default: () => [] })
const id = useId()
const query = ref('')
const grid = ref<HTMLElement | null>(null)
/** The one emoji that can be tabbed to; the arrow keys move it. */
const stop = ref<string>()
const byChar = computed(() => new Map(props.categories.flatMap((category) => category.emoji).map((emoji) => [emoji.char, emoji])))
const groups = computed<EmojiCategory[]>(() => {
if (query.value.trim()) return [{ name: 'Results', emoji: searchEmoji(query.value, props.categories) }]
const used = recent.value.map((char) => byChar.value.get(char)).filter((emoji): emoji is Emoji => Boolean(emoji))
return [...(used.length ? [{ name: 'Recent', emoji: used }] : []), ...props.categories]
})
const found = computed(() => groups.value.reduce((sum, group) => sum + group.emoji.length, 0))
const key = (group: EmojiCategory, emoji: Emoji) => `${group.name}:${emoji.char}`
const firstKey = computed(() => (groups.value[0]?.emoji[0] ? key(groups.value[0], groups.value[0].emoji[0]) : undefined))
// A new search, or a new set of groups, puts the tab stop back on the first emoji.
watch(groups, () => {
stop.value = undefined
})
function choose(emoji: Emoji) {
if (props.recentCount > 0) recent.value = [emoji.char, ...recent.value.filter((char) => char !== emoji.char)].slice(0, props.recentCount)
emit('select', emoji.char, emoji.name)
}
function onKeydown(event: KeyboardEvent) {
const buttons = Array.from(grid.value?.querySelectorAll<HTMLElement>('.er-emoji-picker-emoji') ?? [])
const index = buttons.indexOf(event.target as HTMLElement)
if (index === -1) return
let next = index
if (event.key === 'ArrowRight') next = index + 1
else if (event.key === 'ArrowLeft') next = index - 1
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = buttons.length - 1
else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
// Up and down go to whichever emoji in the next row sits closest to straight above or below, even across groups.
const from = buttons[index]!.getBoundingClientRect()
const direction = event.key === 'ArrowDown' ? 1 : -1
let rowTop: number | undefined
let best = index
let bestGap = Infinity
for (let other = index + direction; other >= 0 && other < buttons.length; other += direction) {
const box = buttons[other]!.getBoundingClientRect()
if (Math.abs(box.top - from.top) < 2) continue
rowTop ??= box.top
if (Math.abs(box.top - rowTop) > 2) break
const gap = Math.abs(box.left - from.left)
if (gap < bestGap) [best, bestGap] = [other, gap]
}
next = best
} else return
event.preventDefault()
const target = buttons[Math.max(0, Math.min(next, buttons.length - 1))]!
stop.value = target.dataset.key
void nextTick(() => target.focus())
}
function jump(name: string) {
query.value = ''
void nextTick(() => {
const heading = grid.value?.querySelector<HTMLElement>(`[data-group="${CSS.escape(name)}"]`)
// Scrolled inside the picker only: scrollIntoView would also move the page.
if (heading && grid.value) grid.value.scrollTop = heading.offsetTop - grid.value.offsetTop
heading?.parentElement?.querySelector<HTMLElement>('.er-emoji-picker-emoji')?.focus({ preventScroll: true })
})
}
</script>
<template>
<div class="er-emoji-picker" role="group" :aria-label="label">
<input v-model="query" class="er-emoji-picker-search" type="search" :placeholder="searchLabel" :aria-label="searchLabel" autocomplete="off" />
<nav v-if="!query.trim()" class="er-emoji-picker-nav" aria-label="Emoji groups">
<button v-for="category in categories" :key="category.name" type="button" :aria-label="category.name" :title="category.name" @click="jump(category.name)">
<span aria-hidden="true">{{ category.emoji[0]?.char }}</span>
</button>
</nav>
<p class="er-emoji-picker-status" role="status">{{ query.trim() ? (found ? `${found} found` : emptyLabel) : '' }}</p>
<div ref="grid" class="er-emoji-picker-scroll" @keydown="onKeydown">
<section v-for="group in groups" :key="group.name" :aria-labelledby="`${id}-${group.name}`">
<h3 :id="`${id}-${group.name}`" class="er-emoji-picker-heading" :data-group="group.name">{{ group.name }}</h3>
<p v-if="!group.emoji.length" class="er-emoji-picker-empty">{{ emptyLabel }}</p>
<div class="er-emoji-picker-grid">
<button
v-for="emoji in group.emoji"
:key="emoji.char"
type="button"
class="er-emoji-picker-emoji"
:data-key="key(group, emoji)"
:tabindex="(stop ?? firstKey) === key(group, emoji) ? 0 : -1"
:aria-label="emoji.name"
:title="emoji.name"
@click="choose(emoji)"
@focus="stop = key(group, emoji)"
>
<span aria-hidden="true">{{ emoji.char }}</span>
</button>
</div>
</section>
</div>
</div>
</template>
<style>
@layer components {
.er-emoji-picker {
display: grid;
gap: 0.5rem;
width: 100%;
max-width: 20rem;
padding: 0.625rem;
border: 1px solid var(--er-border);
border-radius: var(--er-radius-lg);
background: var(--er-popover);
color: var(--er-popover-foreground);
}
.er-emoji-picker-search {
width: 100%;
height: 2.25rem;
padding: 0 0.75rem;
border: 1px solid var(--er-input);
border-radius: var(--er-radius-md);
background: var(--er-input-background);
color: var(--er-foreground);
font: inherit;
/* 16px stops iOS from zooming the page when the field is focused. */
font-size: 1rem;
}
@media (min-width: 48rem) {
.er-emoji-picker-search {
font-size: 0.875rem;
}
}
.er-emoji-picker-search::placeholder {
color: var(--er-muted-foreground);
}
.er-emoji-picker-search:focus-visible {
border-color: var(--er-ring);
outline: 2px solid color-mix(in oklab, var(--er-ring) 45%, transparent);
}
.er-emoji-picker-nav {
display: flex;
justify-content: space-between;
gap: 0.125rem;
}
.er-emoji-picker-nav button,
.er-emoji-picker-emoji {
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: var(--er-radius-sm);
background: transparent;
font: inherit;
line-height: 1;
cursor: pointer;
transition: background-color var(--er-duration);
}
.er-emoji-picker-nav button {
flex: 1;
height: 1.75rem;
font-size: 1rem;
/* Greyed, so the row reads as a set of labels rather than a ninth group of emoji. */
filter: grayscale(1);
opacity: 0.7;
}
.er-emoji-picker-nav button:hover,
.er-emoji-picker-emoji:hover {
background: var(--er-accent);
}
.er-emoji-picker-nav button:hover {
filter: none;
opacity: 1;
}
.er-emoji-picker-nav button:focus-visible,
.er-emoji-picker-emoji:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: -1px;
}
.er-emoji-picker-status {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.er-emoji-picker-scroll {
position: relative;
height: 15rem;
overflow-y: auto;
overscroll-behavior: contain;
}
.er-emoji-picker-heading {
position: sticky;
top: 0;
z-index: 1;
margin: 0;
padding: 0.25rem 0.125rem;
background: var(--er-popover);
color: var(--er-muted-foreground);
font-size: 0.75rem;
font-weight: var(--er-weight-semibold);
line-height: 1rem;
}
.er-emoji-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(2.125rem, 1fr));
}
.er-emoji-picker-emoji {
aspect-ratio: 1;
font-size: 1.375rem;
}
.er-emoji-picker-empty {
margin: 0;
padding: 1rem 0.125rem;
color: var(--er-muted-foreground);
font-size: 0.875rem;
}
}
</style>emoji.ts 63 lines
export interface Emoji {
char: string
/** What it shows, in words: read out by screen readers and matched by the search box. */
name: string
}
export interface EmojiCategory {
name: string
emoji: readonly Emoji[]
}
const list = (text: string): Emoji[] =>
text.split('|').map((entry) => {
const space = entry.indexOf(' ')
return { char: entry.slice(0, space), name: entry.slice(space + 1) }
})
/**
* A few hundred of the most used emoji, in the usual groups. Each entry is the emoji, a space and its name.
* Add to the lists, or pass your own categories to the picker.
*/
export const emojiCategories: readonly EmojiCategory[] = [
{
name: 'Smileys',
emoji: list('๐ grinning face|๐ smiling face with big eyes|๐ smiling face with smiling eyes|๐ beaming face|๐ squinting face|๐
grinning face with sweat|๐คฃ rolling on the floor laughing|๐ face with tears of joy|๐ slightly smiling face|๐ upside-down face|๐ winking face|๐ smiling face with blush|๐ smiling face with halo|๐ฅฐ smiling face with hearts|๐ heart eyes|๐คฉ star-struck|๐ face blowing a kiss|๐ face savouring food|๐ face with tongue|๐ winking face with tongue|๐คช zany face|๐ค thinking face|๐คจ face with raised eyebrow|๐ neutral face|๐ expressionless face|๐ถ face without mouth|๐ smirking face|๐ unamused face|๐ face with rolling eyes|๐ฌ grimacing face|๐ relieved face|๐ pensive face|๐ด sleeping face|๐ท face with medical mask|๐ค face with thermometer|๐ฅต hot face|๐ฅถ cold face|๐ต dizzy face|๐คฏ exploding head|๐ฅณ partying face|๐ smiling face with sunglasses|๐ค nerd face|๐ confused face|๐ worried face|๐ฎ face with open mouth|๐ณ flushed face|๐ฅบ pleading face|๐ข crying face|๐ญ loudly crying face|๐ฑ face screaming in fear|๐ค face with steam from nose|๐ก pouting face|๐ angry face|๐คฌ face with symbols on mouth|๐ skull|๐ป ghost|๐ค robot|๐ฉ pile of poo'),
},
{
name: 'People',
emoji: list('๐ waving hand|โ raised hand|๐ OK hand|โ๏ธ victory hand|๐ค crossed fingers|๐ค love-you gesture|๐ค sign of the horns|๐ pointing left|๐ pointing right|๐ pointing up|๐ pointing down|๐ thumbs up|๐ thumbs down|โ raised fist|๐ oncoming fist|๐ clapping hands|๐ raising hands|๐ค handshake|๐ folded hands|โ๏ธ writing hand|๐ช flexed biceps|๐ eyes|๐ง brain|๐ถ baby|๐ง person|๐ฉ woman|๐จ man|๐ง older person|๐ person raising hand|๐คท person shrugging|๐คฆ person facepalming|๐
person gesturing no|๐ person gesturing OK|๐ woman dancing|๐บ man dancing|๐ person running|๐ถ person walking|๐ช family'),
},
{
name: 'Nature',
emoji: list('๐ถ dog|๐ฑ cat|๐ญ mouse|๐น hamster|๐ฐ rabbit|๐ฆ fox|๐ป bear|๐ผ panda|๐จ koala|๐ฏ tiger|๐ฆ lion|๐ฎ cow|๐ท pig|๐ธ frog|๐ต monkey|๐ chicken|๐ง penguin|๐ฆ bird|๐ฆ duck|๐ฆ owl|๐ด horse|๐ฆ unicorn|๐ honeybee|๐ฆ butterfly|๐ข turtle|๐ snake|๐ octopus|๐ฌ dolphin|๐ณ whale|๐ fish|๐ต cactus|๐ฒ evergreen tree|๐ณ deciduous tree|๐ด palm tree|๐ฑ seedling|๐ four leaf clover|๐ท tulip|๐น rose|๐ป sunflower|๐ธ cherry blossom|โ๏ธ sun|๐ crescent moon|โญ star|๐ rainbow|โ๏ธ cloud|๐ง๏ธ cloud with rain|โก lightning|โ๏ธ snowflake|๐ฅ fire|๐ง droplet|๐ water wave'),
},
{
name: 'Food',
emoji: list('๐ red apple|๐ tangerine|๐ lemon|๐ banana|๐ watermelon|๐ grapes|๐ strawberry|๐ cherries|๐ peach|๐ pineapple|๐ฅ avocado|๐
tomato|๐ฅ carrot|๐ฝ corn|๐ฅฆ broccoli|๐ bread|๐ฅ croissant|๐ง cheese|๐ฅ egg|๐ฅ bacon|๐ hamburger|๐ french fries|๐ pizza|๐ญ hot dog|๐ฎ taco|๐ฏ burrito|๐ steaming bowl|๐ spaghetti|๐ฃ sushi|๐ cooked rice|๐ฅ green salad|๐ฟ popcorn|๐ฉ doughnut|๐ช cookie|๐ birthday cake|๐ฐ shortcake|๐ซ chocolate bar|๐ฆ ice cream|โ hot beverage|๐ต tea|๐ฅค cup with straw|๐บ beer|๐ท wine glass|๐ฅ clinking glasses|๐ธ cocktail'),
},
{
name: 'Activities',
emoji: list('โฝ football|๐ basketball|๐ american football|โพ baseball|๐พ tennis|๐ volleyball|๐ table tennis|โณ golf|๐ person swimming|๐ด person cycling|๐๏ธ person lifting weights|๐ง person in lotus position|๐ฎ video game|๐ฒ game die|โ๏ธ chess pawn|๐ฏ bullseye|๐ณ bowling|๐จ artist palette|๐ฌ clapper board|๐ค microphone|๐ง headphone|๐ธ guitar|๐น piano|๐ฅ drum|๐ป violin|๐ party popper|๐ confetti ball|๐ balloon|๐ wrapped gift|๐ trophy|๐ฅ gold medal|๐๏ธ admission tickets'),
},
{
name: 'Travel',
emoji: list('๐ car|๐ taxi|๐ bus|๐ trolleybus|๐ police car|๐ ambulance|๐ fire engine|๐ delivery truck|๐ tractor|๐ฒ bicycle|๐ต motor scooter|๐๏ธ motorcycle|๐ train|๐ metro|โ๏ธ aeroplane|๐ rocket|๐ helicopter|โต sailboat|๐ข ship|โ anchor|๐บ๏ธ world map|๐งญ compass|๐ house|๐ข office building|๐ฅ hospital|๐ซ school|๐จ hotel|๐ช convenience store|๐ฐ castle|๐ฝ Statue of Liberty|๐ผ Tokyo tower|โฐ๏ธ mountain|๐๏ธ beach with umbrella|๐๏ธ camping|๐ volcano|๐ globe showing Europe and Africa|๐ globe showing Americas|๐ globe showing Asia and Australia'),
},
{
name: 'Objects',
emoji: list('โ watch|๐ฑ mobile phone|๐ป laptop|โจ๏ธ keyboard|๐ฅ๏ธ desktop computer|๐จ๏ธ printer|๐ท camera|๐ฅ movie camera|๐บ television|๐ป radio|โฐ alarm clock|โณ hourglass|๐ก light bulb|๐ฆ flashlight|๐ battery|๐ electric plug|๐ฐ money bag|๐ณ credit card|๐ gem stone|๐ง wrench|๐จ hammer|๐งฐ toolbox|๐ key|๐ locked|๐ unlocked|๐ฆ package|โ๏ธ envelope|๐ง e-mail|๐ memo|๐
calendar|๐ pushpin|๐ paperclip|โ๏ธ scissors|๐ books|๐ open book|๐ magnifying glass|๐ shopping cart|๐ backpack|๐ glasses|๐ t-shirt|๐ running shoe|โ๏ธ umbrella|๐งธ teddy bear|๐๏ธ bed|๐ช door'),
},
{
name: 'Symbols',
emoji: list('โค๏ธ red heart|๐งก orange heart|๐ yellow heart|๐ green heart|๐ blue heart|๐ purple heart|๐ค black heart|๐ค white heart|๐ broken heart|๐ two hearts|๐ฏ hundred points|โ
check mark button|โ cross mark|โ question mark|โ exclamation mark|โ ๏ธ warning|๐ซ prohibited|โ no entry|โป๏ธ recycling symbol|โจ sparkles|๐ค zzz|๐ฌ speech balloon|๐ญ thought balloon|๐ bell|๐ bell with slash|๐ต musical note|โ plus|โ minus|โ divide|โ๏ธ multiply|โพ๏ธ infinity|๐ด red circle|๐ orange circle|๐ก yellow circle|๐ข green circle|๐ต blue circle|๐ฃ purple circle|โซ black circle|โช white circle|๐ chequered flag|๐ฉ triangular flag|๐ณ๏ธ white flag'),
},
]
/** The emoji whose names hold every word typed, in any order. */
export function searchEmoji(query: string, categories: readonly EmojiCategory[] = emojiCategories): Emoji[] {
const words = query.toLowerCase().split(/\s+/).filter(Boolean)
if (!words.length) return []
return categories.flatMap((category) => category.emoji).filter((emoji) => words.every((word) => emoji.name.toLowerCase().includes(word)))
}index.ts 4 lines
/** Emoji picker โ emoji in groups with a search box, moved through with the arrow keys, each one named for screen readers. */
export { default as EmojiPicker } from './EmojiPicker.vue'
export { emojiCategories, searchEmoji, type Emoji, type EmojiCategory } from './emoji'