(These days I would have included more comments.)
Naturally, there was a fallback to pure CSS menus, because we weren't barbarians.
It's such a small detail that I wouldn't have otherwise noticed (because I don't build animations, so it doesn't cross my mind), but now that I know about it, I think about it all the time.
I like predictable, consistent, and fast.
I think there was also an article about it on The Old New Thing, but I can't seem to find it anymore.
Why on earth do we need this thing here?
Oh, that's why.
How is this different from the observation that, say, a non-magnetic iron ball may have more than one possible velocity in a given gravitional field, depending on whether it was recently thrown upward or dropped from above?
Find modern examples by Googling "menu safe triangle." Here's one from 5 days ago https://www.framer.com/blog/cursor-trajectory/
> I asked the Framer Agent to fix it, and within minutes it had built and applied what I now call trajectory-based menu aim
There is no contact form. Only a Confession Textarea
My three most-hated modern trends
- Disappearing scroll bar with content that you have to scroll for
- Pills going across the top of the screen that are "most-used" filters instead of a real filtering menu
- Clickable text and non-clickable text appearing the same. I mean come on. Who the fuck is designing these things? That's the one I can't wrap my mind around. How can a self-respecting programmer put out a UI where the display and clickable elements look exactly the same? What are we doing man
Browsers are different. They don't really benefit from being written for, or from being used. This seems like enough to explain the difference. They provide some functionality, and giving your users a good experience is your problem, not theirs.
I feel like I've seen complaints that Apple has forgotten things it already knew about how to design a usable interface; that wouldn't be explained by this model.
On the question listing pages for my product, FrontPrep, I display company logos next to the interview questions. When you hover over a logo, a tooltip shows the company name.
One thing had been annoying me for a couple of days. If I just moved the cursor across the page, tooltips kept showing up instantly along the way. The cause was simple: I had set the tooltip delay to 0, so it appeared the moment the cursor hovered over the logo.
To fix this issue, I added a 200ms transition delay, which worked but created a new problem. If you look at the user interface, there are a couple of rows where multiple logos sit next to each other because the same interview questions are asked at multiple companies. Now, moving from one logo to another meant waiting the same 200ms delay every time you hovered over a different company logo, which felt sluggish and led to a poor user experience.
This post is not about building a tooltip. It’s about one small interaction pattern, the same one you will find in browser toolbars and various websites, and people are unaware of it.
Here is a short video of the before-and-after experience on my website. It helps you understand the problem better.
Before (Without the delay of 200ms)
After (With the delay of 200ms & instant tooltips)
I will divide my solution into three parts.
hover → wait 200ms → tooltip opens (page is now warm)
leave → tooltip closes → 300ms cooldown
├─ hover another tooltip before cooldown → opens instantly, page stays warm
└─ cooldown ends → page is cold, the 200ms wait is back
In FrontPrep, my tooltips are built with Radix and Motion. I will demonstrate the pattern using a simpler React version below, and later in the post, I will put this idea into a Claude skill which you can use to audit your own codebase.
Here’s a follow-up article that replicates the basic UX here without any JavaScript.
I will try to explain the code in the same order in which things happen when you move the cursor.
When onMouseEnter is triggered on a logo, this function is called:
function handleEnter() { // `tooltips` is a useContext variable where the isWarm state is tracked between components If (tooltips.isWarm) { show(); return; } // `openTimer` is a useRef variable, so .current is how you set/access the value. openTimer.current = setTimeout(show, tooltips.openDelay); }Code language: JavaScript (javascript)
On hover, this component asks one question: is the page warm? If it is, the tooltip opens instantly. If not, it starts a 200ms timer and waits. You might be wondering where this tooltips object came from. It comes from the TooltipProvider which I will discuss in the last step.
Here’s that show function that handles the opening of the tooltip:
function show() { setInstant(tooltips.isWarm); setOpen(true); tooltips.markOpened(); }Code language: JavaScript (javascript)
This does three things:
tooltips.isWarm into a state called instant. Coming back to point 1, it copied what tooltips.isWarm returns into a flag called instant because this instant flag is added to the tooltip as a data attribute, which CSS uses to skip the entrance animation.
.tooltip[data-instant="true"] { transition-duration: 0ms; }Code language: CSS (css)
This is the reason why, when the page is warm, a tooltip opens without any animation or delay.
When onMouseLeave is triggered on a logo, this function is called:
function handleLeave() { clearTimeout(openTimer.current); If (!open) return; setOpen(false); setInstant(false); tooltips.markClosed(); }Code language: JavaScript (javascript)
First, we have to know whether the tooltip is currently open.
Let’s say your cursor crosses a logo in 50ms, which is far less than 200ms, so the timer started by handleEnter is still running and the tooltip is not opened yet. clearTimeout in handleLeave cancels that timer so the tooltip never opens at all, and if (!open) return; stops the function right there, because the tooltip, which never opened has nothing to close and no cooldowns to start. In the first video, a sweep opened every tooltip in its path; now, the same sweep opens no tooltips.
If the tooltip is open, this happens when you rest your cursor on the logo for more than 200ms, or when the page is warm, and the tooltip opens instantly on enter. We will close the tooltip and tell our provider, the provider will then start a 300ms cooldown timer, if you hover over the next logo before this timer ends, the tooltip opens instantly.
const warm = useRef(false); const cooldownTimer = useRef(null); useEffect(() => () => clearTimeout(cooldownTimer.current), []); const tooltips = useMemo( () => ({ openDelay, isWarm: () => skipWhenWarm && warm.current, markOpened() { warm.current = true; clearTimeout(cooldownTimer.current); }, markClosed() { clearTimeout(cooldownTimer.current); cooldownTimer.current = setTimeout(() => { warm.current = false; }, warmFor); }, }), [openDelay, warmFor, skipWhenWarm] );Code language: JavaScript (javascript)
This is the shared state we need for this whole pattern to work. The page could either be warm or cold. If you notice, markOpened cancels the pending cooldowns; this is what keeps the page warm when you move from one logo to another. Basically, every new tooltip cancels the cooldown started by the previous one.
You don’t need to get confused about skipWhenWarm flag, it is just for the before and after toggle in the demo; turning it off always makes the provider cold to help you see the before behavior.
Another important detail here is that isWarm is a ref and not a React state because changing it does not re-render all the tooltips on the page. This ref is only read inside the event handlers.
If we choose a timer of less than 150ms, when a cursor passes over a tooltip trigger, it will most likely open the tooltip. If we choose a timer of more than 250ms, the hover will feel sluggish and broken. So, 200ms is a good, balanced number in this case.
If you ask AI to build a tooltip, it will build a fully functional tooltip, but in the end, it is you, the human, who will decide if the tooltip built is worthy because there are details that separate a working tooltip from a polished tooltip, the same way they separate a working product and a polished product. AI can build a polished product only if you guide it to do so, and you can guide it when you have developed taste and judgment, which come from years of experience, mistakes, and practice.
However, you can use skills of other engineers/designers to have their taste of a polished product and eventually build yours as you start developing your own judgement, you can create/use skills around colors, accessibility, forms, animations, typography etc, you can generate solid outputs but it again does not mean it is production ready, you are still there at the end to judge and ship it only if it meets your standards and taste.
I have created a Claude skill for solving the above problem which I faced, you can build a similar skill or copy the one written below and run it to audit your codebase. Every codebase is different, and every codebase uses different libraries for tooltips or has written custom tooltips differently; this skill will work for all.
.claude
/skills
/tooltip
/SKILL.md
--- name: tooltip description: Tooltips need a delay so they don't open up on unintentional mouse travel --- # Tooltip Timing A click is always intentional whereas a hover is not intentional. The cursor travels across the page to get wherever it has to, and it passes over elements on the way, so a tooltip cannot tell from the hover whether the user wants to open it. The 200ms is how it finds out. ```css .tooltip { transition-delay: 200ms; } ``` ## The three numbers and states | Value | Number | Why | | ----------- | ------ | ------------------------------------------------------------------- | | Open delay | 200ms | Below 150ms a cursor that is only passing over a trigger still opens it. Above 250ms an intentional hover feels broken. | | Warm window | 300ms | Long enough to cover the move from one trigger to the next one. Short enough that a hover a second later waits again. | | Close delay | 0ms | Leaving a trigger should be clear. So there is nothing to wait for.| ## How it should behave ``` hover -> wait 200ms -> tooltip opens (page is now warm) leave -> tooltip closes -> 300ms cooldown ```Code language: Markdown (markdown)
The views expressed in this article are those of the author only and not Coinbase.