@k2b/ui
EmojiPicker
Emoji search in English and German, recently used emoji, and skin tones.
Search in English and German, recently used emoji, skin tones, and the whole grid with the keyboard: arrows move, Enter picks. The data loads when the picker first opens. EmojiPicker.Popover opens it next to a button.
TSX
Copyconst [recent, setRecent] = createSignal<readonly string[]>(stored.recentEmoji);const [tone, setTone] = createSignal<EmojiSkinTone>(stored.skinTone);const [anchor, setAnchor] = createSignal<HTMLElement>(); <Button onClick={(event) => setAnchor(event.currentTarget)}>Add reaction</Button><EmojiPicker.Popover anchor={anchor()} recent={recent()} skinTone={tone()} onSkinToneChange={setTone} onPick={(emoji) => { react(emoji); setRecent(rememberEmoji(recent(), emoji)); }} onClose={() => setAnchor(undefined)}/>EmojiPicker lets people choose an emoji: by searching in English or German,
from the emoji they used recently, or by browsing the groups. People who have
skin tones choose one once. The keyboard reaches everything.
EmojiPicker.Popover opens the picker next to a control, such as the emoji
button of a Chat.Composer or "Add reaction" in a
MessageRow.
Import
import { EmojiPicker, type EmojiSkinTone, rememberEmoji } from "@k2b/ui";Use EmojiPicker
The application keeps the anchor, the recent emoji, and the skin tone. The picker reports each choice and asks to close:
const [anchor, setAnchor] = createSignal<HTMLElement>();
const [recent, setRecent] = createSignal<readonly string[]>(preferences.recentEmoji);
const [tone, setTone] = createSignal<EmojiSkinTone>(preferences.skinTone);
<MessageRow {...row} onAddReaction={setAnchor} />
<EmojiPicker.Popover
anchor={anchor()}
recent={recent()}
skinTone={tone()}
onSkinToneChange={(next) => {
setTone(next);
savePreference("skinTone", next);
}}
onPick={(emoji) => {
react(message, emoji);
setRecent(rememberEmoji(recent(), emoji));
savePreference("recentEmoji", recent());
}}
onClose={() => setAnchor(undefined)}
/>- The picker is open while
anchoris set. It opens above the anchor where there is room and below it otherwise, aligned with the anchor's end, and stays inside the visible part of the viewport. When a phone's keyboard covers part of the page, the picker moves into the rest, and its grid gets shorter where the rest is lower than the picker. - It closes after a pick, on Escape in an empty search field, on a click
outside, and on a second press of its anchor, with a pointer or a key. Each
time
onCloseasks the application to clearanchor. - Focus moves into the search field when it opens, except on touch-only
devices, where the keyboard would cover the emoji. When it closes, focus
returns to where it was, unless
onPickmoved it on purpose, as the composer'sinsertdoes.
The inline EmojiPicker takes the same props without anchor, plus
onClose (Escape in an empty search field) and autofocus. Use it inside a
surface the application already owns, such as a
BottomSheet for reactions on a phone.
Recent emoji and skin tones
recentlists emoji most recent first and shows them as the first group, three rows at most.rememberEmoji(recent, emoji)returns the next list: the emoji first, once, and the oldest dropped. The application stores the list, for example in the person's settings, so it follows them across devices.skinToneis0for the default yellow or1(light) to5(dark). Emoji with skin tones show and are picked in that tone; recent emoji keep the tone they were picked in.onSkinToneChangeadds the tone choice to the header; without it the picker has none.
Search
The search matches English and German names and keywords and GitHub
shortcodes, so "Daumen", "thumbs up", and "+1" all find 👍. It ignores case and
accents and accepts "ae" for "ä". Names show in the render language: German
for a de locale, English otherwise.
Accessibility
The keyboard reaches everything, and the focus stays in the search field:
- the arrow keys move through the grid in rows of eight, from one group into the next; Left and Right move the caret of a search until Up or Down enters the grid;
- Enter picks the highlighted emoji, which is the best match while searching;
- Escape clears the search, and closes the picker when the search is empty;
- Tab reaches the skin-tone button, whose arrow keys choose a tone, and the group bar, whose arrow keys move between groups.
Screen readers hear the field as a combobox and each emoji by its name, in
groups named after the emoji group. Group buttons and skin tones have names
and tooltips; the current group is marked with aria-current.
Layout
Every part has a fixed height: the search field, the group bar, the grid, and the name line under it. Searching, hovering, choosing a tone, and finding nothing change only paint. On a coarse pointer, cells and controls are 2.75rem, and the picker stays narrower than a 390px phone.
Runtime
Search, keyboard use, and the popover need hydrated Solid client code; on the server the picker renders its frame with the loading state.
The emoji names come from Emojibase (MIT), up to
Emoji 15.1, the newest version that current Apple, Android, Windows 11, and
Noto fonts all draw. The data is about 80 kB compressed and loads with the
first picker or the first colon typed in a composer with emoji, as a chunk
of its own: a page that never uses either does not grow by it. Until it has loaded, the grid shows a
spinner in its place; if it cannot load, the picker says so and offers
"Retry".
Shortcodes in the composer
Chat.Composer completes :shortcodes from the same data whenever it has an
emoji prop. Unlike the search, it matches only the start of a shortcode,
name, or keyword, so emoticons such as :DD stay text. See Formatting,
emoji, and microphone.
Example
On a phone, the picker fits a BottomSheet:
import { BottomSheet, bottomSheetOptions, dialogCore, EmojiPicker, rememberEmoji } from "@k2b/ui";
const emoji = await dialogCore.open<string>((close, context) => (
<BottomSheet onDismiss={context.requestDismiss}>
<BottomSheet.Header title="React" close={context.requestDismiss} />
<BottomSheet.Body>
<EmojiPicker recent={recent()} skinTone={tone()} onSkinToneChange={setTone} onPick={close} />
</BottomSheet.Body>
</BottomSheet>
), bottomSheetOptions);
if (emoji) {
react(message, emoji);
setRecent(rememberEmoji(recent(), emoji));
}