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:

config/importmap.rb
pin "emoji-mart", to: "https://cdn.jsdelivr.net/npm/emoji-mart@latest/dist/browser.js"
Terminal
npm install emoji-mart
Terminal
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

Table of contents