20 CSS Popovers12 / 20
Tooltip on Hover and Focus
Popover's built-in trigger is click, so a hover tooltip has to call showPopover and hidePopover itself. Under twenty-five lines gets pointer and focus parity, an open delay so the tooltip does not fire while the cursor crosses the element, and no close delay so it gets out of the way instantly. This is the case where the API buys you the least.
Published
The code
<div class="pop-12">
<div class="pop-12__stage">
<article class="pop-12__essay">
<p class="pop-12__kicker">Field notes · Typesetting</p>
<h2 class="pop-12__h">The measure of a column</h2>
<p class="pop-12__p">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 class="pop-12__term" type="button" id="pop-12-trigger" aria-describedby="pop-12-tip">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="pop-12__p">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 class="pop-12__tip" popover="manual" id="pop-12-tip" role="tooltip">The number of characters, including spaces, on a single line of set text.</div>
</div><div class="pop-12">
<div class="pop-12__stage">
<article class="pop-12__essay">
<p class="pop-12__kicker">Field notes · Typesetting</p>
<h2 class="pop-12__h">The measure of a column</h2>
<p class="pop-12__p">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 class="pop-12__term" type="button" id="pop-12-trigger" aria-describedby="pop-12-tip">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="pop-12__p">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 class="pop-12__tip" popover="manual" id="pop-12-tip" role="tooltip">The number of characters, including spaces, on a single line of set text.</div>
</div>.pop-12 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--paper: #fbfaf6;
--ink: #1a1a18;
--muted: #6a675e;
--rule: #e3e0d6;
--accent: #8c2f1f;
--font-serif: "Iowan Old Style", Georgia, "Times New Roman", serif;
--font-ui: ui-sans-serif, system-ui, sans-serif;
background: var(--paper);
color: var(--ink);
font-family: var(--font-serif);
padding: 44px 20px;
}
.pop-12__stage {
display: grid;
place-items: center;
max-inline-size: 100%;
min-height: calc(100vh - 88px);
}
.pop-12__essay {
inline-size: 100%;
max-inline-size: 32rem;
min-inline-size: 0;
}
.pop-12__kicker {
margin: 0 0 12px;
font: 700 10px/1 var(--font-ui);
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
}
.pop-12__h {
margin: 0 0 18px;
font: 400 clamp(26px, 6vw, 38px)/1.12 var(--font-serif);
letter-spacing: -0.02em;
}
.pop-12__p {
margin: 0 0 16px;
font: 400 clamp(16px, 3.6vw, 18px)/1.72 var(--font-serif);
color: var(--ink);
text-wrap: pretty;
}
.pop-12__term {
display: inline;
padding: 0;
font: inherit;
color: var(--accent);
background: none;
border: 0;
border-block-end: 1px solid var(--accent);
cursor: help;
anchor-name: --pop-12-anchor;
}
.pop-12__term:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 3px;
border-radius: 2px;
}
.pop-12__tip {
position: fixed;
margin: 0;
inline-size: min(16rem, calc(100vw - 28px));
max-inline-size: calc(100vw - 28px);
padding: 9px 12px;
font: 400 12.5px/1.5 var(--font-ui);
color: var(--paper);
background: var(--ink);
border: 0;
border-radius: 4px;
text-wrap: pretty;
opacity: 0;
transition: opacity 140ms ease, display 140ms allow-discrete, overlay 140ms allow-discrete;
}
.pop-12__tip:popover-open {
opacity: 1;
}
@starting-style {
.pop-12__tip:popover-open {
opacity: 0;
}
}
@supports (anchor-name: --a) {
.pop-12__tip {
position-anchor: --pop-12-anchor;
position-area: block-start center;
justify-self: anchor-center;
inset: auto;
margin-block-end: 8px;
position-try-fallbacks: flip-block;
}
}
@media (prefers-reduced-motion: reduce) {
.pop-12__tip {
transition: none;
opacity: 1;
}
@starting-style {
.pop-12__tip:popover-open {
opacity: 1;
}
}
}
@media (prefers-color-scheme: dark) {
.pop-12 {
--paper: #14140f;
--ink: #f3f1e7;
--muted: #a09a8b;
--rule: #2f2c23;
--accent: #e08a6f;
}
.pop-12__tip {
color: #14140f;
background: var(--ink);
}
}
[data-theme="dark"] .pop-12 {
--paper: #14140f;
--ink: #f3f1e7;
--muted: #a09a8b;
--rule: #2f2c23;
--accent: #e08a6f;
}
[data-theme="dark"] .pop-12 .pop-12__tip {
color: #14140f;
}.pop-12 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--paper: #fbfaf6;
--ink: #1a1a18;
--muted: #6a675e;
--rule: #e3e0d6;
--accent: #8c2f1f;
--font-serif: "Iowan Old Style", Georgia, "Times New Roman", serif;
--font-ui: ui-sans-serif, system-ui, sans-serif;
background: var(--paper);
color: var(--ink);
font-family: var(--font-serif);
padding: 44px 20px;
}
.pop-12__stage {
display: grid;
place-items: center;
max-inline-size: 100%;
min-height: calc(100vh - 88px);
}
.pop-12__essay {
inline-size: 100%;
max-inline-size: 32rem;
min-inline-size: 0;
}
.pop-12__kicker {
margin: 0 0 12px;
font: 700 10px/1 var(--font-ui);
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
}
.pop-12__h {
margin: 0 0 18px;
font: 400 clamp(26px, 6vw, 38px)/1.12 var(--font-serif);
letter-spacing: -0.02em;
}
.pop-12__p {
margin: 0 0 16px;
font: 400 clamp(16px, 3.6vw, 18px)/1.72 var(--font-serif);
color: var(--ink);
text-wrap: pretty;
}
.pop-12__term {
display: inline;
padding: 0;
font: inherit;
color: var(--accent);
background: none;
border: 0;
border-block-end: 1px solid var(--accent);
cursor: help;
anchor-name: --pop-12-anchor;
}
.pop-12__term:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 3px;
border-radius: 2px;
}
.pop-12__tip {
position: fixed;
margin: 0;
inline-size: min(16rem, calc(100vw - 28px));
max-inline-size: calc(100vw - 28px);
padding: 9px 12px;
font: 400 12.5px/1.5 var(--font-ui);
color: var(--paper);
background: var(--ink);
border: 0;
border-radius: 4px;
text-wrap: pretty;
opacity: 0;
transition: opacity 140ms ease, display 140ms allow-discrete, overlay 140ms allow-discrete;
}
.pop-12__tip:popover-open {
opacity: 1;
}
@starting-style {
.pop-12__tip:popover-open {
opacity: 0;
}
}
@supports (anchor-name: --a) {
.pop-12__tip {
position-anchor: --pop-12-anchor;
position-area: block-start center;
justify-self: anchor-center;
inset: auto;
margin-block-end: 8px;
position-try-fallbacks: flip-block;
}
}
@media (prefers-reduced-motion: reduce) {
.pop-12__tip {
transition: none;
opacity: 1;
}
@starting-style {
.pop-12__tip:popover-open {
opacity: 1;
}
}
}
@media (prefers-color-scheme: dark) {
.pop-12 {
--paper: #14140f;
--ink: #f3f1e7;
--muted: #a09a8b;
--rule: #2f2c23;
--accent: #e08a6f;
}
.pop-12__tip {
color: #14140f;
background: var(--ink);
}
}
[data-theme="dark"] .pop-12 {
--paper: #14140f;
--ink: #f3f1e7;
--muted: #a09a8b;
--rule: #2f2c23;
--accent: #e08a6f;
}
[data-theme="dark"] .pop-12 .pop-12__tip {
color: #14140f;
}const trigger = document.getElementById('pop-12-trigger');
const tip = document.getElementById('pop-12-tip');
if (HTMLElement.prototype.hasOwnProperty('popover')) {
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('pop-12-trigger');
const tip = document.getElementById('pop-12-tip');
if (HTMLElement.prototype.hasOwnProperty('popover')) {
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 Popover 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: Tooltip on Hover and Focus
Source: https://codefronts.com/snippets/css-popovers/tooltip-on-hover-and-focus/
Popover's built-in trigger is click, so a hover tooltip has to call showPopover and hidePopover itself. Under twenty-five lines gets pointer and focus parity, an open delay so the tooltip does not fire while the cursor crosses the element, and no close delay so it gets out of the way instantly. This is the case where the API buys you the least.
## HTML
```html
<div class="pop-12">
<div class="pop-12__stage">
<article class="pop-12__essay">
<p class="pop-12__kicker">Field notes · Typesetting</p>
<h2 class="pop-12__h">The measure of a column</h2>
<p class="pop-12__p">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 class="pop-12__term" type="button" id="pop-12-trigger" aria-describedby="pop-12-tip">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="pop-12__p">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 class="pop-12__tip" popover="manual" id="pop-12-tip" role="tooltip">The number of characters, including spaces, on a single line of set text.</div>
</div>
```
## CSS
```css
.pop-12 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--paper: #fbfaf6;
--ink: #1a1a18;
--muted: #6a675e;
--rule: #e3e0d6;
--accent: #8c2f1f;
--font-serif: "Iowan Old Style", Georgia, "Times New Roman", serif;
--font-ui: ui-sans-serif, system-ui, sans-serif;
background: var(--paper);
color: var(--ink);
font-family: var(--font-serif);
padding: 44px 20px;
}
.pop-12__stage {
display: grid;
place-items: center;
max-inline-size: 100%;
min-height: calc(100vh - 88px);
}
.pop-12__essay {
inline-size: 100%;
max-inline-size: 32rem;
min-inline-size: 0;
}
.pop-12__kicker {
margin: 0 0 12px;
font: 700 10px/1 var(--font-ui);
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
}
.pop-12__h {
margin: 0 0 18px;
font: 400 clamp(26px, 6vw, 38px)/1.12 var(--font-serif);
letter-spacing: -0.02em;
}
.pop-12__p {
margin: 0 0 16px;
font: 400 clamp(16px, 3.6vw, 18px)/1.72 var(--font-serif);
color: var(--ink);
text-wrap: pretty;
}
.pop-12__term {
display: inline;
padding: 0;
font: inherit;
color: var(--accent);
background: none;
border: 0;
border-block-end: 1px solid var(--accent);
cursor: help;
anchor-name: --pop-12-anchor;
}
.pop-12__term:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 3px;
border-radius: 2px;
}
.pop-12__tip {
position: fixed;
margin: 0;
inline-size: min(16rem, calc(100vw - 28px));
max-inline-size: calc(100vw - 28px);
padding: 9px 12px;
font: 400 12.5px/1.5 var(--font-ui);
color: var(--paper);
background: var(--ink);
border: 0;
border-radius: 4px;
text-wrap: pretty;
opacity: 0;
transition: opacity 140ms ease, display 140ms allow-discrete, overlay 140ms allow-discrete;
}
.pop-12__tip:popover-open {
opacity: 1;
}
@starting-style {
.pop-12__tip:popover-open {
opacity: 0;
}
}
@supports (anchor-name: --a) {
.pop-12__tip {
position-anchor: --pop-12-anchor;
position-area: block-start center;
justify-self: anchor-center;
inset: auto;
margin-block-end: 8px;
position-try-fallbacks: flip-block;
}
}
@media (prefers-reduced-motion: reduce) {
.pop-12__tip {
transition: none;
opacity: 1;
}
@starting-style {
.pop-12__tip:popover-open {
opacity: 1;
}
}
}
@media (prefers-color-scheme: dark) {
.pop-12 {
--paper: #14140f;
--ink: #f3f1e7;
--muted: #a09a8b;
--rule: #2f2c23;
--accent: #e08a6f;
}
.pop-12__tip {
color: #14140f;
background: var(--ink);
}
}
[data-theme="dark"] .pop-12 {
--paper: #14140f;
--ink: #f3f1e7;
--muted: #a09a8b;
--rule: #2f2c23;
--accent: #e08a6f;
}
[data-theme="dark"] .pop-12 .pop-12__tip {
color: #14140f;
}
```
## JavaScript
```js
const trigger = document.getElementById('pop-12-trigger');
const tip = document.getElementById('pop-12-tip');
if (HTMLElement.prototype.hasOwnProperty('popover')) {
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 Popover 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: Tooltip on Hover and Focus
Source: https://codefronts.com/snippets/css-popovers/tooltip-on-hover-and-focus/
Popover's built-in trigger is click, so a hover tooltip has to call showPopover and hidePopover itself. Under twenty-five lines gets pointer and focus parity, an open delay so the tooltip does not fire while the cursor crosses the element, and no close delay so it gets out of the way instantly. This is the case where the API buys you the least.
## HTML
```html
<div class="pop-12">
<div class="pop-12__stage">
<article class="pop-12__essay">
<p class="pop-12__kicker">Field notes · Typesetting</p>
<h2 class="pop-12__h">The measure of a column</h2>
<p class="pop-12__p">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 class="pop-12__term" type="button" id="pop-12-trigger" aria-describedby="pop-12-tip">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="pop-12__p">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 class="pop-12__tip" popover="manual" id="pop-12-tip" role="tooltip">The number of characters, including spaces, on a single line of set text.</div>
</div>
```
## CSS
```css
.pop-12 {
width: 100%;
min-height: 100vh;
display: block;
box-sizing: border-box;
--paper: #fbfaf6;
--ink: #1a1a18;
--muted: #6a675e;
--rule: #e3e0d6;
--accent: #8c2f1f;
--font-serif: "Iowan Old Style", Georgia, "Times New Roman", serif;
--font-ui: ui-sans-serif, system-ui, sans-serif;
background: var(--paper);
color: var(--ink);
font-family: var(--font-serif);
padding: 44px 20px;
}
.pop-12__stage {
display: grid;
place-items: center;
max-inline-size: 100%;
min-height: calc(100vh - 88px);
}
.pop-12__essay {
inline-size: 100%;
max-inline-size: 32rem;
min-inline-size: 0;
}
.pop-12__kicker {
margin: 0 0 12px;
font: 700 10px/1 var(--font-ui);
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
}
.pop-12__h {
margin: 0 0 18px;
font: 400 clamp(26px, 6vw, 38px)/1.12 var(--font-serif);
letter-spacing: -0.02em;
}
.pop-12__p {
margin: 0 0 16px;
font: 400 clamp(16px, 3.6vw, 18px)/1.72 var(--font-serif);
color: var(--ink);
text-wrap: pretty;
}
.pop-12__term {
display: inline;
padding: 0;
font: inherit;
color: var(--accent);
background: none;
border: 0;
border-block-end: 1px solid var(--accent);
cursor: help;
anchor-name: --pop-12-anchor;
}
.pop-12__term:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 3px;
border-radius: 2px;
}
.pop-12__tip {
position: fixed;
margin: 0;
inline-size: min(16rem, calc(100vw - 28px));
max-inline-size: calc(100vw - 28px);
padding: 9px 12px;
font: 400 12.5px/1.5 var(--font-ui);
color: var(--paper);
background: var(--ink);
border: 0;
border-radius: 4px;
text-wrap: pretty;
opacity: 0;
transition: opacity 140ms ease, display 140ms allow-discrete, overlay 140ms allow-discrete;
}
.pop-12__tip:popover-open {
opacity: 1;
}
@starting-style {
.pop-12__tip:popover-open {
opacity: 0;
}
}
@supports (anchor-name: --a) {
.pop-12__tip {
position-anchor: --pop-12-anchor;
position-area: block-start center;
justify-self: anchor-center;
inset: auto;
margin-block-end: 8px;
position-try-fallbacks: flip-block;
}
}
@media (prefers-reduced-motion: reduce) {
.pop-12__tip {
transition: none;
opacity: 1;
}
@starting-style {
.pop-12__tip:popover-open {
opacity: 1;
}
}
}
@media (prefers-color-scheme: dark) {
.pop-12 {
--paper: #14140f;
--ink: #f3f1e7;
--muted: #a09a8b;
--rule: #2f2c23;
--accent: #e08a6f;
}
.pop-12__tip {
color: #14140f;
background: var(--ink);
}
}
[data-theme="dark"] .pop-12 {
--paper: #14140f;
--ink: #f3f1e7;
--muted: #a09a8b;
--rule: #2f2c23;
--accent: #e08a6f;
}
[data-theme="dark"] .pop-12 .pop-12__tip {
color: #14140f;
}
```
## JavaScript
```js
const trigger = document.getElementById('pop-12-trigger');
const tip = document.getElementById('pop-12-tip');
if (HTMLElement.prototype.hasOwnProperty('popover')) {
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 here. It contributes the top layer and Escape, which spare you a stacking-context audit. It does not contribute the trigger: there is no declarative hover invoker, so pointerenter and focusin call showPopover() and their counterparts call hidePopover(). If you were already happy with a CSS-only :hover tooltip that does not need to escape overflow: hidden, keep it.
Delay on open, never on close. A tooltip that appears the instant the pointer touches an element fires constantly as the cursor crosses the page, so hold it for around 150 milliseconds and cancel the timer if the pointer leaves first. Closing is the opposite: hide immediately, because a tooltip that lingers over the thing the user is now trying to click is worse than no tooltip.
Focus parity is not optional. Keyboard users reach the trigger with Tab, not a pointer, so focusin must show the same hint — and it should appear with no delay, since focus is deliberate. Pair it with a :focus-visible ring on the trigger so the hint has a visible owner.
Use manual, not auto, for a hover tooltip. An auto popover forces other autos closed and light-dismisses on any outside pointer-down, which fights a hover model and can close a menu the user just opened. Manual keeps the tooltip under your control; you are already writing the show and hide calls, so you lose nothing.
Make it yours
- Tune the open delay by changing the 150ms constant in the JS.
- Add a short close delay if the tooltip contains a link the user must reach.
- Switch the bubble from
position-area: block-starttoblock-endto hint below the word. - Give the trigger a dotted underline instead of a solid one for a more editorial cue.
- Retint by editing
--inkand--paper; the bubble takes the ink tone. - Add
aria-describedbypointing at the bubble if the hint is descriptive rather than decorative.
Gotchas — read before shipping
- No delay on open makes the tooltip flicker as the pointer crosses the element; no delay on close is correct, and the two are often confused.
- Handling only pointer events leaves keyboard users with no hint at all —
focusinandfocusoutare half the implementation. - An auto popover on hover fights light dismiss and can close other panels; manual is the right mode when you own the trigger.
- A tooltip that contains interactive content cannot be reached by pointer without a close delay, which is a sign the content belongs in a click-triggered panel.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 125+ | 26+ | 131+ | 125+ |
showPopover() and hidePopover() ship with the popover attribute in Chrome 114, Safari 17 and Firefox 125; the version floor above comes from the anchor positioning that tethers the bubble to the word (Chrome 125, Safari 26, Firefox 131). Where the API is missing entirely, the trigger stays a normal underlined term and the JS feature-guards itself out.