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.
它做什麼
把「加點動態」的請求變成經得起嚴格評審的實作。它依固定順序做決定:先過使用頻率這一關(每天用上百次的東西不做動畫),再說出一個明確的目的(回饋、空間連續性、狀態、避免突兀變化、說明、愉悅),選擇最省事的工具(CSS 轉場、@starting-style、CSS 動畫、WAAPI、Motion),決定要動的屬性(只動 transform 與 opacity,絕不從 0 縮放),然後從固定表格選緩動與時長,或使用彈簧,再處理被打斷與退出的行為,以及減少動態效果與懸停的處理。它附有一張「絕不能交付」的表,以及一份 RECIPES 檔,涵蓋按鈕、下拉選單、提示框、對話框、抽屜、通知、手風琴與分頁指示條等常見情境。
輸出
程式碼,加上幾行說明:關卡結果、所用要素,以及哪些需要用手感去檢查。
適合什麼場景
替元件加動態、想要明確結論而不是一堆選項的開發者。
低風險:純指令檔,沒有腳本,不連網,不寫檔。它只寫前端程式碼、不執行,請在瀏覽器與實機上檢查效果。已用一個 React 與 Tailwind 的頭像下拉選單試用一次:它讀取了自帶的 RECIPES 檔,選擇 CSS 轉場,並給出關卡結果、要素與手感檢查;模型也指出手寫選單缺少方向鍵導覽與焦點返回。沒有帶問題呼叫時,它只回覆一句問候。作者 Emil Kowalski,MIT 授權。