12 / 20
All 20 in 20 Tailwind CSS Popovers
- 01Tailwind Popover Show and Hide with the Popover API
- 02Tailwind Auto vs Manual Popover Behavior
- 03Tailwind Anchored Popover with CSS Anchor Positioning
- 04Tailwind Popover Entry and Exit Animation
- 05Tailwind Popover Backdrop and Light Dismiss
- 06Tailwind Position Area Popover Placement Grid
- 07Tailwind Popover Flip with Position Try Fallbacks
- 08Tailwind Tooltip Arrow Pointing to Anchor
- 09Tailwind Dropdown Matching Anchor Width
- 10Tailwind Nested Popover Submenu
- 11Tailwind Custom Select Listbox Popover
- 12Tailwind Tooltip on Hover and Focus
- 13Tailwind User Profile Dropdown Menu
- 14Tailwind Date Picker Popover
- 15Tailwind Filter Dropdown with Checkboxes
- 16Tailwind Popover Fallback and Polyfill
- 17Tailwind Popover vs Dialog Modal
- 18Tailwind Glassmorphism Popover
- 19Tailwind Dark Mode Popover Toggle
- 20Tailwind Accessible Tooltip with ARIA
Tailwind Tooltip on Hover and Focus
Popover's built-in trigger is click, so hover and focus call showPopover()/hidePopover() themselves: a 150ms open delay for hover intent, instant close, instant open on keyboard focus. The bubble is popover="manual" so it never fights the auto stack. The port keeps the controller to twenty lines and moves every visual — the dotted term, the rise-in, the definition card — into utilities.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tpop-12 transition-[--tpop12-d] duration-300 ease-in-out motion-reduce:transition-none dark:[--tpop12-d:1] [[data-theme=dark]_&]:[--tpop12-d:1] [[data-theme=light]_&]:[--tpop12-d:0] w-full min-h-svh bg-tpop12-paper px-5 py-11 font-tpop12-serif text-tpop12-ink" aria-label="Tooltip on hover and focus demo">
<div class="grid min-h-[calc(100svh-88px)] max-w-full place-items-center">
<article class="w-full min-w-0 max-w-[32rem]">
<p class="mb-3 font-tpop12-sans text-[10.5px] font-bold uppercase tracking-[0.22em] text-tpop12-acc">Field notes · Typesetting</p>
<h2 class="mb-4.5 text-[clamp(30px,6.5vw,44px)] font-medium leading-[1.05] tracking-[-0.02em]">The measure of a column</h2>
<p class="mb-4 text-[clamp(17px,3.6vw,19px)] leading-[1.72] text-pretty">Set a column too wide and the eye loses the return sweep; too narrow and hyphenation does the reading for you. The old compositors aimed for a
<button id="tpop-12-trigger" type="button" aria-describedby="tpop-12-tip" class="inline cursor-help rounded-sm p-0 font-[inherit] text-tpop12-acc underline decoration-tpop12-acc/60 decoration-dotted decoration-2 underline-offset-[5px] transition-[text-decoration-color] [anchor-name:--tpop12-a] hover:decoration-tpop12-acc focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-tpop12-acc">measure</button>
of sixty-six characters and were rarely wrong, which is why the figure survives in every style manual written since.</p>
<p class="font-tpop12-sans text-[13px] leading-relaxed text-tpop12-mut">Hover the term, or tab to it. The hint appears after a short pause and leaves the moment you move on.</p>
</article>
</div>
<div popover="manual" id="tpop-12-tip" role="tooltip" class="fixed m-auto w-[min(16.5rem,calc(100vw-28px))] rounded-lg bg-tpop12-ink px-3.5 py-2.5 font-tpop12-sans text-[13px] leading-normal text-tpop12-paper opacity-0 shadow-[0_14px_28px_-12px_oklch(0.2_0.01_80/0.45)] text-pretty transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop12-a] [position-area:block-start_center] [justify-self:anchor-center] [position-try-fallbacks:flip-block] open:opacity-100 starting:open:translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mb-2">
<span class="mb-0.5 block font-tpop12-serif text-[15px] italic text-tpop12-paper">measure <span class="font-tpop12-sans text-[11px] not-italic text-tpop12-paper/70">n.</span></span>
The number of characters, including spaces, on a single line of set text.
</div>
</section><section class="tpop-12 transition-[--tpop12-d] duration-300 ease-in-out motion-reduce:transition-none dark:[--tpop12-d:1] [[data-theme=dark]_&]:[--tpop12-d:1] [[data-theme=light]_&]:[--tpop12-d:0] w-full min-h-svh bg-tpop12-paper px-5 py-11 font-tpop12-serif text-tpop12-ink" aria-label="Tooltip on hover and focus demo">
<div class="grid min-h-[calc(100svh-88px)] max-w-full place-items-center">
<article class="w-full min-w-0 max-w-[32rem]">
<p class="mb-3 font-tpop12-sans text-[10.5px] font-bold uppercase tracking-[0.22em] text-tpop12-acc">Field notes · Typesetting</p>
<h2 class="mb-4.5 text-[clamp(30px,6.5vw,44px)] font-medium leading-[1.05] tracking-[-0.02em]">The measure of a column</h2>
<p class="mb-4 text-[clamp(17px,3.6vw,19px)] leading-[1.72] text-pretty">Set a column too wide and the eye loses the return sweep; too narrow and hyphenation does the reading for you. The old compositors aimed for a
<button id="tpop-12-trigger" type="button" aria-describedby="tpop-12-tip" class="inline cursor-help rounded-sm p-0 font-[inherit] text-tpop12-acc underline decoration-tpop12-acc/60 decoration-dotted decoration-2 underline-offset-[5px] transition-[text-decoration-color] [anchor-name:--tpop12-a] hover:decoration-tpop12-acc focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-tpop12-acc">measure</button>
of sixty-six characters and were rarely wrong, which is why the figure survives in every style manual written since.</p>
<p class="font-tpop12-sans text-[13px] leading-relaxed text-tpop12-mut">Hover the term, or tab to it. The hint appears after a short pause and leaves the moment you move on.</p>
</article>
</div>
<div popover="manual" id="tpop-12-tip" role="tooltip" class="fixed m-auto w-[min(16.5rem,calc(100vw-28px))] rounded-lg bg-tpop12-ink px-3.5 py-2.5 font-tpop12-sans text-[13px] leading-normal text-tpop12-paper opacity-0 shadow-[0_14px_28px_-12px_oklch(0.2_0.01_80/0.45)] text-pretty transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop12-a] [position-area:block-start_center] [justify-self:anchor-center] [position-try-fallbacks:flip-block] open:opacity-100 starting:open:translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mb-2">
<span class="mb-0.5 block font-tpop12-serif text-[15px] italic text-tpop12-paper">measure <span class="font-tpop12-sans text-[11px] not-italic text-tpop12-paper/70">n.</span></span>
The number of characters, including spaces, on a single line of set text.
</div>
</section>@import "tailwindcss";
@theme {
--color-tpop12-paper: #fcfaf6;
--color-tpop12-paper: oklch(0.985 0.006 90);
--color-tpop12-ink: #191816;
--color-tpop12-ink: oklch(0.21 0.005 90);
--color-tpop12-mut: #5f5a51;
--color-tpop12-mut: oklch(0.47 0.015 85);
--color-tpop12-acc: #932d1c;
--color-tpop12-acc: oklch(0.45 0.14 32);
--font-tpop12-serif: 'Newsreader', Georgia, serif;
--font-tpop12-sans: 'Geist', system-ui, sans-serif;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop12-d {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles — plus the dark palette. Each @theme colour is
re-declared on the root as a light/dark mix of the dial, so every utility (and every open
popover, which inherits from the root) follows it. Light value last, dark value first. */
.tpop-12 {
width: 100%;
min-height: 100svh;
--color-tpop12-paper: color-mix(in oklch, oklch(0.17 0.006 85) calc(var(--tpop12-d) * 100%), oklch(0.985 0.006 90));
--color-tpop12-ink: color-mix(in oklch, oklch(0.95 0.01 90) calc(var(--tpop12-d) * 100%), oklch(0.21 0.005 90));
--color-tpop12-mut: color-mix(in oklch, oklch(0.74 0.015 85) calc(var(--tpop12-d) * 100%), oklch(0.47 0.015 85));
--color-tpop12-acc: color-mix(in oklch, oklch(0.74 0.12 35) calc(var(--tpop12-d) * 100%), oklch(0.45 0.14 32));
}@import "tailwindcss";
@theme {
--color-tpop12-paper: #fcfaf6;
--color-tpop12-paper: oklch(0.985 0.006 90);
--color-tpop12-ink: #191816;
--color-tpop12-ink: oklch(0.21 0.005 90);
--color-tpop12-mut: #5f5a51;
--color-tpop12-mut: oklch(0.47 0.015 85);
--color-tpop12-acc: #932d1c;
--color-tpop12-acc: oklch(0.45 0.14 32);
--font-tpop12-serif: 'Newsreader', Georgia, serif;
--font-tpop12-sans: 'Geist', system-ui, sans-serif;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop12-d {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles — plus the dark palette. Each @theme colour is
re-declared on the root as a light/dark mix of the dial, so every utility (and every open
popover, which inherits from the root) follows it. Light value last, dark value first. */
.tpop-12 {
width: 100%;
min-height: 100svh;
--color-tpop12-paper: color-mix(in oklch, oklch(0.17 0.006 85) calc(var(--tpop12-d) * 100%), oklch(0.985 0.006 90));
--color-tpop12-ink: color-mix(in oklch, oklch(0.95 0.01 90) calc(var(--tpop12-d) * 100%), oklch(0.21 0.005 90));
--color-tpop12-mut: color-mix(in oklch, oklch(0.74 0.015 85) calc(var(--tpop12-d) * 100%), oklch(0.47 0.015 85));
--color-tpop12-acc: color-mix(in oklch, oklch(0.74 0.12 35) calc(var(--tpop12-d) * 100%), oklch(0.45 0.14 32));
}(() => {
const trigger = document.getElementById('tpop-12-trigger');
const tip = document.getElementById('tpop-12-tip');
if (!trigger || !tip || trigger.dataset.wired || !HTMLElement.prototype.hasOwnProperty('popover')) return;
trigger.dataset.wired = '1';
let timer = 0;
const open = (delay) => {
clearTimeout(timer);
timer = setTimeout(() => { if (!tip.matches(':popover-open')) tip.showPopover(); }, delay);
};
const close = () => {
clearTimeout(timer);
if (tip.matches(':popover-open')) tip.hidePopover();
};
trigger.addEventListener('pointerenter', () => open(150));
trigger.addEventListener('pointerleave', close);
trigger.addEventListener('focusin', () => open(0));
trigger.addEventListener('focusout', close);
trigger.addEventListener('click', () => open(0));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
})();(() => {
const trigger = document.getElementById('tpop-12-trigger');
const tip = document.getElementById('tpop-12-tip');
if (!trigger || !tip || trigger.dataset.wired || !HTMLElement.prototype.hasOwnProperty('popover')) return;
trigger.dataset.wired = '1';
let timer = 0;
const open = (delay) => {
clearTimeout(timer);
timer = setTimeout(() => { if (!tip.matches(':popover-open')) tip.showPopover(); }, delay);
};
const close = () => {
clearTimeout(timer);
if (tip.matches(':popover-open')) tip.hidePopover();
};
trigger.addEventListener('pointerenter', () => open(150));
trigger.addEventListener('pointerleave', close);
trigger.addEventListener('focusin', () => open(0));
trigger.addEventListener('focusout', close);
trigger.addEventListener('click', () => open(0));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
})();Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-tpop12-paper: #fcfaf6;
--color-tpop12-paper: oklch(0.985 0.006 90);
--color-tpop12-ink: #191816;
--color-tpop12-ink: oklch(0.21 0.005 90);
--color-tpop12-mut: #5f5a51;
--color-tpop12-mut: oklch(0.47 0.015 85);
--color-tpop12-acc: #932d1c;
--color-tpop12-acc: oklch(0.45 0.14 32);
--font-tpop12-serif: 'Newsreader', Georgia, serif;
--font-tpop12-sans: 'Geist', system-ui, sans-serif;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop12-d {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles — plus the dark palette. Each @theme colour is
re-declared on the root as a light/dark mix of the dial, so every utility (and every open
popover, which inherits from the root) follows it. Light value last, dark value first. */
.tpop-12 {
width: 100%;
min-height: 100svh;
--color-tpop12-paper: color-mix(in oklch, oklch(0.17 0.006 85) calc(var(--tpop12-d) * 100%), oklch(0.985 0.006 90));
--color-tpop12-ink: color-mix(in oklch, oklch(0.95 0.01 90) calc(var(--tpop12-d) * 100%), oklch(0.21 0.005 90));
--color-tpop12-mut: color-mix(in oklch, oklch(0.74 0.015 85) calc(var(--tpop12-d) * 100%), oklch(0.47 0.015 85));
--color-tpop12-acc: color-mix(in oklch, oklch(0.74 0.12 35) calc(var(--tpop12-d) * 100%), oklch(0.45 0.14 32));
}@import "tailwindcss";
@theme {
--color-tpop12-paper: #fcfaf6;
--color-tpop12-paper: oklch(0.985 0.006 90);
--color-tpop12-ink: #191816;
--color-tpop12-ink: oklch(0.21 0.005 90);
--color-tpop12-mut: #5f5a51;
--color-tpop12-mut: oklch(0.47 0.015 85);
--color-tpop12-acc: #932d1c;
--color-tpop12-acc: oklch(0.45 0.14 32);
--font-tpop12-serif: 'Newsreader', Georgia, serif;
--font-tpop12-sans: 'Geist', system-ui, sans-serif;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop12-d {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles — plus the dark palette. Each @theme colour is
re-declared on the root as a light/dark mix of the dial, so every utility (and every open
popover, which inherits from the root) follows it. Light value last, dark value first. */
.tpop-12 {
width: 100%;
min-height: 100svh;
--color-tpop12-paper: color-mix(in oklch, oklch(0.17 0.006 85) calc(var(--tpop12-d) * 100%), oklch(0.985 0.006 90));
--color-tpop12-ink: color-mix(in oklch, oklch(0.95 0.01 90) calc(var(--tpop12-d) * 100%), oklch(0.21 0.005 90));
--color-tpop12-mut: color-mix(in oklch, oklch(0.74 0.015 85) calc(var(--tpop12-d) * 100%), oklch(0.47 0.015 85));
--color-tpop12-acc: color-mix(in oklch, oklch(0.74 0.12 35) calc(var(--tpop12-d) * 100%), oklch(0.45 0.14 32));
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400..700;1,6..72,400..700&family=Geist:wght@400..800&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400..700;1,6..72,400..700&family=Geist:wght@400..800&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The
:popover-open+@starting-stylefade becameopen:/starting:open:utilities, and the reduced-motion block becamemotion-reduce:transition-none. border-block-end: 1px solidon the term became a real text decoration (underline decoration-dotted decoration-2 underline-offset-[5px]), which wraps correctly across lines where a border did not.- UI upgrade from the CSS original: the hint is now a small dictionary card — headword in italic serif, part of speech, definition — and rises 4px as it fades in instead of only fading.
- UI upgrade: the body moved to Newsreader at 17–19px for a genuine editorial measure, the dotted cue is the conventional "there's a definition here" signal, the controller is an idempotent IIFE, and muted copy rose from
#6a675etooklch(0.47). - UI upgrade — dark mode: the stage now follows
prefers-color-scheme(and adata-themeoverride on any ancestor) through one registered dial,--tpop12-d. The root rule re-declares each@themecolour ascolor-mix(dark calc(var(--tpop12-d)*100%), light), the root sets the dial withdark:[--tpop12-d:1], andtransition-[--tpop12-d]crossfades page and open popovers together — the markup's colour utilities are unchanged.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Tooltip on Hover and Focus
Source: https://codefronts.com/snippets/tailwind-css-popovers/tailwind-tooltip-on-hover-and-focus/
Popover's built-in trigger is click, so hover and focus call showPopover()/hidePopover() themselves: a 150ms open delay for hover intent, instant close, instant open on keyboard focus. The bubble is popover="manual" so it never fights the auto stack. The port keeps the controller to twenty lines and moves every visual — the dotted term, the rise-in, the definition card — into utilities.
## HTML
```html
<section class="tpop-12 transition-[--tpop12-d] duration-300 ease-in-out motion-reduce:transition-none dark:[--tpop12-d:1] [[data-theme=dark]_&]:[--tpop12-d:1] [[data-theme=light]_&]:[--tpop12-d:0] w-full min-h-svh bg-tpop12-paper px-5 py-11 font-tpop12-serif text-tpop12-ink" aria-label="Tooltip on hover and focus demo">
<div class="grid min-h-[calc(100svh-88px)] max-w-full place-items-center">
<article class="w-full min-w-0 max-w-[32rem]">
<p class="mb-3 font-tpop12-sans text-[10.5px] font-bold uppercase tracking-[0.22em] text-tpop12-acc">Field notes · Typesetting</p>
<h2 class="mb-4.5 text-[clamp(30px,6.5vw,44px)] font-medium leading-[1.05] tracking-[-0.02em]">The measure of a column</h2>
<p class="mb-4 text-[clamp(17px,3.6vw,19px)] leading-[1.72] text-pretty">Set a column too wide and the eye loses the return sweep; too narrow and hyphenation does the reading for you. The old compositors aimed for a
<button id="tpop-12-trigger" type="button" aria-describedby="tpop-12-tip" class="inline cursor-help rounded-sm p-0 font-[inherit] text-tpop12-acc underline decoration-tpop12-acc/60 decoration-dotted decoration-2 underline-offset-[5px] transition-[text-decoration-color] [anchor-name:--tpop12-a] hover:decoration-tpop12-acc focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-tpop12-acc">measure</button>
of sixty-six characters and were rarely wrong, which is why the figure survives in every style manual written since.</p>
<p class="font-tpop12-sans text-[13px] leading-relaxed text-tpop12-mut">Hover the term, or tab to it. The hint appears after a short pause and leaves the moment you move on.</p>
</article>
</div>
<div popover="manual" id="tpop-12-tip" role="tooltip" class="fixed m-auto w-[min(16.5rem,calc(100vw-28px))] rounded-lg bg-tpop12-ink px-3.5 py-2.5 font-tpop12-sans text-[13px] leading-normal text-tpop12-paper opacity-0 shadow-[0_14px_28px_-12px_oklch(0.2_0.01_80/0.45)] text-pretty transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop12-a] [position-area:block-start_center] [justify-self:anchor-center] [position-try-fallbacks:flip-block] open:opacity-100 starting:open:translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mb-2">
<span class="mb-0.5 block font-tpop12-serif text-[15px] italic text-tpop12-paper">measure <span class="font-tpop12-sans text-[11px] not-italic text-tpop12-paper/70">n.</span></span>
The number of characters, including spaces, on a single line of set text.
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tpop12-paper: #fcfaf6;
--color-tpop12-paper: oklch(0.985 0.006 90);
--color-tpop12-ink: #191816;
--color-tpop12-ink: oklch(0.21 0.005 90);
--color-tpop12-mut: #5f5a51;
--color-tpop12-mut: oklch(0.47 0.015 85);
--color-tpop12-acc: #932d1c;
--color-tpop12-acc: oklch(0.45 0.14 32);
--font-tpop12-serif: 'Newsreader', Georgia, serif;
--font-tpop12-sans: 'Geist', system-ui, sans-serif;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop12-d {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles — plus the dark palette. Each @theme colour is
re-declared on the root as a light/dark mix of the dial, so every utility (and every open
popover, which inherits from the root) follows it. Light value last, dark value first. */
.tpop-12 {
width: 100%;
min-height: 100svh;
--color-tpop12-paper: color-mix(in oklch, oklch(0.17 0.006 85) calc(var(--tpop12-d) * 100%), oklch(0.985 0.006 90));
--color-tpop12-ink: color-mix(in oklch, oklch(0.95 0.01 90) calc(var(--tpop12-d) * 100%), oklch(0.21 0.005 90));
--color-tpop12-mut: color-mix(in oklch, oklch(0.74 0.015 85) calc(var(--tpop12-d) * 100%), oklch(0.47 0.015 85));
--color-tpop12-acc: color-mix(in oklch, oklch(0.74 0.12 35) calc(var(--tpop12-d) * 100%), oklch(0.45 0.14 32));
}
```
## JavaScript
```js
(() => {
const trigger = document.getElementById('tpop-12-trigger');
const tip = document.getElementById('tpop-12-tip');
if (!trigger || !tip || trigger.dataset.wired || !HTMLElement.prototype.hasOwnProperty('popover')) return;
trigger.dataset.wired = '1';
let timer = 0;
const open = (delay) => {
clearTimeout(timer);
timer = setTimeout(() => { if (!tip.matches(':popover-open')) tip.showPopover(); }, delay);
};
const close = () => {
clearTimeout(timer);
if (tip.matches(':popover-open')) tip.hidePopover();
};
trigger.addEventListener('pointerenter', () => open(150));
trigger.addEventListener('pointerleave', close);
trigger.addEventListener('focusin', () => open(0));
trigger.addEventListener('focusout', close);
trigger.addEventListener('click', () => open(0));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
})();
```Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Tooltip on Hover and Focus
Source: https://codefronts.com/snippets/tailwind-css-popovers/tailwind-tooltip-on-hover-and-focus/
Popover's built-in trigger is click, so hover and focus call showPopover()/hidePopover() themselves: a 150ms open delay for hover intent, instant close, instant open on keyboard focus. The bubble is popover="manual" so it never fights the auto stack. The port keeps the controller to twenty lines and moves every visual — the dotted term, the rise-in, the definition card — into utilities.
## HTML
```html
<section class="tpop-12 transition-[--tpop12-d] duration-300 ease-in-out motion-reduce:transition-none dark:[--tpop12-d:1] [[data-theme=dark]_&]:[--tpop12-d:1] [[data-theme=light]_&]:[--tpop12-d:0] w-full min-h-svh bg-tpop12-paper px-5 py-11 font-tpop12-serif text-tpop12-ink" aria-label="Tooltip on hover and focus demo">
<div class="grid min-h-[calc(100svh-88px)] max-w-full place-items-center">
<article class="w-full min-w-0 max-w-[32rem]">
<p class="mb-3 font-tpop12-sans text-[10.5px] font-bold uppercase tracking-[0.22em] text-tpop12-acc">Field notes · Typesetting</p>
<h2 class="mb-4.5 text-[clamp(30px,6.5vw,44px)] font-medium leading-[1.05] tracking-[-0.02em]">The measure of a column</h2>
<p class="mb-4 text-[clamp(17px,3.6vw,19px)] leading-[1.72] text-pretty">Set a column too wide and the eye loses the return sweep; too narrow and hyphenation does the reading for you. The old compositors aimed for a
<button id="tpop-12-trigger" type="button" aria-describedby="tpop-12-tip" class="inline cursor-help rounded-sm p-0 font-[inherit] text-tpop12-acc underline decoration-tpop12-acc/60 decoration-dotted decoration-2 underline-offset-[5px] transition-[text-decoration-color] [anchor-name:--tpop12-a] hover:decoration-tpop12-acc focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-tpop12-acc">measure</button>
of sixty-six characters and were rarely wrong, which is why the figure survives in every style manual written since.</p>
<p class="font-tpop12-sans text-[13px] leading-relaxed text-tpop12-mut">Hover the term, or tab to it. The hint appears after a short pause and leaves the moment you move on.</p>
</article>
</div>
<div popover="manual" id="tpop-12-tip" role="tooltip" class="fixed m-auto w-[min(16.5rem,calc(100vw-28px))] rounded-lg bg-tpop12-ink px-3.5 py-2.5 font-tpop12-sans text-[13px] leading-normal text-tpop12-paper opacity-0 shadow-[0_14px_28px_-12px_oklch(0.2_0.01_80/0.45)] text-pretty transition-[opacity,translate,display,overlay] duration-150 ease-out transition-discrete [position-anchor:--tpop12-a] [position-area:block-start_center] [justify-self:anchor-center] [position-try-fallbacks:flip-block] open:opacity-100 starting:open:translate-y-1 starting:open:opacity-0 motion-reduce:transition-none supports-[anchor-name:--a]:inset-auto supports-[anchor-name:--a]:m-0 supports-[anchor-name:--a]:mb-2">
<span class="mb-0.5 block font-tpop12-serif text-[15px] italic text-tpop12-paper">measure <span class="font-tpop12-sans text-[11px] not-italic text-tpop12-paper/70">n.</span></span>
The number of characters, including spaces, on a single line of set text.
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tpop12-paper: #fcfaf6;
--color-tpop12-paper: oklch(0.985 0.006 90);
--color-tpop12-ink: #191816;
--color-tpop12-ink: oklch(0.21 0.005 90);
--color-tpop12-mut: #5f5a51;
--color-tpop12-mut: oklch(0.47 0.015 85);
--color-tpop12-acc: #932d1c;
--color-tpop12-acc: oklch(0.45 0.14 32);
--font-tpop12-serif: 'Newsreader', Georgia, serif;
--font-tpop12-sans: 'Geist', system-ui, sans-serif;
}
/* Theme dial: 0 = light, 1 = dark. Registered so the root can transition it. */
@property --tpop12-d {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
/* Root rule: the full-bleed contract, stated in CSS as well as in the markup's utilities, so the
stage is sized before the browser build compiles — plus the dark palette. Each @theme colour is
re-declared on the root as a light/dark mix of the dial, so every utility (and every open
popover, which inherits from the root) follows it. Light value last, dark value first. */
.tpop-12 {
width: 100%;
min-height: 100svh;
--color-tpop12-paper: color-mix(in oklch, oklch(0.17 0.006 85) calc(var(--tpop12-d) * 100%), oklch(0.985 0.006 90));
--color-tpop12-ink: color-mix(in oklch, oklch(0.95 0.01 90) calc(var(--tpop12-d) * 100%), oklch(0.21 0.005 90));
--color-tpop12-mut: color-mix(in oklch, oklch(0.74 0.015 85) calc(var(--tpop12-d) * 100%), oklch(0.47 0.015 85));
--color-tpop12-acc: color-mix(in oklch, oklch(0.74 0.12 35) calc(var(--tpop12-d) * 100%), oklch(0.45 0.14 32));
}
```
## JavaScript
```js
(() => {
const trigger = document.getElementById('tpop-12-trigger');
const tip = document.getElementById('tpop-12-tip');
if (!trigger || !tip || trigger.dataset.wired || !HTMLElement.prototype.hasOwnProperty('popover')) return;
trigger.dataset.wired = '1';
let timer = 0;
const open = (delay) => {
clearTimeout(timer);
timer = setTimeout(() => { if (!tip.matches(':popover-open')) tip.showPopover(); }, delay);
};
const close = () => {
clearTimeout(timer);
if (tip.matches(':popover-open')) tip.hidePopover();
};
trigger.addEventListener('pointerenter', () => open(150));
trigger.addEventListener('pointerleave', close);
trigger.addEventListener('focusin', () => open(0));
trigger.addEventListener('focusout', close);
trigger.addEventListener('click', () => open(0));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
})();
```How this works
Be honest about what popover contributes. The top layer (no clipping inside overflow-hidden articles) and a clean show/hide API — not the trigger. There's no declarative hover invoker, so the script listens to pointerenter/focusin and their counterparts.
trigger.addEventListener('pointerenter', () => open(150));
trigger.addEventListener('pointerleave', close);
trigger.addEventListener('focusin', () => open(0));
trigger.addEventListener('focusout', close);Delay on open, never on close. Without a delay the tooltip flickers as the cursor crosses the paragraph; with a close delay it lingers over whatever the user is now aiming at. Focus is deliberate, so it gets no delay at all.
Manual, not auto. An auto popover light-dismisses on any outside pointer-down and closes other autos — hostile to a hover model. You're already writing the calls, so manual costs nothing.
Make it yours
- Below the word:
[position-area:block-end_center]andmt-2. - Longer intent: raise the
150inopen(150)to 300 for dense UIs. - Interactive content: add a 120ms close delay and listen on the bubble too — but that's a sign it should be a click panel.
Gotchas — read before shipping
- Pointer-only handlers leave keyboard users with nothing — focus parity is half the implementation.
- An auto popover on hover fights light dismiss and can close a menu the user just opened.
role="tooltip"belongs on hover/focus hints only; a click-opened panel is not a tooltip (see demo 20).
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 125+ | 26+ | 131+ | 125+ |
showPopover() ships with the attribute (Chrome 114 / Safari 17 / Firefox 125); anchoring the bubble to the word sets the floor. Without the API the term stays an underlined word and the script feature-guards itself out.