Emoji Picker Rails Component
Add emoji selection functionality to forms and inputs with a beautiful, accessible emoji picker. Perfect for reactions, comments, and social features.
Installation
1. Stimulus Controller Setup
Start by adding the following controller to your project:
import { Controller } from "@hotwired/stimulus";
import "emoji-mart"; // Use this if you installed emoji-mart with importmap
// import { Picker } from "emoji-mart"; // Use this if you installed emoji-mart with npm or yarn
import data from "@emoji-mart/data";
export default class extends Controller {
static targets = ["input", "pickerContainer", "button"]; // Targets for the input field, picker container, and button
static values = { autoSubmit: { type: Boolean, default: false } }; // Whether to automatically submit the form when an emoji is selected
connect() {
// Prevent duplicate initialization
if (this.element.dataset.emojiPickerInitialized === "true") {
return;
}
// Mark as initialized
this.element.dataset.emojiPickerInitialized = "true";
// Use setTimeout to ensure DOM is fully rendered after Turbo navigation
setTimeout(() => {
this.initializePicker();
this.setupEventListeners();
this.findForm();
}, 0);
}
findForm() {
// Find the closest form element
this.form = this.element.closest("form") || this.element.querySelector("form");
}
setupEventListeners() {
// Prevent duplicate event listeners
if (this.eventListenersSetup) {
return;
}
this.eventListenersSetup = true;
// Add document click event listener to handle outside clicks
this.outsideClickHandler = this.handleOutsideClick.bind(this);
document.addEventListener("click", this.outsideClickHandler);
// Add keyboard event listener for escape key and navigation
this.keydownHandler = this.handleKeydown.bind(this);
document.addEventListener("keydown", this.keydownHandler);
// Add keydown listener to button for backspace/delete functionality
if (this.hasButtonTarget) {
this.buttonKeydownHandler = this.handleButtonKeydown.bind(this);
this.buttonTarget.addEventListener("keydown", this.buttonKeydownHandler);
// Make button focusable
this.buttonTarget.setAttribute("tabindex", "0");
// Add tooltip hint for backspace functionality
this.buttonTarget.setAttribute("title", "Press Backspace or Delete to remove emoji");
}
}
handleButtonKeydown(event) {
// Delete emoji if backspace or delete is pressed
if (event.key === "Backspace" || event.key === "Delete") {
event.preventDefault();
this.clearEmoji();
}
}
clearEmoji() {
if (this.hasInputTarget) {
// Clear the input value
this.inputTarget.value = "";
// Update button HTML to show default icon
if (this.hasButtonTarget) {
this.updateButtonToDefault();
}
// Submit the form to save the change only if auto-submit is enabled
if (this.autoSubmitValue && this.form) {
this.form.requestSubmit();
}
}
}
updateButtonToDefault() {
// Default emoji face icon
const iconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="size-5" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M9,1C4.589,1,1,4.589,1,9s3.589,8,8,8,8-3.589,8-8S13.411,1,9,1Zm-4,7c0-.552,.448-1,1-1s1,.448,1,1-.448,1-1,1-1-.448-1-1Zm4,6c-1.531,0-2.859-1.14-3.089-2.651-.034-.221,.039-.444,.193-.598,.151-.15,.358-.217,.572-.185,1.526,.24,3.106,.24,4.638,.001h0c.217-.032,.428,.036,.583,.189,.153,.153,.225,.373,.192,.589-.229,1.513-1.557,2.654-3.089,2.654Zm3-5c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Z"></path></g></svg>`;
this.buttonTarget.innerHTML = iconHtml;
}
initializePicker() {
try {
// Check if we have the pickerContainer target
if (!this.hasPickerContainerTarget) {
return;
}
// Prevent duplicate picker initialization
if (this.picker) {
return;
}
// Use imported emoji data instead of CDN to avoid network issues
this.picker = new EmojiMart.Picker({
data: data,
onEmojiSelect: this.onEmojiSelect.bind(this),
emojiButtonColors: "#FF9E66",
emojiVersion: 15,
previewPosition: "none",
dynamicWidth: true,
});
this.pickerContainerTarget.innerHTML = "";
this.pickerContainerTarget.appendChild(this.picker);
} catch (error) {
console.error("Error initializing emoji picker:", error);
}
}
toggle(event) {
event.preventDefault();
event.stopPropagation(); // Prevent this click from being caught by the document listener
try {
// Check if we have the pickerContainer target
if (!this.hasPickerContainerTarget) {
return;
}
this.pickerContainerTarget.classList.toggle("hidden");
} catch (error) {
console.error("Error toggling emoji picker:", error);
}
}
handleOutsideClick(event) {
// If picker is visible and click is outside picker and outside the toggle button
if (
this.hasPickerContainerTarget &&
!this.pickerContainerTarget.classList.contains("hidden") &&
!this.pickerContainerTarget.contains(event.target) &&
(!this.hasButtonTarget || !this.buttonTarget.contains(event.target))
) {
this.pickerContainerTarget.classList.add("hidden");
}
}
handleKeydown(event) {
// Close picker when Escape key is pressed
if (
event.key === "Escape" &&
this.hasPickerContainerTarget &&
!this.pickerContainerTarget.classList.contains("hidden")
) {
this.pickerContainerTarget.classList.add("hidden");
return;
}
// Focus on search input when picker is open and navigation/typing occurs
if (
this.hasPickerContainerTarget &&
!this.pickerContainerTarget.classList.contains("hidden") &&
this.shouldFocusSearch(event)
) {
this.focusSearchInput();
}
}
shouldFocusSearch(event) {
// Check for arrow keys
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) {
return true;
}
// Check for typing (alphanumeric characters, space, etc.)
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
return true;
}
return false;
}
focusSearchInput() {
try {
// The emoji picker uses shadow DOM, so we need to access the search input through it
if (this.picker && this.picker.shadowRoot) {
const searchInput = this.picker.shadowRoot.querySelector('input[type="search"]');
if (searchInput) {
searchInput.focus();
}
}
} catch (error) {
console.error("Error focusing search input:", error);
}
}
onEmojiSelect(emoji) {
try {
if (!this.hasInputTarget) {
return;
}
// Set the input field value
this.inputTarget.value = emoji.native;
// Update the button with the selected emoji
if (this.hasButtonTarget) {
this.buttonTarget.innerHTML = `<span class="size-6 text-xl shrink-0 flex items-center justify-center">${emoji.native}</span>`;
}
if (this.hasPickerContainerTarget) {
this.pickerContainerTarget.classList.add("hidden");
}
// Submit the form if it exists and auto-submit is enabled
if (this.autoSubmitValue && this.form) {
this.form.requestSubmit();
}
} catch (error) {
console.error("Error selecting emoji:", error);
}
}
disconnect() {
// Clean up initialization flag
if (this.element) {
delete this.element.dataset.emojiPickerInitialized;
}
// Clean up event listeners when controller disconnects
if (this.outsideClickHandler) {
document.removeEventListener("click", this.outsideClickHandler);
this.outsideClickHandler = null;
}
if (this.keydownHandler) {
document.removeEventListener("keydown", this.keydownHandler);
this.keydownHandler = null;
}
// Remove button keydown listener if it exists
if (this.hasButtonTarget && this.buttonKeydownHandler) {
this.buttonTarget.removeEventListener("keydown", this.buttonKeydownHandler);
this.buttonKeydownHandler = null;
}
// Clean up picker
if (this.picker && this.hasPickerContainerTarget && this.pickerContainerTarget.contains(this.picker)) {
this.pickerContainerTarget.removeChild(this.picker);
this.picker = null;
}
// Reset flags
this.eventListenersSetup = false;
}
}
2. Emoji Mart Installation
The emoji picker component relies on Emoji Mart for the emoji picker interface. Choose your preferred installation method:
pin "emoji-mart", to: "https://cdn.jsdelivr.net/npm/emoji-mart@latest/dist/browser.js"
npm install emoji-mart
yarn add emoji-mart
3. Custom Styling
Add custom CSS to style the emoji picker & match your design system:
/* Emoji Picker Styles */
em-emoji-picker {
--color-border-over: rgba(0, 0, 0, 0.1);
--color-border: rgba(0, 0, 0, 0.05);
--font-family: "Inter", sans-serif;
--rgb-accent: 155, 155, 155;
position: absolute;
z-index: 1000;
max-width: 400px;
min-width: 300px;
resize: horizontal;
overflow: auto;
}
@media (max-width: 768px) {
em-emoji-picker {
max-width: 80vw;
}
}
Examples
Basic emoji picker
A simple emoji picker with a button trigger an a hidden input field for storing the selected emoji.
<div data-controller="emoji-picker">
<div class="flex items-center gap-2">
<button type="button" class="outline-none size-8 text-xl shrink-0 flex items-center justify-center rounded-md text-neutral-700 hover:bg-neutral-100 hover:text-neutral-800 focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:hover:text-neutral-200" data-action="click->emoji-picker#toggle" data-emoji-picker-target="button">
<svg xmlns="http://www.w3.org/2000/svg" class="size-5" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M9,1C4.589,1,1,4.589,1,9s3.589,8,8,8,8-3.589,8-8S13.411,1,9,1Zm-4,7c0-.552,.448-1,1-1s1,.448,1,1-.448,1-1,1-1-.448-1-1Zm4,6c-1.531,0-2.859-1.14-3.089-2.651-.034-.221,.039-.444,.193-.598,.151-.15,.358-.217,.572-.185,1.526,.24,3.106,.24,4.638,.001h0c.217-.032,.428,.036,.583,.189,.153,.153,.225,.373,.192,.589-.229,1.513-1.557,2.654-3.089,2.654Zm3-5c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Z"></path></g></svg>
</button>
<input type="text" name="emoji" class="hidden" placeholder="Select an emoji..." data-emoji-picker-target="input">
</div>
<div data-emoji-picker-target="pickerContainer" class="hidden absolute z-50 mt-2 flex justify-center inset-x-0"></div>
</div>
Auto-submit control
Compare auto-submit enabled (default) vs disabled behavior. When disabled, forms require manual submission.
Auto-submit enabled (default)
Auto-submit disabled
<div class="space-y-6">
<!-- Auto-submit enabled (default behavior) -->
<div class="space-y-2">
<h4 class="text-sm font-medium text-neutral-700 dark:text-neutral-300">Auto-submit enabled (default)</h4>
<form class="p-4 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-neutral-50 dark:bg-neutral-800">
<div class="relative gap-y-1.5" data-controller="emoji-picker" data-emoji-picker-auto-submit-value="true">
<label for="reaction_1" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Reaction (auto-submits)</label>
<div class="flex items-center gap-2">
<button type="button" class="outline-none size-8 text-xl shrink-0 flex items-center justify-center rounded-md text-neutral-700 hover:bg-neutral-100 hover:text-neutral-800 focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:hover:text-neutral-200" data-action="click->emoji-picker#toggle" data-emoji-picker-target="button">
<svg xmlns="http://www.w3.org/2000/svg" class="size-6" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M9,1C4.589,1,1,4.589,1,9s3.589,8,8,8,8-3.589,8-8S13.411,1,9,1Zm-4,7c0-.552,.448-1,1-1s1,.448,1,1-.448,1-1,1-1-.448-1-1Zm4,6c-1.531,0-2.859-1.14-3.089-2.651-.034-.221,.039-.444,.193-.598,.151-.15,.358-.217,.572-.185,1.526,.24,3.106,.24,4.638,.001h0c.217-.032,.428,.036,.583,.189,.153,.153,.225,.373,.192,.589-.229,1.513-1.557,2.654-3.089,2.654Zm3-5c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Z"></path></g></svg>
</button>
<input type="text" id="reaction_1" name="reaction" autocomplete="off" class="form-control" placeholder="Choose an emoji..." data-emoji-picker-target="input">
</div>
<div data-emoji-picker-target="pickerContainer" class="hidden absolute z-50 mt-2 flex justify-start inset-x-0"></div>
</div>
</form>
</div>
<!-- Auto-submit disabled -->
<div class="space-y-2">
<h4 class="text-sm font-medium text-neutral-700 dark:text-neutral-300">Auto-submit disabled</h4>
<form class="p-4 border border-neutral-200 dark:border-neutral-700 rounded-lg bg-neutral-50 dark:bg-neutral-800">
<div class="space-y-4">
<div class="mb-4 relative gap-y-1.5" data-controller="emoji-picker" data-emoji-picker-auto-submit-value="false">
<label for="reaction_2" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Reaction (manual submit)</label>
<div class="flex items-center gap-2">
<button type="button" class="outline-none size-8 text-xl shrink-0 flex items-center justify-center rounded-md text-neutral-700 hover:bg-neutral-100 hover:text-neutral-800 focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:hover:text-neutral-200" data-action="click->emoji-picker#toggle" data-emoji-picker-target="button">
<svg xmlns="http://www.w3.org/2000/svg" class="size-6" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M9,1C4.589,1,1,4.589,1,9s3.589,8,8,8,8-3.589,8-8S13.411,1,9,1Zm-4,7c0-.552,.448-1,1-1s1,.448,1,1-.448,1-1,1-1-.448-1-1Zm4,6c-1.531,0-2.859-1.14-3.089-2.651-.034-.221,.039-.444,.193-.598,.151-.15,.358-.217,.572-.185,1.526,.24,3.106,.24,4.638,.001h0c.217-.032,.428,.036,.583,.189,.153,.153,.225,.373,.192,.589-.229,1.513-1.557,2.654-3.089,2.654Zm3-5c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Z"></path></g></svg>
</button>
<input type="text" id="reaction_2" name="reaction" autocomplete="off" class="form-control" placeholder="Choose an emoji..." data-emoji-picker-target="input">
</div>
<div data-emoji-picker-target="pickerContainer" class="hidden absolute z-50 mt-2 flex justify-start inset-x-0"></div>
</div>
<button type="submit" class="w-full flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3 py-2 text-xs font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">
Send
</button>
</div>
</form>
</div>
</div>
Configuration
The emoji picker component uses Emoji Mart for the picker interface and provides keyboard navigation, form integration, and customizable styling through a Stimulus controller.
Controller Setup
Basic emoji picker structure with required data attributes:
<div data-controller="emoji-picker">
<button type="button" data-action="click->emoji-picker#toggle" data-emoji-picker-target="button">
<!-- Emoji icon -->
</button>
<input type="text" data-emoji-picker-target="input">
<div data-emoji-picker-target="pickerContainer" class="hidden"></div>
</div>
Configuration Values
Prop | Description | Type | Default |
---|---|---|---|
autoSubmit
|
Controls whether the form is automatically submitted when an emoji is selected or cleared |
Boolean
|
false
|
Targets
Target | Description | Required |
---|---|---|
button
|
The button element that triggers the emoji picker | Required |
input
|
The input field that stores the selected emoji value | Required |
pickerContainer
|
The container element where the emoji picker is rendered | Required |
Actions
Action | Description | Usage |
---|---|---|
toggle
|
Toggles the visibility of the emoji picker interface |
click->emoji-picker#toggle
|
Features
- Keyboard Navigation: Escape key closes picker, Backspace/Delete clears selected emoji
- Outside Click Detection: Automatically closes picker when clicking outside
- Form Integration: Optional auto-submit when emoji is selected (enabled by default)
- Visual Feedback: Button updates to show selected emoji
- Customizable Styling: CSS custom properties for theming
Browser Support
- Modern Browsers: Full support in all modern browsers with ES6+ support
- Emoji Mart: Requires modern browser support for custom elements
Accessibility Features
- Keyboard Accessible: Full keyboard navigation support
- Focus Management: Proper focus handling and visual indicators