20 CSS Popovers12 / 20

Light JSMIT licensed

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

Live Demo
Try it

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>
.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(); });
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
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-start to block-end to hint below the word.
  • Give the trigger a dotted underline instead of a solid one for a more editorial cue.
  • Retint by editing --ink and --paper; the bubble takes the ink tone.
  • Add aria-describedby pointing 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 — focusin and focusout are 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

ChromeSafariFirefoxEdge
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.

Techniques used in this demo

Search CodeFronts

Loading…