Build an emoji picker with React
Add an emoji picker to a React message composer, with instant choices and search powered by ParseAPI.
Working example
ReactSet up ParseAPI
Use a public API key restricted to your website. Keep secret keys on your server.
Get an API key βAdd expression without interrupting the message
Build a message composer with an emoji button in its toolbar. Clicking it opens a compact picker with familiar emojis ready to use. Typing in the picker searches ParseAPI. Choosing an emoji inserts it at the message cursor, closes the picker, and returns focus to the message.
Create a React project
Use Node 20.19+ or 22.12+ with npm. Create the app with the React template:
npm create vite@9.3.4 emoji-composer -- --template react --no-interactive
cd emoji-composer
npm install react@19.2.6 react-dom@19.2.6The component below uses React and browser HTTP requests. Set API_KEY to your public ParseAPI key and add localhost to that key's allowed domains. Keep secret keys on a server.
1. Keep the message and its cursor
Import the hooks for state, DOM references, and the search effect:
import { useEffect, useRef, useState } from 'react';Start with a message and a small set of familiar emoji choices. These are local UI defaults, so opening the picker works immediately. They are separate from ParseAPI search results:
const INITIAL_MESSAGE = 'Thanks for your help ';
// These are local starting choices. Search results come from ParseAPI.
const POPULAR = [
['π', 'Grinning face'], ['π', 'Smiling face'], ['π', 'Face with tears of joy'],
['π₯°', 'Smiling face with hearts'], ['π', 'Heart eyes'], ['π', 'Smiling face with sunglasses'],
['π₯³', 'Partying face'], ['π€', 'Thinking face'], ['π₯Ή', 'Face holding back tears'],
['π', 'Loudly crying face'], ['π', 'Waving hand'], ['π', 'Raising hands'],
['π', 'Clapping hands'], ['π', 'Thumbs up'], ['π', 'Folded hands'],
['β€οΈ', 'Red heart'], ['π', 'Yellow heart'], ['π', 'Purple heart'],
['π₯', 'Fire'], ['β¨', 'Sparkles'], ['π', 'Party popper'],
['π―', 'Hundred points'], ['β
', 'Check mark'], ['π', 'Rocket'],
].map(([emoji, name]) => ({ emoji, name }));Inside App, keep the message, picker visibility, search query, and search results in state:
const [message, setMessage] = useState(INITIAL_MESSAGE);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [emojis, setEmojis] = useState([]);
const editor = useRef(null);
const selection = useRef({ start: INITIAL_MESSAGE.length, end: INITIAL_MESSAGE.length });
function rememberSelection(event) {
selection.current = { start: event.target.selectionStart, end: event.target.selectionEnd };
}selection remembers where to insert an emoji. Start it at the end of the message. Record changes to the selection and save it again when the textarea loses focus:
<textarea id="message" ref={editor} value={message}
onChange={event => setMessage(event.target.value)}
onSelect={rememberSelection} onBlur={rememberSelection} />The query stays separate from the message. Searching never replaces what someone wrote, and moving focus into the picker does not lose the saved selection.
2. Open a picker from the toolbar
Put an emoji button below the textarea. Clicking it toggles open and clears the previous search so the picker starts with the popular choices:
<button ref={trigger} type="button" className="emoji-trigger" aria-label="Add emoji"
aria-expanded={open} aria-haspopup="dialog" aria-controls={open ? 'emoji-picker' : undefined}
onClick={() => { changeQuery(''); setOpen(!open); }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
<circle cx="12" cy="12" r="9" /><path d="M8 14a4 4 0 0 0 8 0" /><path d="M8 9h.01M16 9h.01" strokeWidth="3" strokeLinecap="round" />
</svg>
Emoji
</button>When open is true, render a panel with role="dialog" and aria-label="Emoji picker". Give it id="emoji-picker" so the trigger's aria-controls points to it. aria-expanded tells assistive technology whether the picker is open.
Inside that panel, add a search field:
<label className="visually-hidden" htmlFor="emoji-search">Search emoji</label>
<input id="emoji-search" ref={searchField} type="search" autoComplete="off" placeholder="Search emoji"
value={query} onChange={event => changeQuery(event.target.value)} maxLength={80} />Its change handler clears obsolete results and updates the search status immediately:
function changeQuery(value) {
const text = value.trim();
setQuery(value); setEmojis([]); setError(false);
if (text.length < 2) setStatus(text ? 'Type at least 2 characters to search.' : 'Popular');
else if (text.length > 80) setStatus('Use 80 characters or fewer.');
else if (!apiKey.startsWith('parse_public_')) setStatus('Add your public API key in App.jsx to search.');
else setStatus('Searchingβ¦');
}Focus the search field when the picker opens. The complete component places the panel above the toolbar when there is more room there, and closes on Escape, its close button, or a click outside. Escape and the close button return focus to the toolbar button; clicking outside lets the clicked control receive focus.
3. Search ParseAPI as someone types
Watch query in a useEffect. For fewer than two characters, show the local choices. Otherwise, wait 250 milliseconds after the last edit before sending a request. This short pause avoids making a request for every keystroke.
Send the search text to the Emoji API. This browser example uses HTTP directly:
const url = new URL('https://api.parseapi.com/emoji');
url.searchParams.set('q', searchText);
url.searchParams.set('limit', '24');
url.searchParams.set('key', apiKey);
const response = await fetch(url, {
headers: { 'X-API-Version': '2.1.0' },
signal: controller.signal, redirect: 'error', cache: 'no-store'
});
const result = await response.json();The response's emojis array contains the matches. Each item supplies an emoji string and a human-readable name. Use the array as the picker choices:
setEmojis(result.emojis);
setStatus(result.emojis.length ? 'Search results' : 'No matches. Try another word.');The effect's cleanup clears the pending delay and aborts an in-flight request when the query changes or the picker closes. An active flag also ignores a late response if the transport does not stop. The full component limits searches to 80 characters and 24 results, allows eight seconds per request, and offers a retry when a search fails. An empty successful result shows βNo matchesβ instead of an error.
4. Make every emoji a choice
Use POPULAR when the query has fewer than two characters and emojis for search results. Render the choices as buttons, with the returned name as the accessible label:
<ul className="emoji-list" aria-label={query.trim().length < 2 ? 'Popular emojis' : 'Emoji search results'}>
{choices.map((item, index) => <li key={`${item.emoji}-${index}`}>
<button type="button" aria-label={`Insert ${item.name}`} title={item.name} onClick={() => choose(item)}>{item.emoji}</button>
</li>)}
</ul>Native buttons support Tab, Enter, and Space. A person can browse the initial grid or search for a word such as heart. Results only enter the message after an explicit choice. Emoji appearance depends on the device and its fonts.
5. Insert the whole emoji and keep writing
Replace the saved textarea selection with the chosen item's complete emoji string:
export function insertEmoji(message, emoji, start, end) {
return message.slice(0, start) + emoji + message.slice(end);
}An emoji can contain several code points, including a joined family or a skin-tone sequence. Insert the whole returned string. Taking its first character would lose part of the emoji.
Apply the insertion to the message state, close the picker, and put the cursor just after the emoji:
const { start, end } = selection.current;
setMessage(insertEmoji(message, item.emoji, start, end));
setOpen(false);
requestAnimationFrame(() => {
const cursor = start + item.emoji.length;
editor.current?.focus();
editor.current?.setSelectionRange(cursor, cursor);
selection.current = { start: cursor, end: cursor };
});requestAnimationFrame lets React update the textarea before you restore its focus and cursor. The person can keep typing immediately. Your message form can submit this message value as ordinary text, and choosing an emoji makes no additional API request.
Complete example
Use the complete implementation as a reference when adding this feature to your app. It includes the validation and failure handling around the calls above.
View and copy the complete source
// example:imports
import { useEffect, useRef, useState } from 'react';
// /example:imports
const API_KEY = 'YOUR_PUBLIC_API_KEY';
// example:defaults
const INITIAL_MESSAGE = 'Thanks for your help ';
// These are local starting choices. Search results come from ParseAPI.
const POPULAR = [
['π', 'Grinning face'], ['π', 'Smiling face'], ['π', 'Face with tears of joy'],
['π₯°', 'Smiling face with hearts'], ['π', 'Heart eyes'], ['π', 'Smiling face with sunglasses'],
['π₯³', 'Partying face'], ['π€', 'Thinking face'], ['π₯Ή', 'Face holding back tears'],
['π', 'Loudly crying face'], ['π', 'Waving hand'], ['π', 'Raising hands'],
['π', 'Clapping hands'], ['π', 'Thumbs up'], ['π', 'Folded hands'],
['β€οΈ', 'Red heart'], ['π', 'Yellow heart'], ['π', 'Purple heart'],
['π₯', 'Fire'], ['β¨', 'Sparkles'], ['π', 'Party popper'],
['π―', 'Hundred points'], ['β
', 'Check mark'], ['π', 'Rocket'],
].map(([emoji, name]) => ({ emoji, name }));
// /example:defaults
// example:insert
export function insertEmoji(message, emoji, start, end) {
return message.slice(0, start) + emoji + message.slice(end);
}
// /example:insert
const pageStyles = `
:root { color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #18212b; background: #f5f6f8; font-synthesis: none; }
* { box-sizing: border-box; } body { margin: 0; display: block; min-width: 0; } #root { width: 100%; max-width: none; margin: 0; padding: 0; text-align: left; }
@media (prefers-color-scheme: dark) { :root { background: #0c1016; color: #edf1f7; } }
`;
const styles = `
.composer, .composer * { box-sizing: border-box; }
.composer { --paper: #fff; --ink: #18212b; --muted: #66717f; --line: #dce1e8; --field: #f7f8fa; --accent: #2459cb; max-width: 700px; margin: 64px auto; padding: 28px; border: 1px solid var(--line); border-radius: 20px; background: var(--paper); color: var(--ink); }
.composer h1 { margin: 0 0 24px; font-size: 28px; line-height: 1.2; font-weight: 650; letter-spacing: -.035em; }
.composer label { display: block; font-size: 14px; font-weight: 600; margin: 0 0 10px; }
.composer .message-box { border: 1px solid var(--line); border-radius: 14px; background: var(--paper); }
.composer textarea { display: block; width: 100%; min-width: 0; min-height: 120px; resize: vertical; border: 0; border-radius: 14px 14px 0 0; padding: 16px; background: transparent; color: var(--ink); font: inherit; line-height: 1.6; }
.composer .composer-toolbar { position: relative; margin: 0 12px; padding: 4px 0 10px; }
.composer button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 40px; border: 0; border-radius: 8px; padding: 8px; font: inherit; cursor: pointer; color: var(--ink); background: transparent; }
.composer button:hover { background: var(--field); }
.composer .emoji-trigger { font-size: 13px; color: var(--muted); }
.composer .emoji-trigger[aria-expanded=true] { color: var(--ink); background: var(--field); }
.composer :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.composer .emoji-picker { position: absolute; z-index: 30; top: calc(100% + 4px); left: 0; display: flex; flex-direction: column; visibility: hidden; width: 320px; max-width: 100%; padding: 12px; border: 1px solid var(--line); border-radius: 16px; background: var(--paper); box-shadow: 0 12px 40px #0002, 0 2px 8px #0001; }
.composer .emoji-picker[data-placement=above] { top: auto; bottom: calc(100% + 8px); }
.composer .emoji-picker > :not(.emoji-list) { flex-shrink: 0; }
.composer .picker-heading { display: flex; align-items: center; justify-content: space-between; margin: -4px -4px 6px 4px; font-size: 14px; font-weight: 600; }
.composer .picker-heading button { width: 32px; min-height: 32px; padding: 6px; color: var(--muted); }
.composer input { width: 100%; min-width: 0; border: 1px solid var(--line); border-radius: 9px; padding: 10px 12px; background: var(--field); color: var(--ink); font: inherit; font-size: 14px; }
.composer .picker-status { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 32px; margin: 6px 4px 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.composer .picker-status button { min-height: 32px; padding: 4px; color: var(--accent); font-size: 12px; white-space: nowrap; }
.composer .emoji-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(36px, 1fr)); gap: 2px; min-height: 0; max-height: 220px; overflow-y: auto; overscroll-behavior: contain; margin: 0; padding: 4px; list-style: none; }
.composer .emoji-list button { width: 100%; min-height: 40px; padding: 4px; font-family: "Apple Color Emoji", "Segoe UI Emoji", sans-serif; font-size: 25px; line-height: 1.2; }
.composer .visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (max-width: 600px) { .composer { margin: 20px 12px; padding: 20px 16px; border-radius: 16px; } }
@media (prefers-color-scheme: dark) { .composer { --paper: #141b24; --ink: #edf1f7; --muted: #a2afbf; --line: #303b49; --field: #1c2531; --accent: #80aaff; } }
`;
export default function App({ apiKey = API_KEY, embedded = false } = {}) {
// example:state
const [message, setMessage] = useState(INITIAL_MESSAGE);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [emojis, setEmojis] = useState([]);
const editor = useRef(null);
const selection = useRef({ start: INITIAL_MESSAGE.length, end: INITIAL_MESSAGE.length });
function rememberSelection(event) {
selection.current = { start: event.target.selectionStart, end: event.target.selectionEnd };
}
// /example:state
const [status, setStatus] = useState('Popular');
const [error, setError] = useState(false);
const [attempt, setAttempt] = useState(0);
const [announcement, setAnnouncement] = useState('');
const picker = useRef(null);
const trigger = useRef(null);
const searchField = useRef(null);
// example:query
function changeQuery(value) {
const text = value.trim();
setQuery(value); setEmojis([]); setError(false);
if (text.length < 2) setStatus(text ? 'Type at least 2 characters to search.' : 'Popular');
else if (text.length > 80) setStatus('Use 80 characters or fewer.');
else if (!apiKey.startsWith('parse_public_')) setStatus('Add your public API key in App.jsx to search.');
else setStatus('Searchingβ¦');
}
// /example:query
// example:focus
useEffect(() => {
if (!open) return;
function positionPicker() {
const panel = picker.current;
const toolbar = trigger.current?.parentElement;
if (!panel || !toolbar) return;
const bounds = toolbar.getBoundingClientRect();
const below = window.innerHeight - bounds.bottom - 12;
const above = bounds.top - 12;
const placeAbove = below < 360 && above > below;
panel.dataset.placement = placeAbove ? 'above' : 'below';
panel.style.maxHeight = `${Math.max(120, placeAbove ? above : below)}px`;
panel.style.visibility = 'visible';
}
positionPicker();
searchField.current?.focus({ preventScroll: true });
function dismiss(event) {
if (event.type === 'keydown') {
if (event.key !== 'Escape') return;
event.preventDefault();
setOpen(false);
trigger.current?.focus();
} else if (!picker.current?.contains(event.target) && !trigger.current?.contains(event.target)) {
setOpen(false);
}
}
document.addEventListener('click', dismiss);
document.addEventListener('keydown', dismiss);
window.addEventListener('resize', positionPicker);
window.addEventListener('scroll', positionPicker, true);
return () => {
document.removeEventListener('click', dismiss);
document.removeEventListener('keydown', dismiss);
window.removeEventListener('resize', positionPicker);
window.removeEventListener('scroll', positionPicker, true);
};
}, [open]);
// /example:focus
useEffect(() => {
const searchText = query.trim();
if (!open || searchText.length < 2 || searchText.length > 80 || !apiKey.startsWith('parse_public_')) return;
let active = true;
let timedOut = false;
let timeout;
const controller = new AbortController();
// example:debounce
const delay = setTimeout(async () => {
timeout = setTimeout(() => { timedOut = true; controller.abort(); }, 8000);
try {
// example:request
const url = new URL('https://api.parseapi.com/emoji');
url.searchParams.set('q', searchText);
url.searchParams.set('limit', '24');
url.searchParams.set('key', apiKey);
const response = await fetch(url, {
headers: { 'X-API-Version': '2.1.0' },
signal: controller.signal, redirect: 'error', cache: 'no-store'
});
const result = await response.json();
// /example:request
if (!active) return;
if (controller.signal.aborted) throw new Error('request_aborted');
if (!response.ok || !result || !Array.isArray(result.emojis) || result.emojis.length > 24 ||
result.emojis.some(item => !item || typeof item.emoji !== 'string' || !item.emoji || item.emoji.length > 100 ||
typeof item.name !== 'string' || !item.name || item.name.length > 200)) throw new Error('unexpected_response');
// example:results
setEmojis(result.emojis);
setStatus(result.emojis.length ? 'Search results' : 'No matches. Try another word.');
// /example:results
} catch {
if (active) {
setError(true);
setStatus(timedOut ? 'Search timed out.' : 'Search is unavailable.');
}
} finally {
clearTimeout(timeout);
}
}, 250);
return () => {
active = false;
clearTimeout(delay);
clearTimeout(timeout);
controller.abort();
};
// /example:debounce
}, [open, query, apiKey, attempt]);
function choose(item) {
// example:selection
const { start, end } = selection.current;
setMessage(insertEmoji(message, item.emoji, start, end));
setOpen(false);
requestAnimationFrame(() => {
const cursor = start + item.emoji.length;
editor.current?.focus();
editor.current?.setSelectionRange(cursor, cursor);
selection.current = { start: cursor, end: cursor };
});
// /example:selection
setAnnouncement(`Added ${item.name}.`);
}
const choices = query.trim().length < 2 ? POPULAR : emojis;
const Container = embedded ? 'section' : 'main';
return <>
<style>{(embedded ? '' : pageStyles) + styles}</style>
<Container className="composer">
{!embedded && <h1>Write a message</h1>}
<label htmlFor="message">Your message</label>
<div className="message-box">
{/* example:form */}
<textarea id="message" ref={editor} value={message}
onChange={event => setMessage(event.target.value)}
onSelect={rememberSelection} onBlur={rememberSelection} />
{/* /example:form */}
<div className="composer-toolbar">
{/* example:trigger */}
<button ref={trigger} type="button" className="emoji-trigger" aria-label="Add emoji"
aria-expanded={open} aria-haspopup="dialog" aria-controls={open ? 'emoji-picker' : undefined}
onClick={() => { changeQuery(''); setOpen(!open); }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
<circle cx="12" cy="12" r="9" /><path d="M8 14a4 4 0 0 0 8 0" /><path d="M8 9h.01M16 9h.01" strokeWidth="3" strokeLinecap="round" />
</svg>
Emoji
</button>
{/* /example:trigger */}
{open && <div id="emoji-picker" ref={picker} className="emoji-picker" role="dialog" aria-label="Emoji picker">
<div className="picker-heading">
<span>Emoji</span>
<button type="button" aria-label="Close emoji picker" onClick={() => { setOpen(false); trigger.current?.focus(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6" /></svg>
</button>
</div>
{/* example:search */}
<label className="visually-hidden" htmlFor="emoji-search">Search emoji</label>
<input id="emoji-search" ref={searchField} type="search" autoComplete="off" placeholder="Search emoji"
value={query} onChange={event => changeQuery(event.target.value)} maxLength={80} />
{/* /example:search */}
<div className="picker-status">
<span role="status" aria-live="polite">{status}</span>
{error && <button type="button" onClick={() => { setError(false); setStatus('Searchingβ¦'); setAttempt(value => value + 1); }}>Try again</button>}
</div>
{/* example:picker */}
<ul className="emoji-list" aria-label={query.trim().length < 2 ? 'Popular emojis' : 'Emoji search results'}>
{choices.map((item, index) => <li key={`${item.emoji}-${index}`}>
<button type="button" aria-label={`Insert ${item.name}`} title={item.name} onClick={() => choose(item)}>{item.emoji}</button>
</li>)}
</ul>
{/* /example:picker */}
</div>}
</div>
</div>
<span className="visually-hidden" role="status" aria-live="polite">{announcement}</span>
</Container>
</>;
}
Try the complete example on your computer
Follow the React project setup in the lesson and replace src/App.jsx with the downloaded component. Use your public key and add localhost to its allowed domains. From the project folder:
npm run dev -- --host localhost --port 5173 --strictPortOpen localhost:5173 and try the feature.
For every field and parameter, see Emoji API reference.