import { useEffect, useRef, useState } from "react";
export function ProfileMenu({ src }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const onPointerDown = (e) => {
if (!ref.current?.contains(e.target)) setOpen(false);
};
const onKeyDown = (e) => e.key === "Escape" && setOpen(false);
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={open}
className="rounded-full transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-95"
>
<img src={src} alt="Profile" className="size-9 rounded-full" />
</button>
<div
role="menu"
data-state={open ? "open" : "closed"}
className={[
"absolute right-0 top-full mt-2 w-44 origin-top-right rounded-lg border bg-white p-1 shadow-lg",
"transition-[opacity,transform,visibility] ease-[cubic-bezier(0.23,1,0.32,1)]",
open
? "visible scale-100 opacity-100 duration-200"
: "invisible pointer-events-none scale-95 opacity-0 duration-150",
// reduced motion: keep the fade, drop the scale
"motion-reduce:scale-100",
].join(" ")}
>
{["Profile", "Settings", "Log out"].map((label) => (
<button
key={label}
role="menuitem"
tabIndex={open ? 0 : -1}
onClick={() => setOpen(false)}
className="block w-full rounded-md px-3 py-2 text-left text-sm hover:bg-gray-100"
>
{label}
</button>
))}
</div>
</div>
);
}
If you'd rather use a gated hover, swap hover:bg-gray-100 for [@media(hover:hover)]:hover:bg-gray-100. The color change is subtle enough that I left it plain.
Gate: An avatar click happens occasionally and isn't keyboard-driven, so a standard animation is fine. The purpose is spatial consistency: the menu should look like it came out of the avatar.
Ingredients:
- Tool: CSS transitions toggled by state. The menu stays mounted, so no exit-animation library is needed. Transitions also retarget if the user double-clicks, where keyframes would restart.
- Properties:
opacityandtransform: scale(0.95 → 1), neverscale(0).visibilityflips at the end of the transition, which keeps the closed menu out of the tab order and screen readers without cutting off the exit. - Origin:
origin-top-right, assuming the avatar sits at the right edge of the header. Useorigin-top-leftif it's on the left. - Curve and timing:
cubic-bezier(0.23, 1, 0.32, 1), 200ms to open and 150ms to close. Closing is a bit faster because the user has already decided. - Reduced motion:
motion-reduce:scale-100drops the scale and keeps the fade.
Feel-check: Play it at 3–5× duration in the DevTools animation inspector. Confirm the menu grows out of the avatar corner and that rapid open/close clicks reverse smoothly.
This is hand-rolled, so it lacks arrow-key navigation and focus return to the avatar. If you want those, a headless primitive like Radix or Base UI gives you them, and the same classes work on its data-[state] attributes.
Test run: a profile dropdown in React and Tailwind with no animation library installed. The model read the Skill's RECIPES.md, chose CSS transitions and gave the gate, ingredients and a feel-check. The code was not run in a browser. The model noted that the hand-rolled menu lacks arrow-key navigation and focus return.
Qué hace
Convierte una petición de movimiento en una implementación que superaría una revisión estricta. Ejecuta una secuencia de construcción: un filtro de frecuencia (sin animación para cosas que se usan cientos de veces al día), un propósito declarado (feedback, coherencia espacial, estado, evitar un cambio brusco, explicación, deleite), la herramienta de menor coste que funcione (transición CSS, @starting-style, animación CSS, WAAPI, Motion), las propiedades (solo transform y opacity, nunca scale desde cero), después easing y duración a partir de tablas fijas o de un spring, comportamiento de interrupción y salida, y condicionamiento según reduced-motion y hover. Incluye una tabla «never ship» y un archivo RECIPES para casos comunes como botones, menús desplegables, tooltips, modales, paneles laterales, toasts, acordeones e indicadores de pestaña.
Salida
El código y, después, unas líneas sobre el resultado del filtro, los ingredientes y qué comprobar en cuanto a sensación.
Ideal para
Desarrolladores que añaden movimiento a un componente y quieren una respuesta segura y con criterio en lugar de un menú de opciones.
Riesgo bajo: solo contiene instrucciones, sin scripts, acceso a la red ni escritura de archivos. Genera código frontend, pero no lo ejecuta: compruébalo en un navegador y en un dispositivo real. Se probó una vez con un menú de perfil en React y Tailwind: leyó RECIPES, eligió transiciones CSS, explicó si convenía animar, detalló los elementos de la solución y valoró cómo se percibiría la interacción. Señaló que el menú creado a mano carecía de navegación con flechas y de retorno del foco. Si se invoca sin una pregunta, solo saluda. Por Emil Kowalski, MIT.