Adding emoji support to a comment box, review form, or contact textarea sounds like it should require a dedicated library. In practice, if you already have Alpine.js and Tailwind CSS in your project, you can build something genuinely polished in about 50 lines of HTML with no extra dependencies. This tutorial walks through every decision, from the data model to cursor-aware insertion, so you understand what you're shipping rather than just copy-pasting a black box.
The finished component lets a user type into a textarea, click any emoji in a categorized panel, and have that emoji inserted at their current cursor position — not tacked on at the end. The panel opens and closes with a toggle button, animates in with Alpine's x-transition, and closes when the user clicks outside. All state lives in a single x-data object. No document.querySelector, no global variables.
What you need before you start
This guide assumes Tailwind CSS v4 and Alpine.js 3.x. If you are on Tailwind v3, the utility class names are the same — only the configuration mechanism differs. Alpine.js 3.x has been stable since 2021 and the directives used here (x-data, x-show, x-model, x-for, x-transition, @click, @click.outside) have not changed in any meaningful way since then.
Include Alpine via CDN or your bundler:
<!-- CDN, drop in <head> -->
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
And your Tailwind CSS v4 entry file needs just one import — content detection is automatic in v4:
/* main.css */
@import "tailwindcss";
Designing the data model
Before writing any markup, decide what your x-data object needs to hold:
- text — the textarea's current value, bound with
x-model. - open — boolean that controls whether the emoji panel is visible.
- activeCategory — the currently selected emoji category tab.
- categories — an array of category objects, each with a label, an icon, and an array of emoji strings.
Cursor-aware insertion requires one method: insertEmoji(emoji). It reads selectionStart and selectionEnd from the textarea DOM node, splices the emoji in at that position, then resets the cursor to just after the inserted character. To reach the DOM node from an Alpine method, you pass a reference using Alpine's $refs magic property.
The complete component
Here is the full, copy-pasteable component. Everything is in one div with x-data. Read through it, then the sections below explain each part.
<div
x-data="{
text: '',
open: false,
activeCategory: 'smileys',
categories: [
{
id: 'smileys',
label: 'Smileys',
icon: '😀',
emojis: ['😀','😂','😍','🥰','😎','🤔','😢','😡','🥳','🤩','😴','🤯','😇','🤗','😬','🙃']
},
{
id: 'nature',
label: 'Nature',
icon: '🌿',
emojis: ['🌿','🌸','🌻','🍀','🌊','⛰️','🌙','☀️','❄️','🌈','🐝','🦋','🌺','🍁','🐦','🌴']
},
{
id: 'food',
label: 'Food',
icon: '🍕',
emojis: ['🍕','🍔','🌮','🍜','🍣','🍦','☕','🍺','🥑','🍓','🍩','🧁','🥐','🍇','🌽','🥗']
},
{
id: 'activities',
label: 'Activities',
icon: '⚽',
emojis: ['⚽','🏀','🎸','🎮','🏋️','🚴','🎨','📚','✈️','🏕️','🎯','🎲','🎤','🏆','🎹','🧩']
},
{
id: 'symbols',
label: 'Symbols',
icon: '❤️',
emojis: ['❤️','🔥','✨','💯','🎉','👍','👎','🙌','👏','🤝','💪','🫶','⭐','💡','🔑','🚀']
}
],
get currentEmojis() {
return this.categories.find(c => c.id === this.activeCategory)?.emojis ?? [];
},
insertEmoji(emoji) {
const el = this.\$refs.textarea;
const start = el.selectionStart;
const end = el.selectionEnd;
this.text = this.text.slice(0, start) + emoji + this.text.slice(end);
this.\$nextTick(() => {
el.focus();
el.setSelectionRange(start + emoji.length, start + emoji.length);
});
}
}"
@click.outside="open = false"
class="relative w-full max-w-xl"
>
<!-- Textarea -->
<textarea
x-ref="textarea"
x-model="text"
rows="5"
placeholder="Write something…"
class="w-full resize-none rounded-xl border border-neutral-200 bg-white px-4 py-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
></textarea>
<!-- Toolbar: emoji toggle + character count -->
<div class="mt-2 flex items-center justify-between">
<button
@click="open = !open"
type="button"
class="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-neutral-500 hover:bg-neutral-100 hover:text-neutral-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
:aria-expanded="open"
aria-controls="emoji-panel"
>
<span aria-hidden="true">😀</span>
<span>Emoji</span>
</button>
<span class="text-xs text-neutral-400" x-text="text.length + ' chars'"></span>
</div>
<!-- Emoji panel -->
<div
id="emoji-panel"
x-show="open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95 -translate-y-1"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100 translate-y-0"
x-transition:leave-end="opacity-0 scale-95 -translate-y-1"
class="absolute bottom-full left-0 z-20 mb-2 w-80 rounded-xl border border-neutral-200 bg-white shadow-lg"
>
<!-- Category tabs -->
<div class="flex gap-0.5 border-b border-neutral-100 px-2 pt-2">
<template x-for="cat in categories" :key="cat.id">
<button
type="button"
@click="activeCategory = cat.id"
:aria-selected="activeCategory === cat.id"
:class="activeCategory === cat.id
? 'border-b-2 border-blue-500 text-blue-600'
: 'text-neutral-400 hover:text-neutral-700'"
class="flex items-center justify-center rounded-t px-3 pb-2 pt-1 text-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
:title="cat.label"
:aria-label="cat.label"
x-text="cat.icon"
></button>
</template>
</div>
<!-- Emoji grid -->
<div class="grid grid-cols-8 gap-0.5 p-2">
<template x-for="emoji in currentEmojis" :key="emoji">
<button
type="button"
@click="insertEmoji(emoji); open = false"
x-text="emoji"
class="flex aspect-square items-center justify-center rounded text-xl hover:bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
:aria-label="emoji"
></button>
</template>
</div>
</div>
</div>
How cursor-aware insertion works
The naive approach — this.text += emoji — always appends the emoji to the end of the string. That is fine for a simple demo but annoying in real use: if someone has their cursor in the middle of a sentence and clicks an emoji, the emoji jumps to the end rather than landing where they expected.
The insertEmoji method avoids this by reading the textarea's selectionStart and selectionEnd before modifying this.text. It splices the emoji in between those two indices (which handles replacing selected text too). Then it uses Alpine's $nextTick to wait for the DOM to reflect the updated text value before calling setSelectionRange — if you call setSelectionRange synchronously, the DOM hasn't applied the new value yet and the cursor lands in the wrong place.
insertEmoji(emoji) {
const el = this.$refs.textarea;
const start = el.selectionStart;
const end = el.selectionEnd;
// splice: everything before cursor + emoji + everything after selection
this.text = this.text.slice(0, start) + emoji + this.text.slice(end);
this.$nextTick(() => {
el.focus();
el.setSelectionRange(start + emoji.length, start + emoji.length);
});
}
The x-ref="textarea" attribute on the textarea element makes this.$refs.textarea available inside any Alpine method in the same x-data scope. This is cleaner than document.getElementById because it stays scoped to the component and survives page-level changes without breaking.
The category tab pattern
Rather than hardcoding one flat list of emojis, the component uses a categories array with a computed getter currentEmojis. The getter finds the active category by id and returns its emojis array. Alpine treats get accessors defined inside x-data objects as reactive computed properties — currentEmojis re-evaluates automatically whenever activeCategory changes, with no manual wiring required.
The category tab row uses x-for to render one button per category. The active-tab highlight is applied with a dynamic :class binding that switches between a blue underline style and a neutral default. The use of :aria-selected on the tab buttons and :aria-expanded on the toggle makes the component readable by screen readers without extra ARIA scaffolding.
Handling close-on-outside-click
Alpine provides the .outside modifier on event listeners. Placing @click.outside="open = false" on the component's root element means any click that falls outside the element's bounding box will set open to false. This is the idiomatic Alpine pattern for dropdown dismissal — no document.addEventListener, no cleanup needed.
One thing to be aware of: @click.outside only fires on click, not on keyboard navigation. If your form is keyboard-heavy, also add @keydown.escape.window="open = false" on the same root element so pressing Escape closes the panel.
Animating with x-transition
Alpine's x-transition directive hooks into the show/hide cycle managed by x-show. The six modifier attributes used in the component — :enter, :enter-start, :enter-end, :leave, :leave-start, :leave-end — each accept a space-separated list of CSS classes that are applied at specific moments in the transition:
:enterand:leaveset the base transition properties (duration, easing).:enter-start/:leave-endare the "hidden" keyframe — opacity 0, slightly scaled down and shifted up.:enter-end/:leave-startare the "visible" keyframe — opacity 1, full size, at normal position.
Alpine applies these classes, triggers a browser reflow, then removes them, letting the CSS transition property do the actual animation work. The result is a gentle scale-and-fade that feels intentional without being showy.
Extending the component
A few directions worth considering once the base works:
Skin tone support
Some emoji have skin tone variants (e.g., 👍🏽). You can add a skinTone property to x-data and a modifier selector that appends the appropriate Unicode modifier codepoint before inserting. The five Fitzpatrick modifier codepoints are 🏻 through 🏿 (light through dark).
Persisting recently used emoji
Add a recent array to x-data and prepend to it inside insertEmoji. Persist it with localStorage inside an x-init block. This gives you a "Recent" category for free.
Emoji search
Add a search string to x-data and update currentEmojis to filter against a flat lookup object that maps emoji strings to keyword arrays. Keep the keyword data small — a few words per emoji is enough for practical filtering.
Form submission
Because the textarea uses x-model, the text property always mirrors its value. On submit, read text directly from your Alpine component using $data.text, or let a standard form POST pick it up normally since x-model writes back to the actual value attribute.
Wrapping up
The finished component is about 110 lines of HTML and zero JavaScript files. Alpine handles all the state transitions, and Tailwind provides the visual polish without a single custom CSS declaration. The cursor-aware insertion via $refs and $nextTick is the one genuinely tricky part — everything else is straightforward binding. If you find yourself reaching for a library like emoji-picker-element, it is absolutely a fine choice for larger feature sets (skin tones, search, full Unicode dataset), but for a focused comment or review form, the approach above keeps your bundle small and your dependencies count at zero.