EmpireUI
Get Pro
← Blog7 min read#emoji-picker#react-components#tailwind-css

Emoji Picker in React: Searchable, Categorised, Lightweight

Build a searchable, categorised emoji picker in React without heavy dependencies. Real code, Tailwind v4 styling, and patterns that actually work in production.

Colorful emoji symbols displayed on a screen representing a React emoji picker component

Why Most Emoji Pickers Are Overkill

Honestly, most emoji picker libraries ship 800KB of JavaScript for what is essentially a filtered grid of Unicode characters. That's a wild trade-off to make for a chat input or a reaction button.

The popular packages — emoji-mart being the most downloaded — do a lot of heavy lifting around skin tone modifiers, custom emoji sets, and i18n. If you need all of that, fine. But most apps don't. A comment box, a status field, a feedback widget — these need 30 categories and a search box, not a full emoji runtime.

This guide walks through building your own lightweight emoji picker from scratch. We'll use the native Unicode emoji data that browsers already understand, Tailwind v4 for styling, and zero external emoji libraries. You can drop the result into any React project and it'll sit around 12KB gzipped.

If you're already building rich UI with animated components like animated tabs or card stacks, you know how much a bloated dependency can slow down perceived performance. Keep the picker lean.

Structuring Your Emoji Data

The Unicode Consortium publishes the full emoji list as a text file. You don't need to import that whole thing. Instead, curate a JSON array of objects — each with a char, name, and category field. 1,800 to 2,000 entries covers Smileys, People, Nature, Food, Travel, Objects, Symbols, and Flags without going overboard.

Here's the shape you want:

type EmojiEntry = {
  char: string;       // '😀'
  name: string;       // 'grinning face'
  category: string;   // 'smileys'
  keywords?: string[]; // ['happy', 'smile', 'joy']
};

// Sample slice of your emoji-data.json
const EMOJIS: EmojiEntry[] = [
  { char: '😀', name: 'grinning face', category: 'smileys', keywords: ['happy', 'smile'] },
  { char: '😂', name: 'face with tears of joy', category: 'smileys', keywords: ['lol', 'laugh'] },
  { char: '🔥', name: 'fire', category: 'symbols', keywords: ['hot', 'flame', 'lit'] },
  // ... more entries
];

Keywords are optional but they matter a lot for search quality. A user typing 'lol' should find '😂' even though the official name is 'face with tears of joy'. You can source keyword mappings from the emojilib project on GitHub — it's just a JSON file you can copy without pulling in the npm package.

Building the Search Input and Filter Logic

Search is the most-used feature. People don't scroll through 2,000 emojis — they type 'pizza' and click the first result. So get the search logic right before touching the grid layout.

The filter runs on every keystroke, so it needs to be fast. A simple substring match on both name and keywords is plenty for this size of dataset. Don't reach for Fuse.js or a fuzzy matcher here — the latency is not your bottleneck, the render is.

import { useState, useMemo } from 'react';
import EMOJIS from './emoji-data.json';

function useEmojiSearch(query: string, category: string | null) {
  return useMemo(() => {
    const q = query.toLowerCase().trim();
    return EMOJIS.filter((e) => {
      const matchesCategory = !category || e.category === category;
      if (!q) return matchesCategory;
      const inName = e.name.includes(q);
      const inKeywords = e.keywords?.some((k) => k.includes(q)) ?? false;
      return matchesCategory && (inName || inKeywords);
    });
  }, [query, category]);
}

Wrapping this in useMemo with [query, category] as dependencies means you're only recomputing when something actually changes. On a list of 1,900 entries this runs in under 2ms on a mid-range phone. Good enough.

Building the Picker UI with Tailwind v4

The picker itself is a floating panel — usually positioned below or above a trigger button. You want it to feel snappy, not sluggish. Keep the grid cells small, use grid-cols-8 or grid-cols-9, and don't add hover animations that fight the scroll.

With Tailwind v4.0.2, you can use the new @starting-style utilities via the starting: variant for entry animations. That's a nice touch on a panel like this.

function EmojiPicker({ onSelect }: { onSelect: (char: string) => void }) {
  const [query, setQuery] = useState('');
  const [activeCategory, setActiveCategory] = useState<string | null>(null);
  const results = useEmojiSearch(query, activeCategory);

  return (
    <div
      className="
        w-72 rounded-xl border border-white/10
        bg-zinc-900 shadow-2xl
        flex flex-col overflow-hidden
        starting:opacity-0 starting:scale-95
        transition-all duration-150 ease-out
      "
    >
      {/* Search */}
      <div className="px-3 pt-3">
        <input
          type="text"
          placeholder="Search emoji…"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          className="
            w-full rounded-lg bg-zinc-800 px-3 py-2 text-sm text-white
            placeholder:text-zinc-500 outline-none
            focus:ring-2 focus:ring-violet-500/60
          "
        />
      </div>

      {/* Category tabs */}
      <CategoryBar
        active={activeCategory}
        onChange={setActiveCategory}
      />

      {/* Grid */}
      <div className="grid grid-cols-8 gap-0.5 p-2 overflow-y-auto max-h-52">
        {results.map((emoji) => (
          <button
            key={emoji.char}
            onClick={() => onSelect(emoji.char)}
            title={emoji.name}
            aria-label={emoji.name}
            className="
              flex items-center justify-center
              h-9 w-9 rounded-md text-xl
              hover:bg-white/10 active:bg-white/20
              transition-colors duration-75
            "
          >
            {emoji.char}
          </button>
        ))}

        {results.length === 0 && (
          <p className="col-span-8 py-6 text-center text-xs text-zinc-500">
            No emojis found
          </p>
        )}
      </div>
    </div>
  );
}

The max-h-52 on the grid keeps the panel at a fixed height so it doesn't push page content around. The overflow-y-auto lets the grid scroll independently. That 8-column layout with gap-0.5 puts about 2px between cells — tight enough to feel dense but not claustrophobic.

Category Bar and Navigation

Category tabs are the secondary navigation. Most pickers use icons (a smiley face for Smileys, a paw print for Animals). You can do this with actual emoji characters as icon substitutes — it's zero-asset, immediately recognisable, and works in every browser.

Keep the bar scrollable horizontally on mobile. Eight or nine categories in a 288px panel don't fit at 40px each, so overflow-x-auto with scrollbar-none on the bar is the right call. Pair it with scroll-snap-type: x mandatory if you want tactile feel on touch devices.

The active category should visually persist. A 2px bottom border in violet-500 or a bg-white/10 background on the active tab both read clearly on dark backgrounds. Don't use colour alone — screen readers and keyboard users need a focus indicator too. Add aria-selected and role="tab" to make the tabs semantically correct without much extra code.

Positioning the Picker with a Trigger Button

A picker that opens in the wrong place on mobile ruins the experience. You can't rely on position: absolute with a static offset — it'll clip inside overflow-hidden containers, fall off viewport edges, or get buried under other elements.

The cleanest approach in 2026 is the CSS Anchor Positioning API, but browser support is still uneven. The next best thing is a Floating UI hook. It's 4KB gzipped and handles all the edge detection, flip, and shift logic you'd otherwise spend two days writing. Call useFloating with placement: 'top-start' as the preferred position, and let the flip middleware swap it to bottom-start when there's not enough space above.

For the trigger itself, pair it with an animated button from Empire UI if you want a microinteraction on open. Or keep it dead simple — a plain icon button with a title attribute does the job. What matters is that aria-expanded is toggled correctly and the picker has role="dialog" with a proper aria-label. Keyboard users hitting Escape should close the picker and return focus to the trigger.

If you're building a theme-aware UI, make sure the picker's background and border colours respond to the active colour scheme. The rgba values above (rgba(255,255,255,0.10) for hover states) already work on both dark and light backgrounds with minimal adjustments.

Performance: Virtual Scrolling and Lazy Loading

Rendering 1,900 DOM nodes at once is asking for trouble. On a mid-range Android device you'll see a 200-300ms paint delay when the picker opens. That's noticeable. Two options: virtual scrolling or pagination.

Virtual scrolling with @tanstack/virtual keeps the DOM at roughly 40-60 rendered rows regardless of dataset size. It's about 6KB and takes 15 minutes to wire up. For a picker with grid-cols-8, you're virtualising rows of 8 emoji each, so your item count is Math.ceil(results.length / 8). The setup is the same as virtualising any list.

The simpler option — and often good enough — is to cap the initial render at 160 results (20 rows of 8) and show a 'Show more' button or infinite scroll trigger at the bottom. When a user has typed a search query that returns fewer than 160 results, you never hit the limit anyway. Most people find what they're looking for in the first two rows.

Accessibility and Keyboard Navigation

Can you navigate the picker without a mouse? You should be able to. Arrow keys moving between emoji cells, Tab cycling between the search input and category bar, Enter selecting the focused emoji, and Escape closing the panel — these are the minimum requirements.

Implementing grid keyboard navigation means intercepting ArrowUp, ArrowDown, ArrowLeft, ArrowRight in a keydown handler on the grid container. Calculate the target index based on current focus and column count. Call .focus() on the target button programmatically. It's about 25 lines of code and it makes the component usable for anyone on a keyboard, not just nice-to-have for screen reader users.

Screen readers should announce the emoji name, not the character. The aria-label on each button handles that — aria-label="grinning face" is read aloud while the visual display shows '😀'. Don't put both the emoji character and its name in the button text, or the reader will say something like 'grinning face 😀 button' which is redundant and weird.

Worth checking: does the picker work inside a modal? Does it trap focus correctly? If you're building a full chat interface alongside something like a marquee component for status messages, focus management across multiple interactive regions gets complicated fast. Test with VoiceOver and NVDA early, not as a final polish step.

FAQ

Does this approach work with React 18 and the concurrent renderer?

Yes. The useMemo hook for filtering is compatible with concurrent mode. The only thing to watch is if you're wrapping the picker in a Suspense boundary — make sure the emoji JSON import is handled as a static import, not a dynamic one, or you'll hit a loading state on every open.

How do I handle skin tone modifiers without a full emoji library?

Unicode skin tone modifiers are code points U+1F3FB through U+1F3FF. You can append them to a base emoji character: '👋' + '\u{1F3FD}' gives you '👋🏽'. Store the user's preferred modifier in a context value or local storage, then apply it at render time. You don't need a library for this — just a selector UI and a bit of string concatenation.

What's the best way to persist recently used emojis?

localStorage with a simple JSON array is fine. Cap it at 30 entries, prepend on each selection, and deduplicate. Show a 'Recently Used' category at the top of the picker. On first load this array will be empty, so hide the category tab until there's at least one entry.

How do I position the picker correctly inside an overflow-hidden container?

You can't position a child element outside its overflow-hidden ancestor using standard CSS positioning. Use a React portal — ReactDOM.createPortal(pickerNode, document.body) — to render the picker at the document root. Then use Floating UI to calculate the correct top and left values relative to the trigger element's getBoundingClientRect().

Will this work in a Next.js App Router project with server components?

The picker itself needs 'use client' since it relies on useState and event handlers. The emoji JSON data can be imported statically in the client component — Next.js will bundle it at build time. Don't try to fetch emoji data from an API route on every picker open; that adds unnecessary latency.

How large is the emoji JSON file and does it affect bundle size?

A curated set of 1,900 entries with names, categories, and minimal keywords runs around 180KB uncompressed, roughly 45KB gzipped. If that's too heavy for your initial bundle, dynamic import it: const { default: EMOJIS } = await import('./emoji-data.json') inside the component's open handler. It'll load once and be cached by the browser.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Custom Select Dropdown in React: Searchable, Multi-SelectImage Gallery with Lightbox: Accessible Photo Viewer in ReactReact Aria + Tailwind: Accessible Components with Utility ClassesNeumorphism Cards: 8 Soft-UI Variants with Accessibility Fixes