Skip to main content

Harshal V. LADHE

CSS-Only Tooltips with attr() and content

Styled, keyboard-friendly tooltips from one HTML attribute — no JavaScript.
Published at:
Last updated:
Estimated reading time:10 min read

Introduction

When an icon button needs a short hint, the usual options are a JavaScript tooltip library or the native title attribute. A library is a lot of code for one line of text, and title has well-known limits: you can't style it, it appears only after a delay the browser decides, it never shows on keyboard focus, and touch screens can't show it at all.

For the common case — a short label on a button or icon — CSS can do the job on its own. content: attr() copies the value of an HTML attribute into a pseudo-element, and a little positioning turns that pseudo-element into a styled tooltip that appears on hover and on keyboard focus. The text lives in one place, the markup, and there's no JavaScript at all.

How attr() and content Work Together

attr() reads an attribute from the element being styled and returns its value as a string. Used inside the content property of a ::before or ::after pseudo-element, that string is rendered on the page as if it were text inside the element:

<button class="icon-btn" data-tooltip="Save changes" aria-label="Save changes">💾</button>
.icon-btn::after {
  content: attr(data-tooltip); /* renders "Save changes" */
}

A custom data-* attribute is the natural place for the text: it's valid HTML, it has no built-in behaviour, and it doesn't show a second, native tooltip the way title would. Change the attribute and the rendered tooltip changes with it — the CSS never needs to know what the text says.

Building the Tooltip

.icon-btn {
  position: relative; /* the tooltip is positioned against the button */
}

.icon-btn::after {
  content: attr(data-tooltip);
  position: absolute;
  bottom: calc(100% + 0.625rem);
  left: 50%;
  z-index: 1;
  width: max-content;
  max-width: 12rem;
  padding: 0.375rem 0.625rem;
  font-size: 0.75rem;
  color: #fff;
  background: #1d2137;
  border-radius: 0.375rem;
  opacity: 0;
  pointer-events: none;
  transform: translateX(-50%) translateY(0.25rem);
  transition: opacity 0.12s ease, transform 0.12s ease;
}

.icon-btn:hover::after,
.icon-btn:focus-visible::after {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

Each group of declarations has a job:

  • position: relative on the button makes it the containing block, so the absolutely positioned ::after is placed relative to the button rather than the page.
  • bottom: calc(100% + 0.625rem) lifts the tooltip clear of the button's top edge, and left: 50% with translateX(-50%) centres it horizontally, whatever the length of the text.
  • width: max-content with a max-width lets the tooltip size itself to its text. Without it, the tooltip would be squeezed into the width of a small icon button and wrap after every word; the max-width makes long text wrap onto a second line instead of running across the page.
  • opacity: 0 and pointer-events: none hide it. The pointer setting matters because a transparent element is still there: without it, the invisible tooltip would catch hovers and clicks meant for whatever sits underneath.
  • The transition fades the tooltip in while it slides up a few pixels, which reads as "appearing from the button" rather than flashing on.

Adding an Arrow

The element has a second pseudo-element to spare. A ::before with no content and a border that's transparent on three sides draws a small triangle that points from the tooltip back to the button:

.icon-btn::before {
  content: "";
  position: absolute;
  bottom: calc(100% + 0.125rem);
  left: 50%;
  z-index: 1;
  border: 0.25rem solid transparent;
  border-top-color: #1d2137; /* the only visible side: a downward-pointing triangle */
  opacity: 0;
  transform: translateX(-50%);
  transition: opacity 0.12s ease;
}

.icon-btn:hover::before,
.icon-btn:focus-visible::before {
  opacity: 1;
}

The arrow's bottom offset is the tooltip's offset minus the arrow's full height of two 0.25rem borders (0.625rem - 0.5rem), so the flat edge of the triangle sits flush against the tooltip.

Showing It on Keyboard Focus

Look again at the selector that reveals the tooltip:

.icon-btn:hover::after,
.icon-btn:focus-visible::after {
  opacity: 1;
}

:hover on its own serves only mouse and trackpad users. Adding :focus-visible shows the same tooltip when someone reaches the button with the Tab key, so keyboard users get the hint too.

:focus-visible is a better choice here than :focus. Browsers match :focus-visible when focus should be shown — typically after keyboard navigation — but not after a mouse click. With :focus, clicking a button would leave its tooltip stuck open until the user clicked somewhere else.

Flipping It Below the Element

A tooltip near the top of the page has no room above it. Placement is just a matter of which edge you offset from, so an attribute on a parent can switch every tooltip inside it to the other side:

.toolbar[data-tooltip-position="bottom"] .icon-btn::after {
  top: calc(100% + 0.625rem);
  bottom: auto;
}

.toolbar[data-tooltip-position="bottom"] .icon-btn::before {
  top: calc(100% + 0.125rem);
  bottom: auto;
  border-top-color: transparent;
  border-bottom-color: #1d2137; /* now the arrow points up */
}

This is a choice you make in the markup. CSS alone can't measure whether a tooltip would overflow the viewport and flip it automatically — see No collision detection below.

Try It Live

Hover over any button in the toolbar, or Tab to it. Tooltip text switches the source between data-tooltip and aria-label (the next section explains the difference), Tooltip position flips every tooltip above or below its button, and Clip with overflow: hidden shows what happens when a parent cuts the tooltip off. The panel underneath shows the CSS behind the current settings.

attr() Tooltips Playground

Hover over or Tab to any icon button: its tooltip text comes from an HTML attribute via content: attr(). Switch the source between data-tooltip and aria-label, flip tooltips above or below, and see how overflow: hidden clips them.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>attr() Tooltips Playground</title>
  </head>
  <body>
    <main class="playground">
      <header class="playground-header">
        <h1>CSS-Only Tooltips with attr()</h1>
        <p>Hover over a button, or Tab to it. Its tooltip text comes straight from an HTML attribute via <code>content: attr(...)</code>, with no JavaScript.</p>
      </header>

      <section class="demo-area" aria-label="Tooltip preview">
        <div class="toolbar" id="toolbar" data-tooltip-position="top" data-tooltip-source="data-tooltip">
          <button class="icon-btn" type="button" aria-label="Save changes" data-tooltip="Save changes (Ctrl+S)">💾</button>
          <button class="icon-btn" type="button" aria-label="Duplicate" data-tooltip="Duplicate this item (Ctrl+D)">📄</button>
          <button class="icon-btn" type="button" aria-label="Move to bin" data-tooltip="Move to bin (Del)">🗑️</button>
          <button class="icon-btn" type="button" aria-label="Share" data-tooltip="Share with your team">🔗</button>
        </div>
      </section>

      <fieldset class="control-group">
        <legend>Tooltip text</legend>
        <div class="segmented-control" role="radiogroup" aria-label="Tooltip text">
          <label class="segment"><input type="radio" name="source" value="data-tooltip" checked><span>data-tooltip</span></label>
          <label class="segment"><input type="radio" name="source" value="aria-label"><span>aria-label</span></label>
        </div>
      </fieldset>

      <fieldset class="control-group">
        <legend>Tooltip position</legend>
        <div class="segmented-control" role="radiogroup" aria-label="Tooltip position">
          <label class="segment"><input type="radio" name="position" value="top" checked><span>Top</span></label>
          <label class="segment"><input type="radio" name="position" value="bottom"><span>Bottom</span></label>
        </div>

        <label class="checkbox-field">
          <input type="checkbox" id="clip-toggle">
          Clip with <code>overflow: hidden</code> on the toolbar
        </label>
      </fieldset>

      <output id="readout" class="readout" aria-live="polite"></output>
    </main>

    <script src="./index.js"></script>
  </body>
</html>

Read-only
Ln –, Col –HTML2.3 KBUTF-8

Starting sandbox…

No console output yet.

One Attribute Instead of Two

Icon-only buttons need an accessible name, so the examples above carry the same text twice: once in data-tooltip for the eye, once in aria-label for assistive technology. When the tooltip is the button's label, you can remove the duplicate and read the label directly:

<button class="icon-btn" aria-label="Save changes">💾</button>
.icon-btn[aria-label]::after {
  content: attr(aria-label);
}

Now the visible tooltip and the accessible name can never drift apart, which also satisfies the WCAG guideline that a control's visible label should match its accessible name.

Keep data-tooltip for tooltips that add something beyond the name, such as a keyboard shortcut:

<button class="icon-btn" aria-label="Save changes" data-tooltip="Save changes (Ctrl+S)">💾</button>

Screen reader users won't hear that extra text, though, because aria-label replaces the button's content (generated content included) when the accessible name is worked out. If the extra information matters, put it in an element the button references with aria-describedby as well.

The opposite applies to a button with visible text and no aria-label. Its accessible name is computed from its content, and generated content counts, so <button data-tooltip="Ctrl+S">Save</button> is announced as "Save Ctrl+S". Give the tooltip empty alternative text to keep it out of the name — the same fix the layered gradient text technique uses for its duplicated heading:

.text-btn::after {
  content: attr(data-tooltip); /* fallback for browsers without alt text support */
  content: attr(data-tooltip) / "";
}

Respecting Reduced Motion

The slide is decorative, so drop it for readers who've asked for less motion. The tooltip still fades in, which is gentle enough that it's usually kept:

@media (prefers-reduced-motion: reduce) {
  .icon-btn::after {
    transform: translateX(-50%);
    transition: opacity 0.12s ease;
  }

  .icon-btn:hover::after,
  .icon-btn:focus-visible::after {
    transform: translateX(-50%);
  }
}

Accessibility: What a CSS Tooltip Can't Do

A CSS-only tooltip is a visual hint. It isn't the ARIA tooltip pattern, and it's worth knowing exactly where it falls short before you rely on it:

  • Screen readers don't announce it as a tooltip. There's no role="tooltip" and no aria-describedby relationship, and nothing tells assistive technology that text appeared on hover. The accessible name has to come from somewhere else — aria-label here.
  • It can't be dismissed with Esc. WCAG 1.4.13 (Content on Hover or Focus) expects content that appears on hover or focus to be dismissible without moving the pointer or focus. A short tooltip that doesn't cover anything else is exempt from that part of the rule; one that can hide other content isn't, and handling Esc needs JavaScript.
  • You can't hover the tooltip itself. The same criterion expects users to be able to move the pointer onto the revealed content — someone using screen magnification may need to pan across it. With pointer-events: none and a gap between button and tooltip, moving onto it hides it.
  • Touch screens have no hover. Mobile browsers may emulate :hover for a moment after a tap, but not reliably, and a tap usually doesn't trigger :focus-visible. Never put information only in the tooltip.

Browser Support

The content: attr() technique works in every browser in use today; it has been part of CSS since CSS 2.1.

What doesn't work everywhere is the newer, typed attr(), which can return a number, a length or a colour and be used in any property, not just content — for example width: attr(data-size type(<length>)). It's supported in Chromium browsers (Chrome and Edge 133 and later) but not yet in Firefox or Safari at the time of writing, and HTML Attributes as Real CSS Values with Typed attr() covers it in depth. The tooltip technique doesn't depend on it: reading a string into content is the original form of attr().

Common Pitfalls

1. Pseudo-Elements on Images and Inputs

<img>, <input> and other replaced or void elements don't render ::before or ::after, so a data-tooltip on them does nothing. Put the attribute on a wrapping element, or on the button or link around the image.

2. Clipped by overflow: hidden

The tooltip is part of the button, so any ancestor with overflow: hidden (or auto, or scroll) clips it at that ancestor's edge. Toolbars inside scrolling panels and cards with rounded, clipped corners are the usual culprits — try the Clip with overflow: hidden option in the playground to see it happen.

3. Hidden Behind Neighbouring Content

z-index: 1 lifts the tooltip above the button's siblings, but only within the stacking context the button belongs to. If a parent has its own stacking context — from a transform, an opacity below 1, or an explicit z-index — no z-index on the tooltip can raise it above content outside that parent.

4. No Collision Detection

A CSS-only tooltip always appears where you put it. Near the edge of the viewport it can be cut off, and nothing will move it back into view. CSS anchor positioning and its position-try fallbacks solve this in supporting browsers; a JavaScript positioning library solves it everywhere.

5. Flicker Across a Toolbar

Sweeping the pointer along a row of buttons flashes every tooltip on the way. A short delay on the showing transition only — not the hiding one — fixes it, and gives the tooltip the intentional feel of a native one:

.icon-btn:hover::after {
  transition-delay: 0.3s;
}

When to Use This, and When Not To

A CSS-only tooltip works well for:

  • Icon-only buttons in toolbars, editors and compact action rows, where the tooltip repeats the accessible name
  • Places where title isn't good enough but a tooltip library would be more code than the feature deserves
  • Showing the full text of a truncated title on hover and focus — on a wrapper, since the title's own overflow: hidden would clip the tooltip
  • Prototypes and internal tools that need a quick, consistent hint

Reach for something else when:

  • The tooltip holds information a user can't do without
  • It needs interactive content, such as a link or a button
  • It has to stay within the viewport automatically, or escape a clipping parent — the Popover API places content in the top layer, above every stacking context and outside every overflow clip

Key Takeaway

content: attr(data-tooltip) on a ::after, positioned against its element and revealed on :hover and :focus-visible, gives you a styled tooltip with no JavaScript and the text written once, in the markup. Read from aria-label when the tooltip is the label, respect reduced motion, and treat it as a visual hint — the moment a tooltip carries information someone genuinely needs, build a real one.

Changelog

  • — Added cross-reference to tutorial 'Colouring a CSS `text-overflow` Ellipsis Separately from Its Text'
  • — Initial publication.
This tutorial is licensed under CC BY 4.0 by the author.

Share this tutorial

Spotted a mistake or something outdated in this tutorial? Suggest a correction.

  • HTML Attributes as Real CSS Values with Typed attr()

    Read HTML attributes as CSS colours, lengths and numbers.
    Typed attr() reads an HTML attribute as a real CSS value — type(<color>), type(<integer>), a unit like % or px, or raw-string — for any property on any element, not just text in content. Covers fallbacks for missing or invalid values, patterns for layout, transitions and view transitions, safe fallbacks in browsers without support, and when a custom property is the better choice.
    Published at:
  • Theming Native Controls with accent-color

    Theme native checkboxes, radios and sliders with one property.
    The accent-color property themes the checked and filled parts of native checkboxes, radio buttons, switches, range sliders and progress bars — including their indeterminate states — while keeping real browser controls with full native behaviour and accessibility, plus how it pairs with color-scheme and what it leaves alone.
    Published at:
  • Styling a <select> Dropdown with CSS appearance: base-select

    Style a real select's dropdown, options and arrow.
    appearance: base-select opts a native <select> into a fully styleable mode — rich HTML in options, <selectedcontent> in the closed button, and ::picker(select), ::picker-icon and ::checkmark for the dropdown, arrow and tick — while keeping its keyboard support, accessibility and form behaviour, with a plain select as the fallback.
    Published at: