Selector de reacciones con aumento
Mantén o enfoca el botón para abrir la barra de emojis, que crecen con el puntero; flechas y Enter funcionan igual.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
/** Selector de reacciones: al mantener o enfocar el botón se abre una barra de emojis que crecen con el puntero; teclas de flecha y Enter funcionan igual. React + TypeScript, sin dependencias. */
export type ReaccionesEmojiProps = {
reactions?: { id: string; emoji: string; label: string }[];
onReact?: (id: string | null) => void;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcmre-root { box-sizing: border-box; position: relative; display: inline-block; font: 600 15px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; color: #3a3f4b; }
.gcmre-root *, .gcmre-root *::before, .gcmre-root *::after { box-sizing: border-box; }
.gcmre-root button { font: inherit; -webkit-tap-highlight-color: transparent; }
.gcmre-root :focus-visible { outline: 3px solid #2563eb; outline-offset: 3px; border-radius: 999px; }
@media (prefers-reduced-motion: reduce) { .gcmre-root * { transition-duration: .01ms !important; animation: none !important; } }
.gcmre-main { display: inline-flex; align-items: center; gap: 8px; min-height: 46px; padding: 0 20px; border: 1px solid #dfe3ec; border-radius: 999px; background: #fff; color: inherit; cursor: pointer; box-shadow: 0 1px 2px rgba(20, 30, 50, .08); transition: background .2s; }
.gcmre-main:hover { background: #f5f7fb; }
.gcmre-main[data-on="true"] { color: #1f4fd8; border-color: #b7c8ff; background: #eef3ff; }
.gcmre-bar { position: absolute; left: 0; bottom: calc(100% + 10px); display: flex; gap: 2px; padding: 6px 8px; border-radius: 999px; background: #fff; box-shadow: 0 10px 28px -8px rgba(20, 30, 50, .35), 0 0 0 1px #e6e9f2; transform-origin: 20% 100%; animation: gcmre-in .25s cubic-bezier(.34, 1.4, .64, 1); }
.gcmre-r { display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%; background: none; font-size: 28px; line-height: 1; cursor: pointer; transform-origin: 50% 100%; transition: transform .2s cubic-bezier(.34, 1.56, .64, 1); }
.gcmre-r:hover, .gcmre-r:focus-visible { transform: scale(1.5) translateY(-6px); }
.gcmre-r:hover + .gcmre-r, .gcmre-r:has(+ .gcmre-r:hover) { transform: scale(1.2) translateY(-2px); }
@keyframes gcmre-in { from { opacity: 0; transform: translateY(8px) scale(.9); } }`;
const DEFAULT = [
{ id: "like", emoji: "👍", label: "Me gusta" },
{ id: "love", emoji: "❤️", label: "Me encanta" },
{ id: "haha", emoji: "😂", label: "Me divierte" },
{ id: "wow", emoji: "😮", label: "Me sorprende" },
{ id: "sad", emoji: "😢", label: "Me entristece" },
];
export function ReaccionesEmoji({ reactions = DEFAULT, onReact, className = "", style }: ReaccionesEmojiProps) {
const [open, setOpen] = useState(false);
const [chosen, setChosen] = useState<string | null>(null);
const timer = useRef(0);
const barRef = useRef<HTMLDivElement>(null);
const current = reactions.find((reaction) => reaction.id === chosen);
function show() {
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setOpen(true), 120);
}
function hide() {
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setOpen(false), 260);
}
function pick(id: string) {
const next = chosen === id ? null : id;
setChosen(next);
onReact?.(next);
setOpen(false);
}
function mainKey(event: KeyboardEvent) {
if (event.key === "ArrowUp" || event.key === "ArrowRight") {
event.preventDefault();
setOpen(true);
requestAnimationFrame(() => barRef.current?.querySelector<HTMLButtonElement>("button")?.focus());
}
}
function barKey(event: KeyboardEvent) {
const buttons = Array.from(barRef.current?.querySelectorAll<HTMLButtonElement>("button") ?? []);
const i = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (event.key === "ArrowRight") { event.preventDefault(); buttons[(i + 1) % buttons.length]?.focus(); }
if (event.key === "ArrowLeft") { event.preventDefault(); buttons[(i - 1 + buttons.length) % buttons.length]?.focus(); }
if (event.key === "Escape") { setOpen(false); (barRef.current?.parentElement?.querySelector(".gcmre-main") as HTMLButtonElement | null)?.focus(); }
}
return (
<span className={`gcmre-root ${className}`} style={style} onPointerEnter={show} onPointerLeave={hide}>
<style>{CSS}</style>
{open ? (
<div ref={barRef} className="gcmre-bar" role="group" aria-label="Reacciones" onKeyDown={barKey} onPointerEnter={() => window.clearTimeout(timer.current)}>
{reactions.map((reaction) => (
<button key={reaction.id} className="gcmre-r" type="button" aria-label={reaction.label} title={reaction.label} onClick={() => pick(reaction.id)}>
{reaction.emoji}
</button>
))}
</div>
) : null}
<button className="gcmre-main" type="button" data-on={chosen ? "true" : undefined} aria-haspopup="true" aria-expanded={open} onClick={() => pick(chosen ?? reactions[0].id)} onKeyDown={mainKey}>
<span aria-hidden="true" style={{ fontSize: 20 }}>{current ? current.emoji : "👍"}</span>
{current ? current.label : "Reaccionar"}
</button>
</span>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/reacciones-emojiEjemplo de uso de Selector de reacciones con aumento
Después de copiar reacciones-emoji.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { ReaccionesEmoji } from "./reacciones-emoji";
export default function Ejemplo() {
return <ReaccionesEmoji />;
}Propiedades de ReaccionesEmoji
ReaccionesEmoji acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
reactions? | { id: string; emoji: string; label: string }[] | Opcional. |
onReact? | (id: string | null) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 91 líneas (5 KB) y define 1 animación con @keyframes. Esto es lo que hace Selector de reacciones con aumento por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: group.
- Responde al teclado, no solo al ratón.
- Anima con requestAnimationFrame, sincronizado con el refresco de pantalla.
- Es un componente de cliente de Next.js: incluye la directiva "use client".