Expressions Picker
A Discord-inspired picker for Unicode emoji and caller-supplied stickers and GIFs.
Choose an emoji, sticker, or GIF from one searchable dialog. The GIFs, Stickers, and Emoji tabs sit at the top, and the Emoji tab opens by default. It follows the layout of a chat client’s emoji picker: a search field with a skin tone button, a rail of categories beside a grid of collapsible sections, and a bar at the foot that names the expression under the pointer or focus. Stickers and GIFs use the same grid with larger cells.
The picker returns the chosen item and value to your application. It ships no emoji dataset, artwork, or media service, and it does not store anything: you pass the items and categories, and you keep the list of recently used IDs.
newt-expressions-picker"use client"
import * as React from "react"
import {
ExpressionsPicker,
type ExpressionCategory,
type ExpressionItem,
} from "@/registry/bases/newt/ui/expressions-picker"
/* Drawn rather than fetched, so the demo holds up offline. */
function drawn(body: string) {
return (
"data:image/svg+xml;utf8," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">${body}</svg>`
)
)
}
const LAB = drawn(
`<rect width="32" height="32" rx="16" fill="#5865f2"/><path d="M12 8h8M14 8v6l-5 9a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-9V8" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>`
)
const SPARK = drawn(
`<circle cx="16" cy="16" r="15" fill="#5865f2"/><path d="M16 7l2.6 5.6L24 14l-4 3.9.9 5.6L16 21l-4.9 2.5.9-5.6-4-3.9 5.4-1.4z" fill="#fff"/>`
)
const CHECK = drawn(
`<circle cx="16" cy="16" r="15" fill="#23a55a"/><path d="m9 16 5 5 9-10" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`
)
const HELLO = drawn(
`<rect x="2" y="6" width="28" height="20" rx="6" fill="#f0b232"/><text x="16" y="20" font-family="sans-serif" font-size="8" font-weight="700" text-anchor="middle" fill="#1e1f22">HELLO</text>`
)
const THANKS = drawn(
`<rect x="2" y="6" width="28" height="20" rx="6" fill="#23a55a"/><text x="16" y="20" font-family="sans-serif" font-size="7" font-weight="700" text-anchor="middle" fill="#fff">THANKS</text>`
)
const CONFETTI = drawn(
`<rect width="32" height="32" rx="4" fill="#2b2d31"/><circle cx="9" cy="10" r="2" fill="#f0b232"/><circle cx="22" cy="8" r="2" fill="#5865f2"/><circle cx="15" cy="18" r="2" fill="#f23f43"/><circle cx="24" cy="22" r="2" fill="#23a55a"/>`
)
const categories: ExpressionCategory[] = [
{ id: "lab", kind: "emoji", label: "Newt Lab", iconSrc: LAB },
{ id: "smileys", kind: "emoji", label: "Smileys & Emotion", glyph: "😀" },
{ id: "people", kind: "emoji", label: "People & Body", glyph: "👋" },
{ id: "nature", kind: "emoji", label: "Animals & Nature", glyph: "🌿" },
{ id: "food", kind: "emoji", label: "Food & Drink", glyph: "🍔" },
{ id: "symbols", kind: "emoji", label: "Symbols", glyph: "❤️" },
{ id: "greetings", kind: "sticker", label: "Greetings", glyph: "🎁" },
{ id: "trending", kind: "gif", label: "Trending", glyph: "🔥" },
]
const tones = (base: string) =>
["🏻", "🏼", "🏽", "🏾", "🏿"].map((tone) => base + tone)
const items: ExpressionItem[] = [
{
id: "spark",
kind: "emoji",
category: "lab",
label: "spark",
value: ":spark:",
src: SPARK,
},
{
id: "check",
kind: "emoji",
category: "lab",
label: "check",
value: ":check:",
src: CHECK,
},
{
id: "grinning",
kind: "emoji",
category: "smileys",
label: "grinning face",
value: "😀",
keywords: ["happy", "smile"],
},
{
id: "joy",
kind: "emoji",
category: "smileys",
label: "face with tears of joy",
value: "😂",
keywords: ["laugh"],
},
{
id: "wink",
kind: "emoji",
category: "smileys",
label: "winking face",
value: "😉",
},
{
id: "thinking",
kind: "emoji",
category: "smileys",
label: "thinking face",
value: "🤔",
keywords: ["hmm"],
},
{
id: "cool",
kind: "emoji",
category: "smileys",
label: "smiling face with sunglasses",
value: "😎",
},
{
id: "party-face",
kind: "emoji",
category: "smileys",
label: "partying face",
value: "🥳",
keywords: ["celebrate"],
},
{
id: "wave",
kind: "emoji",
category: "people",
label: "waving hand",
value: "👋",
keywords: ["hello"],
skins: tones("👋"),
},
{
id: "thumbs-up",
kind: "emoji",
category: "people",
label: "thumbs up",
value: "👍",
keywords: ["yes"],
skins: tones("👍"),
},
{
id: "clap",
kind: "emoji",
category: "people",
label: "clapping hands",
value: "👏",
skins: tones("👏"),
},
{
id: "raised",
kind: "emoji",
category: "people",
label: "raising hands",
value: "🙌",
keywords: ["celebrate"],
skins: tones("🙌"),
},
{
id: "dog",
kind: "emoji",
category: "nature",
label: "dog face",
value: "🐶",
},
{ id: "fox", kind: "emoji", category: "nature", label: "fox", value: "🦊" },
{
id: "lizard",
kind: "emoji",
category: "nature",
label: "lizard",
value: "🦎",
keywords: ["newt"],
},
{
id: "moon",
kind: "emoji",
category: "nature",
label: "crescent moon",
value: "🌙",
disabled: true,
},
{
id: "coffee",
kind: "emoji",
category: "food",
label: "hot beverage",
value: "☕",
keywords: ["coffee"],
},
{ id: "pizza", kind: "emoji", category: "food", label: "pizza", value: "🍕" },
{
id: "cake",
kind: "emoji",
category: "food",
label: "birthday cake",
value: "🎂",
keywords: ["celebrate"],
},
{
id: "heart",
kind: "emoji",
category: "symbols",
label: "red heart",
value: "❤️",
keywords: ["love"],
},
{
id: "sparkles",
kind: "emoji",
category: "symbols",
label: "sparkles",
value: "✨",
},
{
id: "hundred",
kind: "emoji",
category: "symbols",
label: "hundred points",
value: "💯",
},
{
id: "hello-sticker",
kind: "sticker",
category: "greetings",
label: "Hello sticker",
value: "hello-sticker",
src: HELLO,
},
{
id: "thanks-sticker",
kind: "sticker",
category: "greetings",
label: "Thanks sticker",
value: "thanks-sticker",
src: THANKS,
},
{
id: "confetti-gif",
kind: "gif",
category: "trending",
label: "Confetti",
value: "confetti-gif",
src: CONFETTI,
keywords: ["celebrate"],
},
{
id: "applause-gif",
kind: "gif",
category: "trending",
label: "Applause",
value: "applause-gif",
keywords: ["clap"],
},
]
export default function ExpressionsPickerDemo() {
const [recent, setRecent] = React.useState<string[]>(["wave", "heart"])
const [selected, setSelected] = React.useState<string | null>(null)
return (
<div className="grid gap-[var(--newt-space-3)]">
<ExpressionsPicker
items={items}
categories={categories}
recent={recent}
current={selected}
onSelect={(item, value) => {
setSelected(value)
setRecent((list) =>
[item.id, ...list.filter((id) => id !== item.id)].slice(0, 18)
)
}}
/>
<p role="status" className="m-0 text-newt-text-secondary">
{selected
? `Selected: ${selected}`
: "Choose an emoji, sticker, or GIF."}
</p>
</div>
)
}<script setup lang="ts">
import { shallowRef } from "vue"
import {
ExpressionsPicker,
type ExpressionCategory,
type ExpressionItem,
} from "~~/registry/bases/newt/ui/expressions-picker"
/* Drawn rather than fetched, so the demo holds up offline. */
function drawn(body: string) {
return (
"data:image/svg+xml;utf8," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">${body}</svg>`
)
)
}
const LAB = drawn(
`<rect width="32" height="32" rx="16" fill="#5865f2"/><path d="M12 8h8M14 8v6l-5 9a2 2 0 0 0 2 3h10a2 2 0 0 0 2-3l-5-9V8" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>`
)
const SPARK = drawn(
`<circle cx="16" cy="16" r="15" fill="#5865f2"/><path d="M16 7l2.6 5.6L24 14l-4 3.9.9 5.6L16 21l-4.9 2.5.9-5.6-4-3.9 5.4-1.4z" fill="#fff"/>`
)
const CHECK = drawn(
`<circle cx="16" cy="16" r="15" fill="#23a55a"/><path d="m9 16 5 5 9-10" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`
)
const HELLO = drawn(
`<rect x="2" y="6" width="28" height="20" rx="6" fill="#f0b232"/><text x="16" y="20" font-family="sans-serif" font-size="8" font-weight="700" text-anchor="middle" fill="#1e1f22">HELLO</text>`
)
const THANKS = drawn(
`<rect x="2" y="6" width="28" height="20" rx="6" fill="#23a55a"/><text x="16" y="20" font-family="sans-serif" font-size="7" font-weight="700" text-anchor="middle" fill="#fff">THANKS</text>`
)
const CONFETTI = drawn(
`<rect width="32" height="32" rx="4" fill="#2b2d31"/><circle cx="9" cy="10" r="2" fill="#f0b232"/><circle cx="22" cy="8" r="2" fill="#5865f2"/><circle cx="15" cy="18" r="2" fill="#f23f43"/><circle cx="24" cy="22" r="2" fill="#23a55a"/>`
)
const categories: ExpressionCategory[] = [
{ id: "lab", kind: "emoji", label: "Newt Lab", iconSrc: LAB },
{ id: "smileys", kind: "emoji", label: "Smileys & Emotion", glyph: "😀" },
{ id: "people", kind: "emoji", label: "People & Body", glyph: "👋" },
{ id: "nature", kind: "emoji", label: "Animals & Nature", glyph: "🌿" },
{ id: "food", kind: "emoji", label: "Food & Drink", glyph: "🍔" },
{ id: "symbols", kind: "emoji", label: "Symbols", glyph: "❤️" },
{ id: "greetings", kind: "sticker", label: "Greetings", glyph: "🎁" },
{ id: "trending", kind: "gif", label: "Trending", glyph: "🔥" },
]
const tones = (base: string) =>
["🏻", "🏼", "🏽", "🏾", "🏿"].map((tone) => base + tone)
const items: ExpressionItem[] = [
{
id: "spark",
kind: "emoji",
category: "lab",
label: "spark",
value: ":spark:",
src: SPARK,
},
{
id: "check",
kind: "emoji",
category: "lab",
label: "check",
value: ":check:",
src: CHECK,
},
{
id: "grinning",
kind: "emoji",
category: "smileys",
label: "grinning face",
value: "😀",
keywords: ["happy", "smile"],
},
{
id: "joy",
kind: "emoji",
category: "smileys",
label: "face with tears of joy",
value: "😂",
keywords: ["laugh"],
},
{
id: "wink",
kind: "emoji",
category: "smileys",
label: "winking face",
value: "😉",
},
{
id: "thinking",
kind: "emoji",
category: "smileys",
label: "thinking face",
value: "🤔",
keywords: ["hmm"],
},
{
id: "cool",
kind: "emoji",
category: "smileys",
label: "smiling face with sunglasses",
value: "😎",
},
{
id: "party-face",
kind: "emoji",
category: "smileys",
label: "partying face",
value: "🥳",
keywords: ["celebrate"],
},
{
id: "wave",
kind: "emoji",
category: "people",
label: "waving hand",
value: "👋",
keywords: ["hello"],
skins: tones("👋"),
},
{
id: "thumbs-up",
kind: "emoji",
category: "people",
label: "thumbs up",
value: "👍",
keywords: ["yes"],
skins: tones("👍"),
},
{
id: "clap",
kind: "emoji",
category: "people",
label: "clapping hands",
value: "👏",
skins: tones("👏"),
},
{
id: "raised",
kind: "emoji",
category: "people",
label: "raising hands",
value: "🙌",
keywords: ["celebrate"],
skins: tones("🙌"),
},
{
id: "dog",
kind: "emoji",
category: "nature",
label: "dog face",
value: "🐶",
},
{ id: "fox", kind: "emoji", category: "nature", label: "fox", value: "🦊" },
{
id: "lizard",
kind: "emoji",
category: "nature",
label: "lizard",
value: "🦎",
keywords: ["newt"],
},
{
id: "moon",
kind: "emoji",
category: "nature",
label: "crescent moon",
value: "🌙",
disabled: true,
},
{
id: "coffee",
kind: "emoji",
category: "food",
label: "hot beverage",
value: "☕",
keywords: ["coffee"],
},
{ id: "pizza", kind: "emoji", category: "food", label: "pizza", value: "🍕" },
{
id: "cake",
kind: "emoji",
category: "food",
label: "birthday cake",
value: "🎂",
keywords: ["celebrate"],
},
{
id: "heart",
kind: "emoji",
category: "symbols",
label: "red heart",
value: "❤️",
keywords: ["love"],
},
{
id: "sparkles",
kind: "emoji",
category: "symbols",
label: "sparkles",
value: "✨",
},
{
id: "hundred",
kind: "emoji",
category: "symbols",
label: "hundred points",
value: "💯",
},
{
id: "hello-sticker",
kind: "sticker",
category: "greetings",
label: "Hello sticker",
value: "hello-sticker",
src: HELLO,
},
{
id: "thanks-sticker",
kind: "sticker",
category: "greetings",
label: "Thanks sticker",
value: "thanks-sticker",
src: THANKS,
},
{
id: "confetti-gif",
kind: "gif",
category: "trending",
label: "Confetti",
value: "confetti-gif",
src: CONFETTI,
keywords: ["celebrate"],
},
{
id: "applause-gif",
kind: "gif",
category: "trending",
label: "Applause",
value: "applause-gif",
keywords: ["clap"],
},
]
const recent = shallowRef<string[]>(["wave", "heart"])
const selected = shallowRef<string | null>(null)
function onSelect(item: ExpressionItem, value: string) {
selected.value = value
recent.value = [
item.id,
...recent.value.filter((id) => id !== item.id),
].slice(0, 18)
}
</script>
<template>
<div class="grid gap-[var(--newt-space-3)]">
<ExpressionsPicker
:items="items"
:categories="categories"
:recent="recent"
:current="selected"
@select="onSelect"
/>
<p role="status" class="m-0 text-newt-text-secondary">
{{
selected ? `Selected: ${selected}` : "Choose an emoji, sticker, or GIF."
}}
</p>
</div>
</template>The framework switch above selects the React or Vue demo. Search for “celebrate”, pick a skin tone, collapse a section, and select an item: it moves to the top of “Recently used” and stays marked as pressed. The crescent moon demonstrates an unavailable entry. “Newt Lab” shows how a server’s own emoji appear with an image.
Installation
pnpm dlx newtui@latest add expressions-pickernpx newtui@latest add expressions-pickeryarn dlx newtui@latest add expressions-pickerbunx newtui@latest add expressions-pickerInstall the dependencies
pnpm dlx --yes class-variance-authoritynpx --yes class-variance-authorityyarn dlx --yes class-variance-authoritybunx --yes class-variance-authorityCopy the source into your project
"use client"
import { cva } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"
export type ExpressionKind = "emoji" | "sticker" | "gif"
/** A section of one tab: a Unicode emoji group, a server's own emoji, or a sticker pack. */
export interface ExpressionCategory {
/** Unique across all categories. */
id: string
kind: ExpressionKind
label: string
/** A Unicode character for the category rail. */
glyph?: string
/** A static image for the category rail, such as a server icon. Wins over `glyph`. */
iconSrc?: string
}
export interface ExpressionItem {
/** Unique across all items. */
id: string
kind: ExpressionKind
/** The `id` of the category this item belongs to. */
category: string
/** Accessible name, searched and shown in the preview bar. */
label: string
/** What a selection returns: Unicode text, or an application token for media. */
value: string
keywords?: readonly string[]
/** The emoji in the five skin tones, light to dark. Omit when it has none. */
skins?: readonly string[]
/** A static image: a server emoji, a sticker, or a GIF poster. */
src?: string
disabled?: boolean
}
export type SkinTone = 0 | 1 | 2 | 3 | 4 | 5
export interface ExpressionsPickerProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
"onSelect"
> {
items?: readonly ExpressionItem[]
categories?: readonly ExpressionCategory[]
/** Item IDs used last, most recent first. The application stores and updates them. */
recent?: readonly string[]
/** The value chosen now, marked as pressed in the grid. */
current?: string | null
label?: string
disabled?: boolean
/** Receives the item and its value in the chosen skin tone. */
onSelect?: (item: ExpressionItem, value: string) => void
}
const kinds: { kind: ExpressionKind; label: string }[] = [
{ kind: "gif", label: "GIFs" },
{ kind: "sticker", label: "Stickers" },
{ kind: "emoji", label: "Emoji" },
]
const skinTones: { tone: SkinTone; label: string; sample: string }[] = [
{ tone: 0, label: "Default skin tone", sample: "👋" },
{ tone: 1, label: "Light skin tone", sample: "👋🏻" },
{ tone: 2, label: "Medium-light skin tone", sample: "👋🏼" },
{ tone: 3, label: "Medium skin tone", sample: "👋🏽" },
{ tone: 4, label: "Medium-dark skin tone", sample: "👋🏾" },
{ tone: 5, label: "Dark skin tone", sample: "👋🏿" },
]
/** The value of an item in a skin tone, or its own value when it has none. */
export function expressionValue(item: ExpressionItem, tone: SkinTone): string {
return (tone === 0 ? undefined : item.skins?.[tone - 1]) ?? item.value
}
/** `:grinning_face:` for an emoji, the label for media. */
export function expressionShortcode(item: ExpressionItem): string {
if (item.kind !== "emoji") return item.label
const name = item.label
.toLowerCase()
.replaceAll(/[^\p{L}\p{N}]+/gu, "_")
.replaceAll(/^_+|_+$/gu, "")
return `:${name}:`
}
/** Every word of the query must appear in the label or the keywords. */
export function matchesExpression(item: ExpressionItem, query: string) {
const text = [item.label, ...(item.keywords ?? [])].join(" ").toLowerCase()
return query
.toLowerCase()
.split(/[\s_:]+/u)
.filter(Boolean)
.every((word) => text.includes(word))
}
interface Section {
key: string
label: string
glyph?: string
iconSrc?: string
items: ExpressionItem[]
collapsible: boolean
}
function buildSections(
items: readonly ExpressionItem[],
categories: readonly ExpressionCategory[],
recent: readonly string[],
kind: ExpressionKind,
query: string
): Section[] {
const ofKind = items.filter((item) => item.kind === kind)
if (query.trim()) {
const found = ofKind.filter((item) => matchesExpression(item, query))
return found.length
? [
{
key: "results",
label: "Search results",
items: found,
collapsible: false,
},
]
: []
}
const byId = new Map(ofKind.map((item) => [item.id, item]))
const sections: Section[] = []
const used = recent.flatMap((id) => {
const item = byId.get(id)
return item ? [item] : []
})
if (used.length)
sections.push({
key: "recent",
label: "Recently used",
glyph: "🕘",
items: used,
collapsible: true,
})
for (const category of categories) {
if (category.kind !== kind) continue
const list = ofKind.filter((item) => item.category === category.id)
if (list.length)
sections.push({
key: `category-${category.id}`,
label: category.label,
glyph: category.glyph,
iconSrc: category.iconSrc,
items: list,
collapsible: true,
})
}
return sections
}
/** The cell an arrow key leads to, by where the cells sit on screen. */
function neighbour(
cells: HTMLElement[],
index: number,
key: string,
rtl: boolean
): HTMLElement | undefined {
if (key === (rtl ? "ArrowLeft" : "ArrowRight")) return cells[index + 1]
if (key === (rtl ? "ArrowRight" : "ArrowLeft")) return cells[index - 1]
if (key === "Home") return cells[0]
if (key === "End") return cells.at(-1)
if (key !== "ArrowDown" && key !== "ArrowUp") return undefined
const from = cells[index]?.getBoundingClientRect()
if (!from) return undefined
const step = key === "ArrowDown" ? 1 : -1
let rowTop: number | null = null
let best: { cell: HTMLElement; distance: number } | undefined
for (let at = index + step; at >= 0 && at < cells.length; at += step) {
const cell = cells[at]
if (!cell) break
const rect = cell.getBoundingClientRect()
if (Math.abs(rect.top - from.top) < 4) continue
rowTop ??= rect.top
if (Math.abs(rect.top - rowTop) >= 4) break
const distance = Math.abs(rect.left - from.left)
if (!best || distance < best.distance) best = { cell, distance }
}
return best?.cell
}
const focusRing =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link"
/** Tabs and the small controls of the panel. */
export const expressionsPickerVariants = cva(
`inline-flex min-h-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] min-w-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] cursor-pointer items-center justify-center gap-[var(--newt-space-1)] rounded-md border-0 px-[var(--newt-space-3)] font-sans text-[length:var(--newt-font-size-sm)] font-semibold leading-[var(--newt-line-height-normal)] enabled:hover:bg-newt-bg-hover enabled:hover:text-newt-text-primary enabled:active:bg-newt-bg-active disabled:cursor-not-allowed disabled:text-newt-text-muted ${focusRing}`,
{
variants: {
selected: {
true: "bg-newt-bg-active text-newt-text-primary",
false: "bg-transparent text-newt-text-secondary",
},
},
defaultVariants: { selected: false },
}
)
const triggerClass = `min-h-[var(--newt-expressions-target-size)] cursor-pointer rounded-sm border border-newt-border bg-newt-bg-elevated px-[var(--newt-space-3)] py-[var(--newt-space-2)] font-sans text-[length:var(--newt-font-size-sm)] leading-[var(--newt-line-height-normal)] text-newt-text-primary enabled:hover:bg-newt-bg-hover enabled:active:bg-newt-bg-active disabled:cursor-not-allowed disabled:text-newt-text-muted ${focusRing}`
function trapFocus(event: React.KeyboardEvent<HTMLDialogElement>) {
if (event.key !== "Tab") return
const controls = [
...event.currentTarget.querySelectorAll<HTMLElement>(
"button:not(:disabled), input:not(:disabled)"
),
].filter(
(element) => element.tabIndex >= 0 && element.getClientRects().length > 0
)
const first = controls[0]
const last = controls[controls.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last?.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first?.focus()
}
}
function ExpressionArt({
item,
value,
large,
}: {
item: ExpressionItem
value: string
large?: boolean
}) {
if (item.src)
return (
<img
src={item.src}
alt=""
loading="lazy"
className={cn(
"object-contain",
large
? "size-[calc(var(--newt-expressions-preview-size)-var(--newt-space-3))]"
: "size-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))]"
)}
/>
)
if (item.kind === "emoji")
return (
<span
aria-hidden="true"
className="text-[length:var(--newt-font-size-expression)] leading-none"
>
{value}
</span>
)
return (
<span
aria-hidden="true"
className="px-[var(--newt-space-1)] text-center text-[length:var(--newt-font-size-xs)] text-newt-text-secondary"
>
{item.label}
</span>
)
}
function SectionIcon({ section }: { section: Section }) {
if (section.iconSrc)
return (
<img
src={section.iconSrc}
alt=""
className="size-[calc(var(--newt-space-3)+var(--newt-space-1))] shrink-0 rounded-full"
/>
)
if (!section.glyph) return null
return (
<span aria-hidden="true" className="shrink-0 leading-none">
{section.glyph}
</span>
)
}
const ExpressionsPicker = React.forwardRef<
HTMLDivElement,
ExpressionsPickerProps
>(
(
{
items = [],
categories = [],
recent = [],
current = null,
label = "Choose an expression",
disabled = false,
onSelect,
className,
...props
},
ref
) => {
const id = React.useId()
const dialog = React.useRef<HTMLDialogElement>(null)
const trigger = React.useRef<HTMLButtonElement>(null)
const search = React.useRef<HTMLInputElement>(null)
const scroller = React.useRef<HTMLDivElement>(null)
const toneButton = React.useRef<HTMLButtonElement>(null)
const toneRoot = React.useRef<HTMLDivElement>(null)
const [open, setOpen] = React.useState(false)
const [kind, setKind] = React.useState<ExpressionKind>("emoji")
const [query, setQuery] = React.useState("")
const [tone, setTone] = React.useState<SkinTone>(0)
const [showTones, setShowTones] = React.useState(false)
const [collapsed, setCollapsed] = React.useState<string[]>([])
const [scrolledTo, setScrolledTo] = React.useState<string | null>(null)
const [pendingScroll, setPendingScroll] = React.useState<string | null>(
null
)
const [activeKey, setActiveKey] = React.useState<string | null>(null)
const [hovered, setHovered] = React.useState<{
item: ExpressionItem
where: string
} | null>(null)
const searching = query.trim().length > 0
const sections = buildSections(items, categories, recent, kind, query)
const isOpen = (key: string) => !collapsed.includes(key)
const activeSection = scrolledTo ?? sections[0]?.key
const cellKeys = sections
.filter((section) => isOpen(section.key))
.flatMap((section) =>
section.items
.filter((item) => !item.disabled)
.map((item) => `${section.key}|${item.id}`)
)
const tabKey =
activeKey && cellKeys.includes(activeKey) ? activeKey : cellKeys[0]
const resultCount = sections.reduce(
(count, section) => count + section.items.length,
0
)
const currentItem = React.useMemo(() => {
if (!current) return null
const item = items.find(
(entry) =>
entry.kind === kind &&
(entry.value === current || entry.skins?.includes(current))
)
if (!item) return null
return {
item,
where:
categories.find((category) => category.id === item.category)?.label ??
"",
}
}, [categories, current, items, kind])
const preview = hovered ?? currentItem
function toggleSection(key: string) {
setCollapsed((list) =>
list.includes(key)
? list.filter((entry) => entry !== key)
: [...list, key]
)
}
function goTo(key: string) {
setCollapsed((list) => list.filter((entry) => entry !== key))
setScrolledTo(key)
setPendingScroll(key)
}
React.useLayoutEffect(() => {
if (!pendingScroll || !scroller.current) return
const section = scroller.current.querySelector<HTMLElement>(
`[data-section="${pendingScroll}"]`
)
if (section) scroller.current.scrollTop = section.offsetTop
setPendingScroll(null)
}, [pendingScroll])
function onScroll() {
const element = scroller.current
if (!element) return
let found: string | null = null
for (const section of element.querySelectorAll<HTMLElement>(
"[data-section]"
)) {
if (section.offsetTop > element.scrollTop + 8) break
found = section.dataset.section ?? null
}
if (found) setScrolledTo(found)
}
function show() {
if (disabled) return
setQuery("")
setHovered(null)
setShowTones(false)
dialog.current?.showModal()
setOpen(true)
search.current?.focus()
}
function close() {
dialog.current?.close()
}
React.useEffect(() => {
if (disabled) dialog.current?.close()
}, [disabled])
function changeKind(next: ExpressionKind) {
setKind(next)
setScrolledTo(null)
setHovered(null)
setShowTones(false)
if (scroller.current) scroller.current.scrollTop = 0
}
function tabKeyDown(
event: React.KeyboardEvent<HTMLButtonElement>,
index: number
) {
const rtl = getComputedStyle(event.currentTarget).direction === "rtl"
const forward = rtl ? "ArrowLeft" : "ArrowRight"
const back = rtl ? "ArrowRight" : "ArrowLeft"
const next =
event.key === "Home"
? 0
: event.key === "End"
? kinds.length - 1
: event.key === forward
? (index + 1) % kinds.length
: event.key === back
? (index + kinds.length - 1) % kinds.length
: -1
const nextTab = kinds[next]
if (!nextTab) return
event.preventDefault()
changeKind(nextTab.kind)
dialog.current
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
.item(next)
?.focus()
}
function gridKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const target = event.target
if (!(target instanceof HTMLElement) || !target.matches("[data-cell]"))
return
const cells = [
...(scroller.current?.querySelectorAll<HTMLElement>(
"[data-cell]:not(:disabled)"
) ?? []),
]
const rtl = getComputedStyle(target).direction === "rtl"
const next = neighbour(cells, cells.indexOf(target), event.key, rtl)
if (!next) return
event.preventDefault()
next.focus()
next.scrollIntoView({ block: "nearest" })
}
function focusFirstCell(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "ArrowDown") return
event.preventDefault()
scroller.current
?.querySelector<HTMLElement>('[data-cell][tabindex="0"]')
?.focus()
}
function onDialogKeyDown(event: React.KeyboardEvent<HTMLDialogElement>) {
if (event.key === "Escape" && showTones) {
event.preventDefault()
setShowTones(false)
toneButton.current?.focus()
return
}
trapFocus(event)
}
function pickTone(next: SkinTone) {
setTone(next)
setShowTones(false)
toneButton.current?.focus()
}
React.useEffect(() => {
if (!showTones) return
toneRoot.current
?.querySelector<HTMLElement>('[aria-pressed="true"]')
?.focus()
function outside(event: PointerEvent) {
const target = event.target
if (!(target instanceof Node) || !toneRoot.current?.contains(target))
setShowTones(false)
}
document.addEventListener("pointerdown", outside)
return () => document.removeEventListener("pointerdown", outside)
}, [showTones])
function select(item: ExpressionItem) {
if (disabled || item.disabled) return
close()
onSelect?.(item, expressionValue(item, tone))
}
const toneSample = skinTones[tone]
return (
<div ref={ref} className={cn("inline-block", className)} {...props}>
<button
ref={trigger}
type="button"
disabled={disabled}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={id}
onClick={show}
className={triggerClass}
>
{label}
</button>
<dialog
ref={dialog}
id={id}
aria-label={label}
onKeyDown={onDialogKeyDown}
onClick={(event) => {
// The panel fills the dialog, so only the backdrop targets it.
if (event.target === event.currentTarget) close()
}}
onClose={() => {
setOpen(false)
setShowTones(false)
trigger.current?.focus()
}}
className="m-auto box-border h-[min(var(--newt-expressions-height),calc(100dvh-2*var(--newt-space-3)))] w-[min(var(--newt-expressions-width),calc(100%-2*var(--newt-space-3)))] max-w-none overflow-hidden rounded-lg border border-newt-border bg-newt-bg-surface p-0 font-sans text-[length:var(--newt-font-size-sm)] leading-[var(--newt-line-height-normal)] text-newt-text-primary shadow-elevation-high backdrop:bg-[color-mix(in_srgb,var(--newt-bg-base)_80%,transparent)] open:flex open:flex-col"
>
<div className="flex shrink-0 items-center gap-[var(--newt-space-2)] px-[var(--newt-space-3)] pt-[var(--newt-space-3)]">
<div
role="tablist"
aria-label="Expression type"
className="flex items-center gap-[var(--newt-space-2)]"
>
{kinds.map((tab, index) => (
<button
key={tab.kind}
id={`${id}-${tab.kind}`}
role="tab"
type="button"
aria-selected={kind === tab.kind}
aria-controls={`${id}-panel`}
tabIndex={kind === tab.kind ? 0 : -1}
onClick={() => changeKind(tab.kind)}
onKeyDown={(event) => tabKeyDown(event, index)}
className={expressionsPickerVariants({
selected: kind === tab.kind,
})}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="flex shrink-0 items-center gap-[var(--newt-space-2)] border-b border-newt-border p-[var(--newt-space-3)]">
<div className="relative min-w-0 flex-1">
<input
ref={search}
type="text"
autoComplete="off"
spellCheck={false}
value={query}
aria-label="Search expressions"
placeholder="Search by name or keyword"
onChange={(event) => {
setQuery(event.target.value)
setHovered(null)
}}
onKeyDown={focusFirstCell}
className={`box-border h-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] w-full rounded-md border-0 bg-newt-bg-input pe-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] ps-[calc(var(--newt-space-3)*3)] font-sans text-[length:var(--newt-font-size-sm)] text-newt-text-primary placeholder:text-newt-text-muted ${focusRing}`}
/>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="pointer-events-none absolute start-[var(--newt-space-3)] top-1/2 size-[calc(var(--newt-space-3)+var(--newt-space-1))] -translate-y-1/2 fill-none stroke-newt-text-secondary stroke-2"
>
<circle cx="7" cy="7" r="5" />
<path d="m11 11 4 4" strokeLinecap="round" />
</svg>
{query ? (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setQuery("")
search.current?.focus()
}}
className={cn(
expressionsPickerVariants(),
"absolute end-[var(--newt-space-1)] top-1/2 min-h-0 min-w-0 -translate-y-1/2 size-[calc(var(--newt-expressions-target-size)-var(--newt-space-3)-var(--newt-space-2))] p-0"
)}
>
<span aria-hidden="true">×</span>
</button>
) : null}
</div>
{kind === "emoji" ? (
<div ref={toneRoot} className="relative shrink-0">
<button
ref={toneButton}
type="button"
aria-label={`Skin tone: ${toneSample?.label}`}
aria-expanded={showTones}
onClick={() => setShowTones((value) => !value)}
className={cn(
expressionsPickerVariants({ selected: showTones }),
"p-0 no-underline"
)}
>
<span
aria-hidden="true"
className="text-[length:var(--newt-font-size-expression)] leading-none"
>
{toneSample?.sample}
</span>
</button>
{showTones ? (
<div
role="group"
aria-label="Skin tone"
className="absolute -end-[var(--newt-space-1)] -top-[var(--newt-space-1)] z-20 flex flex-col gap-[var(--newt-space-1)] rounded-md border border-newt-border bg-newt-bg-floating p-[var(--newt-space-1)] shadow-elevation-high"
>
{skinTones.map((option) => (
<button
key={option.tone}
type="button"
aria-label={option.label}
title={option.label}
aria-pressed={tone === option.tone}
onClick={() => pickTone(option.tone)}
className={cn(
expressionsPickerVariants(),
"p-0 aria-pressed:bg-newt-bg-active aria-pressed:shadow-[inset_0_0_0_2px_var(--newt-brand)]"
)}
>
<span
aria-hidden="true"
className="text-[length:var(--newt-font-size-expression)] leading-none"
>
{option.sample}
</span>
</button>
))}
</div>
) : null}
</div>
) : null}
</div>
<div
id={`${id}-panel`}
role="tabpanel"
aria-labelledby={`${id}-${kind}`}
className="flex min-h-0 flex-1"
>
{!searching && sections.length > 1 ? (
<nav
aria-label="Categories"
className="flex w-[calc(var(--newt-expressions-target-size)+var(--newt-space-2))] shrink-0 flex-col items-center gap-[var(--newt-space-1)] overflow-y-auto overflow-x-hidden border-e border-newt-border p-[var(--newt-space-2)] ![scrollbar-width:none]"
>
{sections.map((section) => (
<button
key={section.key}
type="button"
aria-label={section.label}
title={section.label}
aria-current={
activeSection === section.key ? "true" : undefined
}
onClick={() => goTo(section.key)}
className={cn(
expressionsPickerVariants(),
"relative shrink-0 p-0",
activeSection === section.key &&
"text-newt-text-primary before:absolute before:-start-[var(--newt-space-2)] before:top-1/2 before:h-[calc(var(--newt-space-3)+var(--newt-space-2))] before:w-[var(--newt-space-1)] before:-translate-y-1/2 before:rounded-e-full before:bg-newt-text-primary before:content-['']"
)}
>
{section.iconSrc ? (
<img
src={section.iconSrc}
alt=""
className="size-[calc(var(--newt-expressions-target-size)-var(--newt-space-3)-var(--newt-space-1))] rounded-full"
/>
) : (
<span
aria-hidden="true"
className="text-[length:calc(var(--newt-font-size-expression)*0.625)] leading-none"
>
{section.glyph ?? section.label.slice(0, 1)}
</span>
)}
</button>
))}
</nav>
) : null}
<div
ref={scroller}
onScroll={onScroll}
onKeyDown={gridKeyDown}
onMouseLeave={() => setHovered(null)}
className="relative min-w-0 flex-1 overflow-y-auto px-[var(--newt-space-2)] pb-[var(--newt-space-2)]"
>
{sections.map((section) => (
<section
key={section.key}
data-section={section.key}
aria-labelledby={`${id}-${section.key}-title`}
>
<h3
id={`${id}-${section.key}-title`}
className="sticky top-0 z-10 m-0 bg-newt-bg-surface pb-[var(--newt-space-1)] pt-[var(--newt-space-2)] text-[length:var(--newt-font-size-sm)] font-semibold text-newt-text-secondary"
>
{section.collapsible ? (
<button
type="button"
aria-expanded={isOpen(section.key)}
aria-controls={`${id}-${section.key}`}
onClick={() => toggleSection(section.key)}
className={`inline-flex max-w-full cursor-pointer items-center gap-[var(--newt-space-2)] rounded-sm border-0 bg-transparent px-[var(--newt-space-1)] font-[inherit] text-[inherit] hover:text-newt-text-primary ${focusRing}`}
>
<SectionIcon section={section} />
<span className="min-w-0 truncate">
{section.label}
</span>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className={cn(
"size-[var(--newt-space-3)] shrink-0 fill-none stroke-current stroke-2",
!isOpen(section.key) &&
"[transform:rotate(calc(-90deg*var(--newt-dir)))]"
)}
>
<path
d="m4 6 4 4 4-4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
) : (
<span className="inline-flex items-center gap-[var(--newt-space-2)] px-[var(--newt-space-1)]">
{section.label}
</span>
)}
</h3>
{isOpen(section.key) ? (
<div
id={`${id}-${section.key}`}
className={cn(
"grid pt-[var(--newt-space-1)]",
kind === "emoji"
? "grid-cols-[repeat(auto-fill,var(--newt-expressions-target-size))]"
: "grid-cols-[repeat(auto-fill,minmax(var(--newt-expressions-preview-size),1fr))] gap-[var(--newt-space-1)]"
)}
>
{section.items.map((item) => {
const key = `${section.key}|${item.id}`
const value = expressionValue(item, tone)
const where =
categories.find(
(category) => category.id === item.category
)?.label ?? ""
return (
<button
key={key}
type="button"
data-cell
disabled={disabled || item.disabled}
tabIndex={key === tabKey ? 0 : -1}
aria-label={
item.disabled
? `${item.label}, unavailable`
: item.label
}
aria-pressed={
current != null &&
(item.value === current ||
item.skins?.includes(current) === true)
}
onClick={() => select(item)}
onFocus={() => {
setActiveKey(key)
setHovered({ item, where })
}}
onMouseEnter={() => setHovered({ item, where })}
className={cn(
"flex cursor-pointer items-center justify-center rounded-md border border-transparent bg-transparent p-0 enabled:hover:bg-newt-bg-hover aria-pressed:bg-newt-bg-active aria-pressed:shadow-[inset_0_0_0_2px_var(--newt-brand)] disabled:cursor-not-allowed disabled:border-dashed disabled:border-newt-border disabled:opacity-50",
kind === "emoji"
? "size-[var(--newt-expressions-target-size)]"
: "aspect-square w-full",
focusRing
)}
>
<ExpressionArt
item={item}
value={value}
large={kind !== "emoji"}
/>
</button>
)
})}
</div>
) : null}
</section>
))}
{sections.length === 0 ? (
<div
role="status"
className="flex flex-col items-center px-[var(--newt-space-3)] py-[calc(var(--newt-space-3)*3)] text-center text-newt-text-secondary"
>
<p className="m-0 font-semibold text-newt-text-primary">
{searching
? "No expressions match your search."
: "Nothing here yet."}
</p>
<p className="m-0">
{searching
? "Try another word or another tab."
: "Try another tab."}
</p>
</div>
) : searching ? (
<p role="status" className="sr-only">
{resultCount === 1 ? "1 result" : `${resultCount} results`}
</p>
) : null}
</div>
</div>
<div className="flex h-[calc(var(--newt-expressions-target-size)+var(--newt-space-3))] shrink-0 items-center gap-[var(--newt-space-3)] border-t border-newt-border bg-newt-bg-floating px-[var(--newt-space-3)]">
{preview ? (
<>
<span
aria-hidden="true"
className="flex size-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] shrink-0 items-center justify-center overflow-hidden"
>
{preview.item.src ? (
<img
src={preview.item.src}
alt=""
className="size-full object-contain"
/>
) : preview.item.kind === "emoji" ? (
<span className="text-[length:var(--newt-font-size-expression)] leading-none">
{expressionValue(preview.item, tone)}
</span>
) : null}
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate font-semibold">
{expressionShortcode(preview.item)}
</span>
{preview.where ? (
<span className="truncate text-[length:var(--newt-font-size-xs)] text-newt-text-secondary">
from{" "}
<strong className="font-semibold">{preview.where}</strong>
</span>
) : null}
</span>
</>
) : (
<span className="text-newt-text-secondary">
Hover or focus an expression to preview it.
</span>
)}
</div>
</dialog>
</div>
)
}
)
ExpressionsPicker.displayName = "ExpressionsPicker"
export { ExpressionsPicker }<script setup lang="ts">
import {
computed,
nextTick,
onBeforeUnmount,
shallowRef,
useId,
useTemplateRef,
watch,
} from "vue"
import { cn } from "@/lib/utils"
import {
buildExpressionSections,
expressionKinds,
expressionShortcode,
expressionsFocusRing,
expressionsPickerVariants,
expressionValue,
neighbourCell,
skinTones,
type ExpressionCategory,
type ExpressionItem,
type ExpressionKind,
type SkinTone,
} from "."
import ExpressionArt from "./ExpressionArt.vue"
const props = withDefaults(
defineProps<{
items?: readonly ExpressionItem[]
categories?: readonly ExpressionCategory[]
/** Item IDs used last, most recent first. The application stores and updates them. */
recent?: readonly string[]
/** The value chosen now, marked as pressed in the grid. */
current?: string | null
label?: string
disabled?: boolean
}>(),
{
items: () => [],
categories: () => [],
recent: () => [],
current: null,
label: "Choose an expression",
disabled: false,
}
)
/** Receives the item and its value in the chosen skin tone. */
const emit = defineEmits<{ select: [item: ExpressionItem, value: string] }>()
const id = useId()
const dialog = useTemplateRef<HTMLDialogElement>("dialog")
const trigger = useTemplateRef<HTMLButtonElement>("trigger")
const search = useTemplateRef<HTMLInputElement>("search")
const scroller = useTemplateRef<HTMLDivElement>("scroller")
const toneButton = useTemplateRef<HTMLButtonElement>("toneButton")
const toneRoot = useTemplateRef<HTMLDivElement>("toneRoot")
const open = shallowRef(false)
const kind = shallowRef<ExpressionKind>("emoji")
const query = shallowRef("")
const tone = shallowRef<SkinTone>(0)
const showTones = shallowRef(false)
const collapsed = shallowRef<string[]>([])
const scrolledTo = shallowRef<string | null>(null)
const activeKey = shallowRef<string | null>(null)
const hovered = shallowRef<{ item: ExpressionItem; where: string } | null>(null)
const triggerClass = `min-h-[var(--newt-expressions-target-size)] cursor-pointer rounded-sm border border-newt-border bg-newt-bg-elevated px-[var(--newt-space-3)] py-[var(--newt-space-2)] font-sans text-[length:var(--newt-font-size-sm)] leading-[var(--newt-line-height-normal)] text-newt-text-primary enabled:hover:bg-newt-bg-hover enabled:active:bg-newt-bg-active disabled:cursor-not-allowed disabled:text-newt-text-muted ${expressionsFocusRing}`
const searching = computed(() => query.value.trim().length > 0)
const sections = computed(() =>
buildExpressionSections(
props.items,
props.categories,
props.recent,
kind.value,
query.value
)
)
const isOpen = (key: string) => !collapsed.value.includes(key)
const activeSection = computed(() => scrolledTo.value ?? sections.value[0]?.key)
const tabKey = computed(() => {
const keys = sections.value
.filter((section) => isOpen(section.key))
.flatMap((section) =>
section.items
.filter((item) => !item.disabled)
.map((item) => `${section.key}|${item.id}`)
)
return activeKey.value && keys.includes(activeKey.value)
? activeKey.value
: keys[0]
})
const resultCount = computed(() =>
sections.value.reduce((count, section) => count + section.items.length, 0)
)
const toneSample = computed(() => skinTones[tone.value])
function where(item: ExpressionItem) {
return (
props.categories.find((category) => category.id === item.category)?.label ??
""
)
}
function isCurrent(item: ExpressionItem) {
return (
props.current != null &&
(item.value === props.current ||
item.skins?.includes(props.current) === true)
)
}
const currentItem = computed(() => {
const item = props.items.find(
(entry) => entry.kind === kind.value && isCurrent(entry)
)
return item ? { item, where: where(item) } : null
})
const preview = computed(() => hovered.value ?? currentItem.value)
function toggleSection(key: string) {
collapsed.value = isOpen(key)
? [...collapsed.value, key]
: collapsed.value.filter((entry) => entry !== key)
}
async function goTo(key: string) {
collapsed.value = collapsed.value.filter((entry) => entry !== key)
scrolledTo.value = key
await nextTick()
const section = scroller.value?.querySelector<HTMLElement>(
`[data-section="${key}"]`
)
if (section && scroller.value) scroller.value.scrollTop = section.offsetTop
}
function onScroll() {
const element = scroller.value
if (!element) return
let found: string | null = null
for (const section of element.querySelectorAll<HTMLElement>(
"[data-section]"
)) {
if (section.offsetTop > element.scrollTop + 8) break
found = section.dataset.section ?? null
}
if (found) scrolledTo.value = found
}
function show() {
if (props.disabled) return
query.value = ""
hovered.value = null
showTones.value = false
dialog.value?.showModal()
open.value = true
search.value?.focus()
}
function close() {
dialog.value?.close()
}
function closed() {
open.value = false
showTones.value = false
trigger.value?.focus()
}
watch(
() => props.disabled,
(disabled) => {
if (disabled) close()
}
)
function changeKind(next: ExpressionKind) {
kind.value = next
scrolledTo.value = null
hovered.value = null
showTones.value = false
if (scroller.value) scroller.value.scrollTop = 0
}
function tabKeyDown(event: KeyboardEvent, index: number) {
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
const rtl = getComputedStyle(target).direction === "rtl"
const count = expressionKinds.length
const next =
event.key === "Home"
? 0
: event.key === "End"
? count - 1
: event.key === (rtl ? "ArrowLeft" : "ArrowRight")
? (index + 1) % count
: event.key === (rtl ? "ArrowRight" : "ArrowLeft")
? (index + count - 1) % count
: -1
const nextTab = expressionKinds[next]
if (!nextTab) return
event.preventDefault()
changeKind(nextTab.kind)
dialog.value
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
.item(next)
?.focus()
}
function gridKeyDown(event: KeyboardEvent) {
const target = event.target
if (!(target instanceof HTMLElement) || !target.matches("[data-cell]")) return
const cells = [
...(scroller.value?.querySelectorAll<HTMLElement>(
"[data-cell]:not(:disabled)"
) ?? []),
]
const rtl = getComputedStyle(target).direction === "rtl"
const next = neighbourCell(cells, cells.indexOf(target), event.key, rtl)
if (!next) return
event.preventDefault()
next.focus()
next.scrollIntoView({ block: "nearest" })
}
function focusFirstCell() {
scroller.value
?.querySelector<HTMLElement>('[data-cell][tabindex="0"]')
?.focus()
}
function onDialogKeyDown(event: KeyboardEvent) {
if (event.key === "Escape" && showTones.value) {
event.preventDefault()
showTones.value = false
toneButton.value?.focus()
return
}
if (event.key !== "Tab") return
const controls = [
...(dialog.value?.querySelectorAll<HTMLElement>(
"button:not(:disabled), input:not(:disabled)"
) ?? []),
].filter(
(element) => element.tabIndex >= 0 && element.getClientRects().length > 0
)
const first = controls[0]
const last = controls[controls.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last?.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first?.focus()
}
}
function pickTone(next: SkinTone) {
tone.value = next
showTones.value = false
toneButton.value?.focus()
}
function outside(event: PointerEvent) {
const target = event.target
if (!(target instanceof Node) || !toneRoot.value?.contains(target))
showTones.value = false
}
watch(showTones, async (shown) => {
document.removeEventListener("pointerdown", outside)
if (!shown) return
document.addEventListener("pointerdown", outside)
await nextTick()
toneRoot.value?.querySelector<HTMLElement>('[aria-pressed="true"]')?.focus()
})
onBeforeUnmount(() => document.removeEventListener("pointerdown", outside))
function select(item: ExpressionItem) {
if (props.disabled || item.disabled) return
close()
emit("select", item, expressionValue(item, tone.value))
}
function focusCell(key: string, item: ExpressionItem) {
activeKey.value = key
hovered.value = { item, where: where(item) }
}
function clearQuery() {
query.value = ""
search.value?.focus()
}
</script>
<template>
<div class="inline-block">
<button
ref="trigger"
type="button"
:disabled="disabled"
aria-haspopup="dialog"
:aria-expanded="open"
:aria-controls="id"
:class="triggerClass"
@click="show"
>
{{ label }}
</button>
<dialog
:id="id"
ref="dialog"
:aria-label="label"
class="m-auto box-border h-[min(var(--newt-expressions-height),calc(100dvh-2*var(--newt-space-3)))] w-[min(var(--newt-expressions-width),calc(100%-2*var(--newt-space-3)))] max-w-none overflow-hidden rounded-lg border border-newt-border bg-newt-bg-surface p-0 font-sans text-[length:var(--newt-font-size-sm)] leading-[var(--newt-line-height-normal)] text-newt-text-primary shadow-elevation-high backdrop:bg-[color-mix(in_srgb,var(--newt-bg-base)_80%,transparent)] open:flex open:flex-col"
@close="closed"
@keydown="onDialogKeyDown"
@click.self="close"
>
<div
class="flex shrink-0 items-center gap-[var(--newt-space-2)] px-[var(--newt-space-3)] pt-[var(--newt-space-3)]"
>
<div
role="tablist"
aria-label="Expression type"
class="flex items-center gap-[var(--newt-space-2)]"
>
<button
v-for="(tab, index) in expressionKinds"
:id="`${id}-${tab.kind}`"
:key="tab.kind"
role="tab"
type="button"
:aria-selected="kind === tab.kind"
:aria-controls="`${id}-panel`"
:tabindex="kind === tab.kind ? 0 : -1"
:class="expressionsPickerVariants({ selected: kind === tab.kind })"
@click="changeKind(tab.kind)"
@keydown="tabKeyDown($event, index)"
>
{{ tab.label }}
</button>
</div>
</div>
<div
class="flex shrink-0 items-center gap-[var(--newt-space-2)] border-b border-newt-border p-[var(--newt-space-3)]"
>
<div class="relative min-w-0 flex-1">
<input
ref="search"
v-model="query"
type="text"
autocomplete="off"
spellcheck="false"
aria-label="Search expressions"
placeholder="Search by name or keyword"
:class="`box-border h-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] w-full rounded-md border-0 bg-newt-bg-input pe-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] ps-[calc(var(--newt-space-3)*3)] font-sans text-[length:var(--newt-font-size-sm)] text-newt-text-primary placeholder:text-newt-text-muted ${expressionsFocusRing}`"
@input="hovered = null"
@keydown.down.prevent="focusFirstCell"
/>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
class="pointer-events-none absolute start-[var(--newt-space-3)] top-1/2 size-[calc(var(--newt-space-3)+var(--newt-space-1))] -translate-y-1/2 fill-none stroke-newt-text-secondary stroke-2"
>
<circle cx="7" cy="7" r="5" />
<path d="m11 11 4 4" stroke-linecap="round" />
</svg>
<button
v-if="query"
type="button"
aria-label="Clear search"
:class="
cn(
expressionsPickerVariants(),
'absolute end-[var(--newt-space-1)] top-1/2 min-h-0 min-w-0 -translate-y-1/2 size-[calc(var(--newt-expressions-target-size)-var(--newt-space-3)-var(--newt-space-2))] p-0'
)
"
@click="clearQuery"
>
<span aria-hidden="true">×</span>
</button>
</div>
<div v-if="kind === 'emoji'" ref="toneRoot" class="relative shrink-0">
<button
ref="toneButton"
type="button"
:aria-label="`Skin tone: ${toneSample?.label}`"
:aria-expanded="showTones"
:class="
cn(
expressionsPickerVariants({ selected: showTones }),
'p-0 no-underline'
)
"
@click="showTones = !showTones"
>
<span
aria-hidden="true"
class="text-[length:var(--newt-font-size-expression)] leading-none"
>{{ toneSample?.sample }}</span
>
</button>
<div
v-if="showTones"
role="group"
aria-label="Skin tone"
class="absolute -end-[var(--newt-space-1)] -top-[var(--newt-space-1)] z-20 flex flex-col gap-[var(--newt-space-1)] rounded-md border border-newt-border bg-newt-bg-floating p-[var(--newt-space-1)] shadow-elevation-high"
>
<button
v-for="option in skinTones"
:key="option.tone"
type="button"
:aria-label="option.label"
:title="option.label"
:aria-pressed="tone === option.tone"
:class="
cn(
expressionsPickerVariants(),
'p-0 aria-pressed:bg-newt-bg-active aria-pressed:shadow-[inset_0_0_0_2px_var(--newt-brand)]'
)
"
@click="pickTone(option.tone)"
>
<span
aria-hidden="true"
class="text-[length:var(--newt-font-size-expression)] leading-none"
>{{ option.sample }}</span
>
</button>
</div>
</div>
</div>
<div
:id="`${id}-panel`"
role="tabpanel"
:aria-labelledby="`${id}-${kind}`"
class="flex min-h-0 flex-1"
>
<nav
v-if="!searching && sections.length > 1"
aria-label="Categories"
class="flex w-[calc(var(--newt-expressions-target-size)+var(--newt-space-2))] shrink-0 flex-col items-center gap-[var(--newt-space-1)] overflow-y-auto overflow-x-hidden border-e border-newt-border p-[var(--newt-space-2)] ![scrollbar-width:none]"
>
<button
v-for="section in sections"
:key="section.key"
type="button"
:aria-label="section.label"
:title="section.label"
:aria-current="activeSection === section.key ? 'true' : undefined"
:class="
cn(
expressionsPickerVariants(),
'relative shrink-0 p-0',
activeSection === section.key &&
`text-newt-text-primary before:absolute before:-start-[var(--newt-space-2)] before:top-1/2 before:h-[calc(var(--newt-space-3)+var(--newt-space-2))] before:w-[var(--newt-space-1)] before:-translate-y-1/2 before:rounded-e-full before:bg-newt-text-primary before:content-['']`
)
"
@click="goTo(section.key)"
>
<img
v-if="section.iconSrc"
:src="section.iconSrc"
alt=""
class="size-[calc(var(--newt-expressions-target-size)-var(--newt-space-3)-var(--newt-space-1))] rounded-full"
/>
<span
v-else
aria-hidden="true"
class="text-[length:calc(var(--newt-font-size-expression)*0.625)] leading-none"
>{{ section.glyph ?? section.label.slice(0, 1) }}</span
>
</button>
</nav>
<div
ref="scroller"
class="relative min-w-0 flex-1 overflow-y-auto px-[var(--newt-space-2)] pb-[var(--newt-space-2)]"
@scroll.passive="onScroll"
@keydown="gridKeyDown"
@mouseleave="hovered = null"
>
<section
v-for="section in sections"
:key="section.key"
:data-section="section.key"
:aria-labelledby="`${id}-${section.key}-title`"
>
<h3
:id="`${id}-${section.key}-title`"
class="sticky top-0 z-10 m-0 bg-newt-bg-surface pb-[var(--newt-space-1)] pt-[var(--newt-space-2)] text-[length:var(--newt-font-size-sm)] font-semibold text-newt-text-secondary"
>
<button
v-if="section.collapsible"
type="button"
:aria-expanded="isOpen(section.key)"
:aria-controls="`${id}-${section.key}`"
:class="`inline-flex max-w-full cursor-pointer items-center gap-[var(--newt-space-2)] rounded-sm border-0 bg-transparent px-[var(--newt-space-1)] font-[inherit] text-[inherit] hover:text-newt-text-primary ${expressionsFocusRing}`"
@click="toggleSection(section.key)"
>
<img
v-if="section.iconSrc"
:src="section.iconSrc"
alt=""
class="size-[calc(var(--newt-space-3)+var(--newt-space-1))] shrink-0 rounded-full"
/>
<span
v-else-if="section.glyph"
aria-hidden="true"
class="shrink-0 leading-none"
>{{ section.glyph }}</span
>
<span class="min-w-0 truncate">{{ section.label }}</span>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
:class="
cn(
'size-[var(--newt-space-3)] shrink-0 fill-none stroke-current stroke-2',
!isOpen(section.key) &&
'[transform:rotate(calc(-90deg*var(--newt-dir)))]'
)
"
>
<path
d="m4 6 4 4 4-4"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<span v-else class="px-[var(--newt-space-1)]">{{
section.label
}}</span>
</h3>
<div
v-if="isOpen(section.key)"
:id="`${id}-${section.key}`"
:class="
cn(
'grid pt-[var(--newt-space-1)]',
kind === 'emoji'
? 'grid-cols-[repeat(auto-fill,var(--newt-expressions-target-size))]'
: 'grid-cols-[repeat(auto-fill,minmax(var(--newt-expressions-preview-size),1fr))] gap-[var(--newt-space-1)]'
)
"
>
<button
v-for="item in section.items"
:key="`${section.key}|${item.id}`"
type="button"
data-cell
:disabled="disabled || item.disabled"
:tabindex="`${section.key}|${item.id}` === tabKey ? 0 : -1"
:aria-label="
item.disabled ? `${item.label}, unavailable` : item.label
"
:aria-pressed="isCurrent(item)"
:class="
cn(
'flex cursor-pointer items-center justify-center rounded-md border border-transparent bg-transparent p-0 enabled:hover:bg-newt-bg-hover aria-pressed:bg-newt-bg-active aria-pressed:shadow-[inset_0_0_0_2px_var(--newt-brand)] disabled:cursor-not-allowed disabled:border-dashed disabled:border-newt-border disabled:opacity-50',
kind === 'emoji'
? 'size-[var(--newt-expressions-target-size)]'
: 'aspect-square w-full',
expressionsFocusRing
)
"
@click="select(item)"
@focus="focusCell(`${section.key}|${item.id}`, item)"
@mouseenter="hovered = { item, where: where(item) }"
>
<ExpressionArt
:item="item"
:value="expressionValue(item, tone)"
:large="kind !== 'emoji'"
/>
</button>
</div>
</section>
<div
v-if="sections.length === 0"
role="status"
class="flex flex-col items-center px-[var(--newt-space-3)] py-[calc(var(--newt-space-3)*3)] text-center text-newt-text-secondary"
>
<p class="m-0 font-semibold text-newt-text-primary">
{{
searching
? "No expressions match your search."
: "Nothing here yet."
}}
</p>
<p class="m-0">
{{
searching
? "Try another word or another tab."
: "Try another tab."
}}
</p>
</div>
<p v-else-if="searching" role="status" class="sr-only">
{{ resultCount === 1 ? "1 result" : `${resultCount} results` }}
</p>
</div>
</div>
<div
class="flex h-[calc(var(--newt-expressions-target-size)+var(--newt-space-3))] shrink-0 items-center gap-[var(--newt-space-3)] border-t border-newt-border bg-newt-bg-floating px-[var(--newt-space-3)]"
>
<template v-if="preview">
<span
aria-hidden="true"
class="flex size-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] shrink-0 items-center justify-center overflow-hidden"
>
<img
v-if="preview.item.src"
:src="preview.item.src"
alt=""
class="size-full object-contain"
/>
<span
v-else-if="preview.item.kind === 'emoji'"
class="text-[length:var(--newt-font-size-expression)] leading-none"
>{{ expressionValue(preview.item, tone) }}</span
>
</span>
<span class="flex min-w-0 flex-col">
<span class="truncate font-semibold">{{
expressionShortcode(preview.item)
}}</span>
<span
v-if="preview.where"
class="truncate text-[length:var(--newt-font-size-xs)] text-newt-text-secondary"
>from
<strong class="font-semibold">{{ preview.where }}</strong></span
>
</span>
</template>
<span v-else class="text-newt-text-secondary"
>Hover or focus an expression to preview it.</span
>
</div>
</dialog>
</div>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
import type { ExpressionItem } from "."
defineProps<{ item: ExpressionItem; value: string; large?: boolean }>()
</script>
<template>
<img
v-if="item.src"
:src="item.src"
alt=""
loading="lazy"
:class="
cn(
'object-contain',
large
? 'size-[calc(var(--newt-expressions-preview-size)-var(--newt-space-3))]'
: 'size-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))]'
)
"
/>
<span
v-else-if="item.kind === 'emoji'"
aria-hidden="true"
class="text-[length:var(--newt-font-size-expression)] leading-none"
>{{ value }}</span
>
<span
v-else
aria-hidden="true"
class="px-[var(--newt-space-1)] text-center text-[length:var(--newt-font-size-xs)] text-newt-text-secondary"
>{{ item.label }}</span
>
</template>import { cva } from "class-variance-authority"
export { default as ExpressionsPicker } from "./ExpressionsPicker.vue"
export type ExpressionKind = "emoji" | "sticker" | "gif"
/** A section of one tab: a Unicode emoji group, a server's own emoji, or a sticker pack. */
export interface ExpressionCategory {
/** Unique across all categories. */
id: string
kind: ExpressionKind
label: string
/** A Unicode character for the category rail. */
glyph?: string
/** A static image for the category rail, such as a server icon. Wins over `glyph`. */
iconSrc?: string
}
export interface ExpressionItem {
/** Unique across all items. */
id: string
kind: ExpressionKind
/** The `id` of the category this item belongs to. */
category: string
/** Accessible name, searched and shown in the preview bar. */
label: string
/** What a selection returns: Unicode text, or an application token for media. */
value: string
keywords?: readonly string[]
/** The emoji in the five skin tones, light to dark. Omit when it has none. */
skins?: readonly string[]
/** A static image: a server emoji, a sticker, or a GIF poster. */
src?: string
disabled?: boolean
}
export type SkinTone = 0 | 1 | 2 | 3 | 4 | 5
export const expressionKinds: { kind: ExpressionKind; label: string }[] = [
{ kind: "gif", label: "GIFs" },
{ kind: "sticker", label: "Stickers" },
{ kind: "emoji", label: "Emoji" },
]
export const skinTones: { tone: SkinTone; label: string; sample: string }[] = [
{ tone: 0, label: "Default skin tone", sample: "👋" },
{ tone: 1, label: "Light skin tone", sample: "👋🏻" },
{ tone: 2, label: "Medium-light skin tone", sample: "👋🏼" },
{ tone: 3, label: "Medium skin tone", sample: "👋🏽" },
{ tone: 4, label: "Medium-dark skin tone", sample: "👋🏾" },
{ tone: 5, label: "Dark skin tone", sample: "👋🏿" },
]
/** The value of an item in a skin tone, or its own value when it has none. */
export function expressionValue(item: ExpressionItem, tone: SkinTone): string {
return (tone === 0 ? undefined : item.skins?.[tone - 1]) ?? item.value
}
/** `:grinning_face:` for an emoji, the label for media. */
export function expressionShortcode(item: ExpressionItem): string {
if (item.kind !== "emoji") return item.label
const name = item.label
.toLowerCase()
.replaceAll(/[^\p{L}\p{N}]+/gu, "_")
.replaceAll(/^_+|_+$/gu, "")
return `:${name}:`
}
/** Every word of the query must appear in the label or the keywords. */
export function matchesExpression(item: ExpressionItem, query: string) {
const text = [item.label, ...(item.keywords ?? [])].join(" ").toLowerCase()
return query
.toLowerCase()
.split(/[\s_:]+/u)
.filter(Boolean)
.every((word) => text.includes(word))
}
export interface ExpressionSection {
key: string
label: string
glyph?: string
iconSrc?: string
items: ExpressionItem[]
collapsible: boolean
}
export function buildExpressionSections(
items: readonly ExpressionItem[],
categories: readonly ExpressionCategory[],
recent: readonly string[],
kind: ExpressionKind,
query: string
): ExpressionSection[] {
const ofKind = items.filter((item) => item.kind === kind)
if (query.trim()) {
const found = ofKind.filter((item) => matchesExpression(item, query))
return found.length
? [
{
key: "results",
label: "Search results",
items: found,
collapsible: false,
},
]
: []
}
const byId = new Map(ofKind.map((item) => [item.id, item]))
const sections: ExpressionSection[] = []
const used = recent.flatMap((id) => {
const item = byId.get(id)
return item ? [item] : []
})
if (used.length)
sections.push({
key: "recent",
label: "Recently used",
glyph: "🕘",
items: used,
collapsible: true,
})
for (const category of categories) {
if (category.kind !== kind) continue
const list = ofKind.filter((item) => item.category === category.id)
if (list.length)
sections.push({
key: `category-${category.id}`,
label: category.label,
glyph: category.glyph,
iconSrc: category.iconSrc,
items: list,
collapsible: true,
})
}
return sections
}
/** The cell an arrow key leads to, by where the cells sit on screen. */
export function neighbourCell(
cells: HTMLElement[],
index: number,
key: string,
rtl: boolean
): HTMLElement | undefined {
if (key === (rtl ? "ArrowLeft" : "ArrowRight")) return cells[index + 1]
if (key === (rtl ? "ArrowRight" : "ArrowLeft")) return cells[index - 1]
if (key === "Home") return cells[0]
if (key === "End") return cells.at(-1)
if (key !== "ArrowDown" && key !== "ArrowUp") return undefined
const from = cells[index]?.getBoundingClientRect()
if (!from) return undefined
const step = key === "ArrowDown" ? 1 : -1
let rowTop: number | null = null
let best: { cell: HTMLElement; distance: number } | undefined
for (let at = index + step; at >= 0 && at < cells.length; at += step) {
const cell = cells[at]
if (!cell) break
const rect = cell.getBoundingClientRect()
if (Math.abs(rect.top - from.top) < 4) continue
rowTop ??= rect.top
if (Math.abs(rect.top - rowTop) >= 4) break
const distance = Math.abs(rect.left - from.left)
if (!best || distance < best.distance) best = { cell, distance }
}
return best?.cell
}
export const expressionsFocusRing =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link"
/** Tabs and the small controls of the panel. */
export const expressionsPickerVariants = cva(
`inline-flex min-h-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] min-w-[calc(var(--newt-expressions-target-size)-var(--newt-space-2))] cursor-pointer items-center justify-center gap-[var(--newt-space-1)] rounded-md border-0 px-[var(--newt-space-3)] font-sans text-[length:var(--newt-font-size-sm)] font-semibold leading-[var(--newt-line-height-normal)] enabled:hover:bg-newt-bg-hover enabled:hover:text-newt-text-primary enabled:active:bg-newt-bg-active disabled:cursor-not-allowed disabled:text-newt-text-muted ${expressionsFocusRing}`,
{
variants: {
selected: {
true: "bg-newt-bg-active text-newt-text-primary",
false: "bg-transparent text-newt-text-secondary",
},
},
defaultVariants: { selected: false },
}
)Update the import paths
The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.
Usage
import { ExpressionsPicker } from "@/components/ui/expressions-picker"
export function Example() {
return <ExpressionsPicker />
}<script setup lang="ts">
import { ExpressionsPicker, ExpressionArt } from "@/components/ui/expressions-picker"
</script>
<template>
<ExpressionsPicker />
</template>React receives selections through onSelect(item, value); Vue emits select with the same two arguments. value is the item’s value in the chosen skin tone, or item.value when it has no tones. Every selection closes the dialog. Store value as the current choice and move item.id to the front of recent yourself; the demo keeps the last 18.
API reference
ExpressionsPicker
items?readonly ExpressionItem[]
Available expressions. Each tab shows the items of its kind, grouped by category in the order of `categories`.
readonly ExpressionItem[][]categories?readonly ExpressionCategory[]
The sections of each tab, in display order. Items whose category is missing are not shown.
readonly ExpressionCategory[][]recent?readonly string[]
Item IDs used last, most recent first. Shown as a "Recently used" section above the categories.
readonly string[][]current?string | null
The value chosen now. Matching cells are pressed, and the foot bar names it while nothing is hovered.
string | nullnulllabel?string
Trigger text and accessible dialog name.
stringChoose an expressiondisabled?boolean
Disables the trigger and closes an open picker.
booleanfalseonSelect / @select?(item: ExpressionItem, value: string) => void
Receives an enabled item and its value in the chosen skin tone.
(item: ExpressionItem, value: string) => voidclassName / class?string
Classes applied to the outer wrapper.
stringExpressionItem
| Field | Type | Meaning |
|---|---|---|
id |
string |
Stable ID, unique across the complete list. recent refers to it. |
kind |
"emoji" | "sticker" | "gif" |
Which tab contains this item. |
category |
string |
The id of its ExpressionCategory. |
label |
string |
Accessible name, searched, and shown in the foot bar. Emoji show it as :snake_case:. |
value |
string |
What a selection returns: Unicode text, or an application token for server emoji and media. |
keywords |
readonly string[] |
Optional search aliases. Every word of a search must match the label or a keyword. |
skins |
readonly string[] |
Optional: the emoji in the five skin tones, light to dark. Without it the tone does not apply. |
src |
string |
Optional static image: a server emoji, a sticker, or a GIF poster. Never fetched as media. |
disabled |
boolean |
Optional unavailable state, shown dimmed with a dashed border and announced as unavailable. |
ExpressionCategory
| Field | Type | Meaning |
|---|---|---|
id |
string |
Unique across all categories. |
kind |
"emoji" | "sticker" | "gif" |
Which tab shows this section. |
label |
string |
Section title, rail button name, and source named in the foot bar. |
glyph |
string |
Optional Unicode character for the rail button. |
iconSrc |
string |
Optional static image for the rail button, such as a server icon. |
Use a dataset such as Emojibase to build items for the full Unicode set; group its entries into categories and pass its skin tone variants as skins. Images must be still and yours to use. The picker never renders value as a media source.
expressionsPickerVariants({ selected }) exports the style of the tabs and small controls. The selected variant fills the control with the active surface.
HTML and CSS
Load tokens.css, expressions-picker.css, and expressions-picker.js with defer, after copying the example markup. Put the categories, items, and recent IDs as JSON in the data-newt-expressions-data script; the fields are the same as above. The script draws the rail, sections, and foot bar, and initializes each data-newt-expressions-picker root separately with unique ARIA IDs.
Listen for the bubbling newt:expression-select event on the root. event.detail holds item, value, and recent, the updated list of IDs to persist. The script keeps that list for the life of the page and marks the last choice as pressed. Disable the trigger to disable opening the picker, and close the dialog before disabling an open one.
Accessibility
The native modal dialog contains keyboard focus and makes the surrounding page inert. Opening focuses the search field; Down Arrow moves from it into the grid. Escape closes the skin tone list first, then the dialog. A click on the backdrop also closes it. Closing or selecting returns focus to the trigger.
The tab list uses automatic activation: Left and Right move between types in reading direction, and Home and End select the first and last tab. The selected tab is a filled pill, not only a color change.
The grid keeps one cell in the tab order. Arrow keys move by position on screen, following the reading direction, and Home and End jump to the first and last cell. Each cell is a button named by its label; the current choice is aria-pressed. Section titles are buttons with aria-expanded, and the rail is a navigation landmark whose buttons name their section and mark the visible one with aria-current and a bar on the rail’s start edge. The skin tone list is a group of pressed-state buttons. A polite status announces the number of search results and explains empty searches.
Every focus indicator uses a 2px link-color outline with a 2px offset. The layout follows inherited dir, scales down to narrow screens, and uses no motion: jumping to a section scrolls instantly, and a collapsed section’s chevron is a static rotation that points along the reading direction. The rail hides its scrollbar but still scrolls with the wheel, touch, and keyboard. Unicode emoji appearance depends on the operating system.
Tokens
Expressions Picker reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer. In the plain HTML and CSS distribution the same component is newt-expressions-picker.
The picker uses the existing surface, border, text, brand, font, radius, and elevation tokens. New shared tokens are --newt-space-1 (4px), --newt-space-2 (8px), --newt-space-3 (12px), --newt-font-size-xs (12px), --newt-font-size-sm (14px), --newt-font-size-expression (32px), and --newt-line-height-normal (1.5). Picker geometry uses --newt-expressions-width (512px), --newt-expressions-height (496px), --newt-expressions-target-size (48px, an emoji cell), and --newt-expressions-preview-size (96px, the smallest sticker or GIF cell).
These additions are backward compatible. Existing installations should refresh theme-newt or copy the new token declarations before adding this picker.