{"id":10635,"date":"2026-08-13T16:13:08","date_gmt":"2026-08-13T21:13:08","guid":{"rendered":"https:\/\/master.dev\/blog\/?p=10635"},"modified":"2026-08-13T16:32:35","modified_gmt":"2026-08-13T21:32:35","slug":"tooltips-need-a-delay-and-then-they-need-to-skip-it","status":"publish","type":"post","link":"https:\/\/master.dev\/blog\/tooltips-need-a-delay-and-then-they-need-to-skip-it\/","title":{"rendered":"Tooltips Need a Delay, and Then They Need to Skip It"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">On the question listing pages for my product, <a href=\"https:\/\/www.frontprep.com\/user-interface\">FrontPrep<\/a>, I display company logos next to the interview questions. When you hover over a logo, a tooltip shows the company name.<br><br>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 <code>0<\/code>, so it appeared the moment the cursor hovered over the logo.<br><br>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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This post is not about building a tooltip. It\u2019s about one small interaction pattern, the same one you will find in browser toolbars and various websites, and people are unaware of it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is a short video of the before-and-after experience on my website. It helps you understand the problem better.<\/p>\n\n\n\n\t\t<figure class=\"wp-block-jetpack-videopress jetpack-videopress-player\" style=\"\" >\n\t\t\t<div class=\"jetpack-videopress-player__wrapper\"> <iframe loading=\"lazy\" title=\"VideoPress Video Player\" aria-label=\"VideoPress Video Player\" width=\"500\" height=\"297\" src=\"https:\/\/videopress.com\/embed\/YKRNKA4w?cover=1&amp;autoPlay=0&amp;controls=1&amp;loop=0&amp;muted=0&amp;persistVolume=1&amp;playsinline=0&amp;preloadContent=metadata&amp;useAverageColor=1&amp;hd=0\" frameborder=\"0\" allowfullscreen data-resize-to-parent=\"true\" allow=\"clipboard-write; presentation\"><\/iframe><script src='https:\/\/v0.wordpress.com\/js\/next\/videopress-iframe.js?m=1770107250'><\/script><\/div>\n\t\t\t<figcaption>Before (Without the delay of 200ms)<\/figcaption>\n\t\t\t\n\t\t<\/figure>\n\t\t\n\n\n\t\t<figure class=\"wp-block-jetpack-videopress jetpack-videopress-player\" style=\"\" >\n\t\t\t<div class=\"jetpack-videopress-player__wrapper\"> <iframe loading=\"lazy\" title=\"VideoPress Video Player\" aria-label=\"VideoPress Video Player\" width=\"500\" height=\"297\" src=\"https:\/\/videopress.com\/embed\/sxtDkDOR?cover=1&amp;autoPlay=0&amp;controls=1&amp;loop=0&amp;muted=0&amp;persistVolume=1&amp;playsinline=0&amp;preloadContent=metadata&amp;useAverageColor=1&amp;hd=0\" frameborder=\"0\" allowfullscreen data-resize-to-parent=\"true\" allow=\"clipboard-write; presentation\"><\/iframe><script src='https:\/\/v0.wordpress.com\/js\/next\/videopress-iframe.js?m=1770107250'><\/script><\/div>\n\t\t\t<figcaption>After (With the delay of 200ms &amp; instant tooltips)<\/figcaption>\n\t\t\t\n\t\t<\/figure>\n\t\t\n\n\n<h2 class=\"wp-block-heading\">A Solution<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">I will divide my solution into three parts.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>You hover over a logo. The tooltip waits 200ms before opening.<\/li>\n\n\n\n<li>A tooltip closes, and a 300ms timer starts. I call this the warm window or warm page. If you hover over another logo while this 300ms timer is running, or we could say when the page is warm, the tooltip opens instantly without any waiting or animation.<\/li>\n\n\n\n<li>When the 300ms timer expires, everything returns to normal, and the page becomes cold. If you hover over the logo again, the next tooltip has to wait 200ms again. Without this step, the first tooltip you opened would turn off the delay for the whole page permanently.<\/li>\n<\/ol>\n\n\n\n<pre class=\"wp-block-preformatted\">hover \u2192 wait 200ms \u2192 tooltip opens (page is now warm)<br>leave \u2192 tooltip closes \u2192 300ms cooldown<br>            \u251c\u2500 hover another tooltip before cooldown \u2192 opens instantly, page stays warm<br>            \u2514\u2500 cooldown ends \u2192 page is cold, the 200ms wait is back<\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_GgrYMOo\" src=\"\/\/codepen.io\/anon\/embed\/GgrYMOo?height=550&amp;theme-id=1&amp;slug-hash=GgrYMOo&amp;default-tab=result\" height=\"550\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed GgrYMOo\" title=\"CodePen Embed GgrYMOo\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p class=\"learn-more wp-block-paragraph\"><a href=\"https:\/\/master.dev\/blog\/delayed-then-instant-tooltips-with-html-css-alone\/\">Here&#8217;s a follow-up article<\/a> that replicates the basic UX here without any JavaScript. <\/p>\n\n\n\n<h2 class=\"wp-block-heading\">How the Code Works<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">I will try to explain the code in the same order in which things happen when you move the cursor.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1: You Hover Over a Logo<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When <code>onMouseEnter<\/code> is triggered on a logo, this function is called:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">handleEnter<\/span>(<span class=\"hljs-params\"><\/span>) <\/span>{\n  <span class=\"hljs-comment\">\/\/ `tooltips` is a useContext variable where the isWarm state is tracked between components<\/span>\n  If (tooltips.isWarm) {\n    show();\n    <span class=\"hljs-keyword\">return<\/span>;\n  }\n  <span class=\"hljs-comment\">\/\/ `openTimer` is a useRef variable, so .current is how you set\/access the value.<\/span>\n  openTimer.current = setTimeout(show, tooltips.openDelay);\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">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 <code>TooltipProvider<\/code> which I will discuss in the last step.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2: The Tooltip Opens<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s that show function that handles the opening of the tooltip:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">show<\/span>(<span class=\"hljs-params\"><\/span>) <\/span>{\n  setInstant(tooltips.isWarm);\n  setOpen(<span class=\"hljs-literal\">true<\/span>);\n  tooltips.markOpened();\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">This does three things:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>It copied the value of <code>tooltips.isWarm<\/code> into a state called <code>instant<\/code>.&nbsp;<\/li>\n\n\n\n<li>It opens the tooltip.<\/li>\n\n\n\n<li>It tells the provider that the tooltip is open, which makes the page warm.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Coming back to point 1, it copied what <code>tooltips.isWarm<\/code> 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.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-class\">.tooltip<\/span><span class=\"hljs-selector-attr\">&#91;data-instant=<span class=\"hljs-string\">\"true\"<\/span>]<\/span> {\n  <span class=\"hljs-attribute\">transition-duration<\/span>: <span class=\"hljs-number\">0ms<\/span>;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">This is the reason why, when the page is warm, a tooltip opens without any animation or delay.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3: You Leave the Logo<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When <code>onMouseLeave<\/code> is triggered on a logo, this function is called:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">handleLeave<\/span>(<span class=\"hljs-params\"><\/span>) <\/span>{\n  clearTimeout(openTimer.current);\n  If (!open) <span class=\"hljs-keyword\">return<\/span>;\n  setOpen(<span class=\"hljs-literal\">false<\/span>);\n  setInstant(<span class=\"hljs-literal\">false<\/span>);\n  tooltips.markClosed();\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">First, we have to know whether the tooltip is currently open.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Let\u2019s say your cursor crosses a logo in 50ms, which is far less than 200ms, so the timer started by <code>handleEnter<\/code> is still running and the tooltip is not opened yet. <code>clearTimeout<\/code> in <code>handleLeave<\/code> cancels that timer so the tooltip never opens at all, and <code>if (!open) return;<\/code> 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.&nbsp;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.&nbsp;<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4: The Provider<\/h3>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">const<\/span> warm = useRef(<span class=\"hljs-literal\">false<\/span>);\n<span class=\"hljs-keyword\">const<\/span> cooldownTimer = useRef(<span class=\"hljs-literal\">null<\/span>);\n\nuseEffect(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> <span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> clearTimeout(cooldownTimer.current), &#91;]);\n\n<span class=\"hljs-keyword\">const<\/span> tooltips = useMemo(\n  <span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> ({\n    openDelay,\n    <span class=\"hljs-attr\">isWarm<\/span>: <span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> skipWhenWarm &amp;&amp; warm.current,\n    markOpened() {\n      warm.current = <span class=\"hljs-literal\">true<\/span>;\n      clearTimeout(cooldownTimer.current);\n    },\n    markClosed() {\n      clearTimeout(cooldownTimer.current);\n      cooldownTimer.current = setTimeout(<span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> {\n        warm.current = <span class=\"hljs-literal\">false<\/span>;\n      }, warmFor);\n    },\n  }),\n  &#91;openDelay, warmFor, skipWhenWarm]\n);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You don\u2019t need to get confused about <code>skipWhenWarm<\/code> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Another important detail here is that <code>isWarm<\/code> 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why a Timer of 200ms?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Judgement &amp; Taste<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Skill<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">.claude<br>  \/skills<br>    \/tooltip<br>      \/SKILL.md<\/pre>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"Markdown\" data-shcb-language-slug=\"markdown\"><span><code class=\"hljs language-markdown\">---\nname: tooltip\n<span class=\"hljs-section\">description: Tooltips need a delay so they don't open up on unintentional mouse travel\n---<\/span>\n\n<span class=\"hljs-section\"># Tooltip Timing<\/span>\n\nA 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.\n\n<span class=\"hljs-code\">```css\n.tooltip {\n transition-delay: 200ms;\n}\n```<\/span>\n\n<span class=\"hljs-section\">## The three numbers and states<\/span>\n\n| Value       | Number | Why                                                                                                     |\n| ----------- | ------ | ------------------------------------------------------------------- |\n| Open delay  | 200ms  | Below 150ms a cursor that is only passing over a trigger still opens it. Above 250ms an intentional hover feels broken. |\n| 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. |\n| Close delay | 0ms    | Leaving a trigger should be clear. So there is nothing to wait for.|\n\n<span class=\"hljs-section\">## How it should behave<\/span>\n\n<span class=\"hljs-code\">```\nhover -&gt; wait 200ms -&gt; tooltip opens (page is now warm)\nleave -&gt; tooltip closes -&gt; 300ms cooldown\n```<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">Markdown<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">markdown<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>","protected":false},"excerpt":{"rendered":"<p>Thinking about UX, a slight delay on a simple tooltip can be nice so that mousing over something quickly doesn&#8217;t trigger it, but if there are related tooltips nearby, maybe we don&#8217;t need to wait.<\/p>\n","protected":false},"author":47,"featured_media":10670,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_feature_clip_id":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_post_was_ever_published":false},"categories":[1],"tags":[62,417,26],"class_list":["post-10635","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-react","tag-tooltips","tag-ux"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/master.dev\/blog\/wp-content\/uploads\/2026\/08\/tooltip.jpg?fit=2000%2C1200&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/posts\/10635","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/users\/47"}],"replies":[{"embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/comments?post=10635"}],"version-history":[{"count":9,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/posts\/10635\/revisions"}],"predecessor-version":[{"id":10686,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/posts\/10635\/revisions\/10686"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/media\/10670"}],"wp:attachment":[{"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/media?parent=10635"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/categories?post=10635"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/master.dev\/blog\/wp-json\/wp\/v2\/tags?post=10635"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}