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 init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add emoji-picker
pnpm dlx ermineui add emoji-picker
yarn dlx ermineui add emoji-picker
bunx ermineui add emoji-picker

That 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

FileWhere it goes
EmojiPicker.vuesrc/components/ui/emoji-picker/EmojiPicker.vue
emoji.tssrc/components/ui/emoji-picker/emoji.ts
index.tssrc/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'