emoji-picker-element/README.md

21 KiB
Raw Blame History

emoji-picker-element

A lightweight emoji picker, distributed as a custom element.

It's built on top of IndexedDB, so it consumes far less memory than other emoji pickers. It also uses Svelte, so it has a minimal runtime footprint.

Design goals:

  • Store emoji data in IndexedDB
  • Render native emoji
  • Accessible
  • Drop-in as a vanilla web component

Install

npm install emoji-picker-element

Usage

<emoji-picker></emoji-picker>
import 'emoji-picker-element';

Then listen for emoji-click events:

document.querySelector('emoji-picker')
  .addEventListener('emoji-click', event => console.log(event.detail));

This will log:

{
  "annotation": "grinning face",
  "group": 0,
  "order": 1,
  "shortcodes": [ "gleeful" ],
  "tags": [ "face", "grin" ],
  "tokens": [ ":d", "face", "gleeful", "grin", "grinning" ],
  "unicode": "😀",
  "version": 1,
  "emoticon": ":D"
}

Styling

emoji-picker-element uses Shadow DOM, so its inner styling cannot be changed with arbitrary CSS. Refer to the API below for style customization.

Size

emoji-picker-element has a default size, but you can change it to whatever you want:

emoji-picker {
  width: 400px;
  height: 300px;
}

For instance, to make it expand to fit whatever container you give it:

emoji-picker {
  width: 100%;
  height: 100%;
}

Dark mode

By default, emoji-picker-element will automatically switch to dark mode based on prefers-color-scheme. Or you can add the class dark or light to force dark/light mode:

<emoji-picker class="dark"></emoji-picker>
<emoji-picker class="light"></emoji-picker>

CSS variables

Most colors and sizes can be styled with CSS variables. For example:

emoji-picker {
  --num-columns: 6;
  --emoji-size: 3rem;
  --background: gray;
}

Here is a full list of options:

Variable Default Default (dark) Description
--background #fff #222 Background of the entire <emoji-picker>
--border-color #e0e0e0 #444
--border-size 1px Width of border used in most of the picker
--button-active-background #e6e6e6 #555555 Background of an active button
--button-hover-background #d9d9d9 #484848 Background of a hovered button
--emoji-padding 0.5rem
--emoji-size 1.375rem
--indicator-color #385ac1 #5373ec Color of the nav indicator
--indicator-height 3px Height of the nav indicator
--input-border-color #999 #ccc
--input-border-radius 0.5rem
--input-border-size 1px
--input-font-color #111 #efefef
--input-font-size 1rem
--input-line-height 1.5
--input-padding 0.25rem
--input-placeholder-color #999 #ccc
--num-columns 8 How many columns to display in the emoji grid
--outline-color #999 #fff Focus outline color
--outline-size 2px Focus outline width
--skintone-border-radius 1rem border radius of the skintone dropdown

Focus outline

For accessibility reasons, emoji-picker-element displays a prominent focus ring. If you want to hide the focus ring for non-keyboard users (e.g. mouse and touch only), then use the focus-visible polyfill, e.g.:

import { applyFocusVisiblePolyfill } from 'focus-visible';

const picker = new Picker();
applyFocusVisiblePolyfill(picker.shadowRoot);

emoji-picker-element already ships with the proper CSS for both the :focus-visible standard and the polyfill.

JavaScript API

Picker

import { Picker } from 'emoji-picker-element';
const picker = new Picker();
document.body.appendChild(picker);

The new Picker(options) constructor supports several options:

constructor

+ new Picker(__namedParameters: object): Picker

Parameters:

Default value __namedParameters: object= {}

Name Type Default Description
dataSource string "https://cdn.jsdelivr.net/npm/emojibase-data@5/en/data.json" URL to fetch the emojibase data from
i18n I18n - i18n object (see below for details)
locale string "en" Locale string
skinToneEmoji string "🖐️" the emoji to use for the skin tone picker

Returns: Picker

These values can also be set at runtime, e.g.:

const picker = new Picker();
picker.dataSource = '/my-emoji.json';

i18n structure

Here is the default English i81n object ("en" locale):

{
  "categories": {
    "smileys-emotion": "Smileys and emoticons",
    "people-body": "People and body",
    "animals-nature": "Animals and nature",
    "food-drink": "Food and drink",
    "travel-places": "Travel and places",
    "activities": "Activities",
    "objects": "Objects",
    "symbols": "Symbols",
    "flags": "Flags"
  },
  "categoriesLabel": "Categories",
  "emojiUnsupported": "Your browser does not support color emoji.",
  "favoritesLabel": "Favorites",
  "loading": "Loading…",
  "networkError": "Could not load emoji. Try refreshing.",
  "regionLabel": "Emoji picker",
  "search": "Search",
  "searchDescription": "When search results are available, press up or down to select and enter to choose.",
  "searchResultsLabel": "Search results",
  "skinToneDescription": "When expanded, press up or down to select and enter to choose.",
  "skinToneLabel": "Choose a skin tone (currently {skinTone})",
  "skinTones": [
    "Default",
    "Light",
    "Medium-Light",
    "Medium",
    "Medium-Dark",
    "Dark"
  ],
  "skinTonesTitle": "Skin tones"
}

Note that some of these strings are only visible to users of screen readers. But you should still support them if you internationalize your app!

Database

You can work with the database API separately, which allows you to query emoji the same way that the picker does:

import { Database } from 'emoji-picker-element';

const database = new Database();
await database.getEmojiBySearchPrefix('elephant'); // [{unicode: "🐘", ...}]

Note that under the hood, IndexedDB data is partitioned based on the locale. So if you create two Databases with two different locales, it will store twice as much data.

Full API:

constructor

+ new Database(__namedParameters: object): Database

Create a new Database.

Note that multiple Databases pointing to the same locale will share the same underlying IndexedDB connection and database.

Parameters:

Default value __namedParameters: object= {}

Name Type Default Description
dataSource string "https://cdn.jsdelivr.net/npm/emojibase-data@5/en/data.json" URL to fetch the emojibase data from
locale string "en" Locale string

Returns: Database

close

close(): Promisevoid

Closes the underlying IndexedDB connection. The Database is not usable after that (or any other Databases with the same locale).

Returns: Promisevoid


delete

delete(): Promisevoid

Deletes the underlying IndexedDB database. The Database is not usable after that (or any other Databases with the same locale).

Returns: Promisevoid


getEmojiByGroup

getEmojiByGroup(group: number): PromiseEmoji]

Returns all emoji belonging to a group, ordered by order.

Non-numbers throw an error.

Parameters:

Name Type Description
group number the group number

Returns: Promise[Emoji]


getEmojiBySearchQuery

getEmojiBySearchQuery(query: string): Promise[Emoji]

Returns all emoji matching the given search query, ordered by order.

Empty/null strings throw an error.

Parameters:

Name Type Description
query string search query string

Returns: Promise[Emoji]


getEmojiByShortcode

getEmojiByShortcode(shortcode: string): Promise[Emoji | null

Return a single emoji matching the shortcode, or null if not found.

The colons around the shortcode should not be included when querying, e.g. use "slight_smile", not ":slight_smile:". Uppercase versus lowercase does not matter. Empty/null strings throw an error.

Parameters:

Name Type Description
shortcode string

Returns: PromiseEmoji | null


getEmojiByUnicode

getEmojiByUnicode(unicode: string): PromiseEmoji | null

Return a single emoji matching the unicode string, or null if not found.

Empty/null strings throw an error.

Parameters:

Name Type Description
unicode string unicode string

Returns: PromiseEmoji | null


getPreferredSkinTone

getPreferredSkinTone(): PromiseSkinTone

Get the user's preferred skin tone. Returns 0 if not found.

Returns: PromiseSkinTone


getTopFavoriteEmoji

getTopFavoriteEmoji(n: number): PromiseEmoji]

Get the top favorite emoji in descending order. If there are no favorite emoji yet, returns an empty array.

Parameters:

Name Type Description
n number maximum number of results to return

Returns: Promise[Emoji]


incrementFavoriteEmojiCount

incrementFavoriteEmojiCount(unicode: string): Promisevoid

Increment the favorite count for an emoji by one. The unicode string must be non-empty. It should correspond to the base (non-skin-tone) unicode string from the emoji object.

Parameters:

Name Type Description
unicode string unicode of the emoji to increment

Returns: Promisevoid


ready

ready(): Promisevoid

Resolves when the Database is ready, or throws an error if the Database could not initialize.

Note that you don't need to do this before calling other APIs they will all wait for this promise to resolve before doing anything.

Returns: Promisevoid


setPreferredSkinTone

setPreferredSkinTone(skinTone: [SkinTone): Promisevoid

Set the user's preferred skin tone. Non-numbers throw an error.

Parameters:

Name Type Description
skinTone SkinTone preferred skin tone

Returns: Promisevoid

Events

emoji-click

The emoji-click event is fired when an emoji is selected by the user. Example format:

{
  emoji: {
    annotation: 'thumbs up',
    group: 1,
    order: 280,
    shortcodes: ['thumbsup', '+1', 'yes'],
    tags: ['+1', 'hand', 'thumb', 'up'],
    tokens: ['+1', 'hand', 'thumb', 'thumbs', 'thumbsup', 'up', 'yes'],
    unicode: '👍️',
    version: 0.6,
    skins: [
      { tone: 1, unicode: '👍🏻', version: 1 },
      { tone: 2, unicode: '👍🏼', version: 1 },
      { tone: 3, unicode: '👍🏽', version: 1 },
      { tone: 4, unicode: '👍🏾', version: 1 },
      { tone: 5, unicode: '👍🏿', version: 1 }
    ]
  },
  skinTone: 4,
  unicode: '👍🏾'
}

And usage:

picker.addEventListener('emoji-click', event => {
  console.log(event.detail); // will log something like the above
});

Note that unicode will represent whatever the emoji should look like with the given skinTone. If the skinTone is 0, or if the emoji has no skin tones, then no skin tone is applied to unicode.

skin-tone-change

This event is fired whenever the user selects a new skin tone. Example format:

{
  skinTone: 5
}

And usage:

picker.addEventListener('skin-tone-change', event => {
  console.log(event.detail); // will log something like the above
})

Note that skin tones are an integer from 0 (default) to 1 (light) through 5 (dark).

Tree-shaking

If you want to import the Database without the Picker, or you want to code-split them separately, then do:

import Picker from 'emoji-picker-element/picker';
import Database from 'emoji-picker-element/database';

The reason for this is that Picker automatically registers itself as a custom element, following web component best practices. But this adds side effects, so bundlers like Webpack and Rollup do not tree-shake as well, unless the modules are imported from completely separate files.

Data and offline

Data source and JSON format

emoji-picker-element requires the full emojibase-data JSON file, not the "compact" one. If you would like to trim the JSON file down even further, then you can modify the JSON to only contain these keys:

[
  "annotation", "emoji", "emoticon", "group", 
  "order", "shortcodes", "skins", "tags", "version"
]

You can fetch the emoji JSON file from wherever you want. However, it's recommended that your server expose an ETag header if so, emoji-picker-element can avoid re-downloading the entire JSON file over and over again. Instead, it will fire off a HEAD request and just check the ETag.

If the server hosting the JSON file is not the same as the one containing the emoji picker, then the cross-origin server will also need to expose Access-Control-Allow-Origin: * and Access-Control-Allow-Headers: *. (Note that jsdelivr already does this, which is partly why it is the default.)

Unfortunately Safari does not currently support Access-Control-Allow-Headers, meaning that the ETag header will not be available cross-origin. In that case, emoji-picker-element will fall back to the less performant option. If you want to avoid this, host the JSON file on the same server as your web app.

Offline-first

emoji-picker-element uses a "stale while revalidate" strategy to update emoji data. In other words, it will use any existing data it finds in IndexedDB, and lazily update via the dataSource in case that data has changed. This means it will work offline-first the second time it runs.

If you would like to manage the database yourself (e.g. to ensure that it's correctly populated before displaying the Picker), then create a new Database instance and wait for its ready() promise to resolve:

const database = new Database()
try {
  await database.ready()
} catch (err) {
  // Deal with any errors (e.g. offline)
}

If emoji-picker-element fails to fetch the JSON data the first time it loads, then it will display an error message.

Design decisions

IndexedDB

Why IndexedDB? Well, the emojibase-data English JSON file is 854kB, and the "compact" version is still 543kB. That's a lot of data to keep in memory just for an emoji picker. And it's not as if that number is ever going down; the Unicode Consortium keeps adding more emoji every year.

Using IndexedDB has a few advantages:

  1. We don't need to keep half a megabyte of emoji data in memory at all times.
  2. The second time your visitors visit your website, we don't even need to download, parse, and index the emoji data, because it's already available on their hard drive.
  3. Heck, you can even preload the IndexedDB data in a web worker or service worker. That way, you only pay the UI thread cost of accessing IndexedDB, not of fetching the data, indexing the data, or inserting it into IndexedDB.

Native emoji

To avoid downloading a large sprite sheet that renders a particular emoji set (which may look out-of-place on different platforms, or may have IP issues), emoji-picker-element only renders native emoji. This means it is limited to the emoji actually installed on the user's device.

To avoid rendering ugly unsupported or half-supported emoji, emoji-picker-element will automatically detect emoji support and only render the supported characters. (So no empty boxes or awkward double emoji.) If no color emoji are supported by the browser/OS, then an error message is displayed.

JSON loading

Why only allow loading via a URL rather than directly passing in a JSON object? A few reasons:

First, it bloats the size of the JavaScript bundle to do so. emoji-picker-element is optimized for second load, where it doesn't even need to fetch, parse, or read the full JSON object into memory it can just rely on IndexedDB. Sure, this could be optional, but if an anti-pattern is allowed, then people might do it out of convenience.

Second, browsers deal with JSON more efficiently when it's loaded via fetch() rather than embedded in JavaScript. It's faster for the browser to parse JSON than JavaScript, plus using the await (await fetch()).json() pattern gives the browser more room for optimizations, since you're explicitly telling it to cache and parse the data (asynchronously) as JSON. (I'm not aware of any browsers that do this, e.g. off-main-thread JSON parsing, but it's certainly possible!)

Browser support

emoji-picker-element only supports the latest versions of Chrome, Firefox, and Safari, as well as equivalent browsers (Edge, Opera, etc.).

Benchmarks

Benchmark code can be found in the test/ directory. See Contributing for how to run the scripts.

Memory usage

This test navigates to four pages: 1) an empty page, 2) the same page containing emoji-picker-element with the standard configuration, 3) a page containing the emojibase English compact.json object, and 4) a page containing the full data.json object.

Scenario Bytes Relative to blank page
blank 763 kB (763097) 0 B (0)
picker 1.32 MB (1322356) 559 kB (559259)
compact 1.53 MB (1533547) 770 kB (770450)
full 1.87 MB (1868599) 1.11 MB (1105502)

As you can see, emoji-picker-element consumes less memory than merely loading the JSON files and keeping the reference. So any emoji picker that keeps these JSON objects in memory is already using more memory than emoji-picker-element, in addition to whatever it's doing with JS/CSS/DOM.

performance.measureMemory() in Chrome is used to calculate memory usage.

Bundle size

30.13kB at the time of writing (minified but not gzipped, for both the Picker and the Database combined).

Contributing

Install

yarn

Lint:

yarn lint

Fix most lint issues:

yarn lint:fix

Run the tests:

yarn test

Check code coverage:

yarn cover

Run a local dev server on localhost:3000:

yarn dev

Benchmark memory usage:

yarn benchmark:memory

Benchmark bundle size:

yarn benchmark:bundlesize