Ir al contenido
Evaluar mi proyecto
Componentes · MicrointeraccionesDisponible

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.

reacciones-emoji.tsx91 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/reacciones-emoji

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
reactions?{ id: string; emoji: string; label: string }[]Opcional.
onReact?(id: string | null) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)