CSS Tooltip: 17 Examples + Anchor Positioning

Icon-only buttons are fine until someone has to guess what the icon does. A CSS tooltip fixes that with a positioned pseudo-element, a data attribute and a short fade, but WCAG 1.4.13 also wants it dismissible, hoverable and persistent.

Below are 17 CSS tooltip examples you can try and copy, followed by a guide to positioning, accessibility and anchor positioning.

By Bogdan Sandu · Updated October 27, 2026

Pure CSS tooltips

Six tooltips with no script behind the reveal. Each one opens on hover and on keyboard focus and stays open while the pointer rests on the bubble. CSS cannot react to a key press, so a few lines of script add the Escape dismissal WCAG 1.4.13 asks for: an is-dismissed class hides the bubble until pointer and focus have left.

Data attribute tooltip on an icon rail

  • attr()
  • ::after
  • :focus-visible

Good morning, Mara

Three new messages from the Halden team and one report waiting for review.

Hover a control, or press Tab to reach it. Escape hides an open tip.

The classic recipe: content: attr(data-tip) fills a pseudo-element, and :hover plus :focus-visible reveal it. Screen readers do not reliably announce generated content, so every button also carries an aria-label, and the bubble only repeats it for sighted users. The arrow fills the gap between button and bubble, which keeps the pointer inside the hover area on its way across.

View code
.tt-rail-app {
  display: flex;
  width: min(100%, 460px);
  min-height: 280px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 18px 40px rgba(20, 33, 61, .12);
}
.tt-rail {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 14px 10px;
  border-radius: 18px 0 0 18px;
  background: #14213d;
}
.tt-rail__btn {
  position: relative;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 12px;
  background: transparent;
  color: #c9d3ea;
  font: inherit;
  cursor: pointer;
}
.tt-rail__btn svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.tt-rail__btn:hover,
.tt-rail__btn:focus-visible {
  background: #24345c;
  color: #fff;
}
.tt-rail__btn[aria-current="page"] {
  background: #fca311;
  color: #14213d;
}
.tt-rail__btn:focus-visible {
  outline: 3px solid #fca311;
  outline-offset: 2px;
}
.tt-rail__btn::before,
.tt-rail__btn::after {
  position: absolute;
  top: 50%;
  z-index: 2;
  opacity: 0;
  visibility: hidden;
  transition: opacity .15s ease, visibility .15s;
}
.tt-rail__btn::before {
  content: "";
  left: 100%;
  width: 10px;
  height: 12px;
  background: #0b1220;
  clip-path: polygon(100% 0, 100% 100%, 30% 50%);
  transform: translateY(-50%);
}
.tt-rail__btn::after {
  content: attr(data-tip);
  left: calc(100% + 10px);
  padding: 7px 11px;
  border-radius: 8px;
  background: #0b1220;
  color: #fff;
  font-size: 13px;
  font-weight: 600;
  line-height: 1.3;
  white-space: nowrap;
  transform: translateY(-50%);
}
.tt-rail__btn:hover::before,
.tt-rail__btn:hover::after,
.tt-rail__btn:focus-visible::before,
.tt-rail__btn:focus-visible::after {
  opacity: 1;
  visibility: visible;
}
.tt-rail__main {
  flex: 1;
  min-width: 0;
  padding: 22px 20px;
}
.tt-rail__title {
  margin: 0 0 6px;
  color: #14213d;
  font-size: 19px;
  font-weight: 700;
}
.tt-rail__text {
  margin: 0 0 18px;
  color: #4a5568;
  font-size: 14px;
  line-height: 1.55;
}
.tt-rail__cards {
  display: grid;
  gap: 10px;
}
.tt-rail__cards span {
  height: 34px;
  border-radius: 10px;
  background: #eef1f7;
}
.tt-rail__cards span:first-child {
  height: 58px;
  background: #fff4e0;
}
.tt-rail .tt-rail__btn.is-dismissed::before,
.tt-rail .tt-rail__btn.is-dismissed::after {
  opacity: 0;
  visibility: hidden;
}

Real tooltip elements with aria-describedby

  • role="tooltip"
  • aria-describedby
  • hover bridge

Night Ferry

Halden Coast

Shuffle is off Previous: Tidewater Play, 4:12 left Next: Lantern Room Repeat the album

Hover a control, or press Tab to reach it. Escape hides an open tip.

The accessible version of the first demo. Each bubble is a real span with role="tooltip" and an id, and the button points at it with aria-describedby, so a screen reader reads the name first and the tip as the description. A transparent ::after strip under each bubble bridges the gap, so moving the pointer up onto the text never closes it.

View code
.tt-pl {
  width: min(100%, 340px);
  padding: 22px 18px 24px;
  border-radius: 24px;
  background: #241b33;
  color: #f4ecff;
  text-align: center;
}
.tt-pl__art {
  display: grid;
  place-items: center;
  width: 128px;
  aspect-ratio: 1;
  margin: 0 auto 16px;
  border-radius: 18px;
  background: #e8b04b;
}
.tt-pl__art span {
  width: 64px;
  height: 64px;
  border: 10px solid #241b33;
  border-radius: 50%;
}
.tt-pl__track {
  margin: 0;
  font-size: 18px;
  font-weight: 700;
}
.tt-pl__artist {
  margin: 2px 0 14px;
  color: #c9b8e6;
  font-size: 14px;
}
.tt-pl__bar {
  height: 4px;
  margin: 0 6px 18px;
  border-radius: 4px;
  background: #4a3a63;
}
.tt-pl__bar span {
  display: block;
  width: 38%;
  height: 100%;
  border-radius: 4px;
  background: #e8b04b;
}
.tt-pl__ctls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 4px;
}
.tt-pl__ctl {
  position: relative;
  display: inline-flex;
}
.tt-pl__btn {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: #f4ecff;
  font: inherit;
  cursor: pointer;
}
.tt-pl__btn svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.tt-pl__btn--play {
  width: 60px;
  height: 60px;
  background: #e8b04b;
  color: #241b33;
}
.tt-pl__btn--play svg {
  fill: currentColor;
}
.tt-pl__btn:hover {
  background: #3a2c50;
}
.tt-pl__btn--play:hover {
  background: #f0c46e;
}
.tt-pl__btn:focus-visible {
  outline: 3px solid #e8b04b;
  outline-offset: 2px;
}
.tt-pl__tip {
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  z-index: 2;
  padding: 7px 11px;
  border-radius: 8px;
  background: #fff6e5;
  color: #241b33;
  font-size: 13px;
  font-weight: 600;
  line-height: 1.3;
  white-space: nowrap;
  transform: translateX(-50%);
  opacity: 0;
  visibility: hidden;
  transition: opacity .15s ease, visibility .15s;
}
.tt-pl__tip::before {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  border: 6px solid transparent;
  border-top-color: #fff6e5;
  transform: translateX(-50%);
}
.tt-pl__tip::after {
  content: "";
  position: absolute;
  top: 100%;
  right: 0;
  left: 0;
  height: 12px;
}
.tt-pl__ctl:first-child .tt-pl__tip {
  left: 0;
  transform: none;
}
.tt-pl__ctl:first-child .tt-pl__tip::before {
  left: 16px;
}
.tt-pl__ctl:last-child .tt-pl__tip {
  right: 0;
  left: auto;
  transform: none;
}
.tt-pl__ctl:last-child .tt-pl__tip::before {
  left: auto;
  right: 10px;
}
.tt-pl__ctl:hover .tt-pl__tip,
.tt-pl__btn:focus-visible + .tt-pl__tip {
  opacity: 1;
  visibility: visible;
}
.tt-pl .tt-pl__ctl.is-dismissed .tt-pl__tip {
  opacity: 0;
  visibility: hidden;
}

Four placements with arrows

  • modifier classes
  • border triangle
  • transform
Rotate 90 degrees Redo Crop to 1:1 Undo

Hover a control, or press Tab to reach it. Escape hides an open tip.

One base rule hides and styles every bubble, and a modifier class per side swaps the offset pair: bottom: 100% for the top, left: 100% for the right, and so on. The arrow is a zero-size box whose one colored border points back at the button, and each side moves that colored border to the matching edge.

View code
.tt-side {
  padding: 58px 80px;
}
.tt-side__frame {
  position: relative;
  width: 140px;
  height: 140px;
}
.tt-side__photo {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 14px;
  box-shadow: 0 14px 30px rgba(19, 78, 74, .18);
}
.tt-side__item {
  position: absolute;
  display: inline-flex;
}
.tt-side__item--top {
  top: 0;
  left: 50%;
  transform: translate(-50%, -50%);
}
.tt-side__item--right {
  top: 50%;
  right: 0;
  transform: translate(50%, -50%);
}
.tt-side__item--bottom {
  bottom: 0;
  left: 50%;
  transform: translate(-50%, 50%);
}
.tt-side__item--left {
  top: 50%;
  left: 0;
  transform: translate(-50%, -50%);
}
.tt-side__btn {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 2px solid #134e4a;
  border-radius: 50%;
  background: #fff;
  color: #134e4a;
  font: inherit;
  cursor: pointer;
}
.tt-side__btn svg {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.tt-side__btn:hover {
  background: #e3f3f1;
}
.tt-side__btn:focus-visible {
  outline: 3px solid #0f766e;
  outline-offset: 2px;
}
.tt-side__tip {
  position: absolute;
  z-index: 2;
  padding: 6px 10px;
  border-radius: 7px;
  background: #134e4a;
  color: #fff;
  font-size: 13px;
  font-weight: 600;
  line-height: 1.3;
  white-space: nowrap;
  opacity: 0;
  visibility: hidden;
  transition: opacity .15s ease, visibility .15s;
}
.tt-side__tip::before {
  content: "";
  position: absolute;
  border: 6px solid transparent;
}
.tt-side__tip::after {
  content: "";
  position: absolute;
}
.tt-side__item--top .tt-side__tip {
  bottom: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%);
}
.tt-side__item--top .tt-side__tip::before {
  top: 100%;
  left: 50%;
  border-top-color: #134e4a;
  transform: translateX(-50%);
}
.tt-side__item--top .tt-side__tip::after {
  top: 100%;
  right: 0;
  left: 0;
  height: 12px;
}
.tt-side__item--bottom .tt-side__tip {
  top: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%);
}
.tt-side__item--bottom .tt-side__tip::before {
  bottom: 100%;
  left: 50%;
  border-bottom-color: #134e4a;
  transform: translateX(-50%);
}
.tt-side__item--bottom .tt-side__tip::after {
  bottom: 100%;
  right: 0;
  left: 0;
  height: 12px;
}
.tt-side__item--right .tt-side__tip {
  top: 50%;
  left: calc(100% + 10px);
  transform: translateY(-50%);
}
.tt-side__item--right .tt-side__tip::before {
  top: 50%;
  right: 100%;
  border-right-color: #134e4a;
  transform: translateY(-50%);
}
.tt-side__item--right .tt-side__tip::after {
  top: 0;
  bottom: 0;
  right: 100%;
  width: 12px;
}
.tt-side__item--left .tt-side__tip {
  top: 50%;
  right: calc(100% + 10px);
  transform: translateY(-50%);
}
.tt-side__item--left .tt-side__tip::before {
  top: 50%;
  left: 100%;
  border-left-color: #134e4a;
  transform: translateY(-50%);
}
.tt-side__item--left .tt-side__tip::after {
  top: 0;
  bottom: 0;
  left: 100%;
  width: 12px;
}
.tt-side__item:hover .tt-side__tip,
.tt-side__btn:focus-visible + .tt-side__tip {
  opacity: 1;
  visibility: visible;
}
.tt-side .tt-side__item.is-dismissed .tt-side__tip {
  opacity: 0;
  visibility: hidden;
}

Delay in, grace period out

  • transition-delay
  • visibility
  • aria-labelledby
  • Notes
  • Mail
  • Maps
  • Music
  • Photos

400 ms delay in, 150 ms grace out

Hover a control, or press Tab to reach it. Escape hides an open tip.

The open rule waits 400 ms, so labels do not flash while the pointer sweeps across the dock. The closed rule waits 150 ms before fading, which gives the pointer time to cross onto the label. Visibility switches with its own delay, so the label leaves hit testing only after the fade has finished.

View code
.tt-dock {
  padding-top: 64px;
  text-align: center;
}
.tt-dock__bar {
  display: flex;
  gap: 8px;
  margin: 0;
  padding: 10px;
  border: 1px solid #dfe3ec;
  border-radius: 22px;
  background: #fff;
  box-shadow: 0 16px 36px rgba(15, 23, 42, .12);
  list-style: none;
}
.tt-dock__item {
  position: relative;
  display: flex;
}
.tt-dock__app {
  display: grid;
  place-items: center;
  width: clamp(44px, 12vw, 54px);
  aspect-ratio: 1;
  padding: 0;
  border: 0;
  border-radius: 14px;
  color: #fff;
  font: inherit;
  cursor: pointer;
}
.tt-dock__app svg {
  width: 26px;
  height: 26px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.tt-dock__app--notes {
  background: #f2c94c;
  color: #3d2f00;
}
.tt-dock__app--mail {
  background: #2f6fde;
}
.tt-dock__app--maps {
  background: #2e8b57;
}
.tt-dock__app--music {
  background: #d6336c;
}
.tt-dock__app--photos {
  background: #6741d9;
}
.tt-dock__app:focus-visible {
  outline: 3px solid #1f2937;
  outline-offset: 3px;
}
.tt-dock__tip {
  position: absolute;
  bottom: 100%;
  left: 50%;
  z-index: 2;
  padding-bottom: 12px;
  transform: translateX(-50%);
  opacity: 0;
  visibility: hidden;
  transition: opacity .15s ease .15s, visibility 0s .3s;
}
.tt-dock__label {
  position: relative;
  display: block;
  padding: 6px 11px;
  border-radius: 8px;
  background: #1f2937;
  color: #fff;
  font-size: 13px;
  font-weight: 600;
  line-height: 1.3;
  white-space: nowrap;
}
.tt-dock__label::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  border: 6px solid transparent;
  border-top-color: #1f2937;
  transform: translateX(-50%);
}
.tt-dock__item:hover .tt-dock__tip,
.tt-dock__app:focus-visible + .tt-dock__tip {
  opacity: 1;
  visibility: visible;
  transition: opacity .15s ease .4s, visibility 0s .4s;
}
.tt-dock__caption {
  margin: 16px 0 0;
  color: #4b5563;
  font-size: 13px;
}
.tt-dock .tt-dock__item.is-dismissed .tt-dock__tip {
  opacity: 0;
  visibility: hidden;
  transition: opacity .15s ease, visibility 0s .15s;
}

Avatar stack with an opt-in scale animation

  • prefers-reduced-motion
  • transform-origin
  • aria-labelledby
  • Ines Duarte Design lead
  • Tomas Okafor Front-end
  • Mei Sato Copywriter
  • Rafael Vidal Product
  • 4 more Ada, Noor, Pim, Lev

Reviewing this file now

Hover a control, or press Tab to reach it. Escape hides an open tip.

Every avatar is a button labelled by its own tooltip, so the name lives in one place. The fade runs for everyone, while the grow-from-the-arrow scale sits inside a prefers-reduced-motion: no-preference query. transform-origin at the bottom center makes the bubble appear to come out of the arrow tip.

View code
.tt-av {
  padding: 78px 26px 24px;
  border-radius: 20px;
  background: #fff;
  box-shadow: 0 14px 34px rgba(30, 41, 59, .1);
  text-align: center;
}
.tt-av__label {
  margin: 12px 0 0;
  color: #475569;
  font-size: 14px;
  font-weight: 600;
}
.tt-av__stack {
  display: flex;
  justify-content: center;
  margin: 0;
  padding: 0;
  list-style: none;
}
.tt-av__item {
  position: relative;
  display: flex;
}
.tt-av__item + .tt-av__item {
  margin-left: -12px;
}
.tt-av__item:hover,
.tt-av__item:focus-within {
  z-index: 3;
}
.tt-av__btn {
  width: 52px;
  height: 52px;
  padding: 0;
  border: 3px solid #fff;
  border-radius: 50%;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
}
.tt-av__btn--a {
  background: #f4a261;
  color: #3b1d06;
}
.tt-av__btn--b {
  background: #2a9d8f;
  color: #fff;
}
.tt-av__btn--c {
  background: #e9c46a;
  color: #3d2e00;
}
.tt-av__btn--d {
  background: #264653;
  color: #fff;
}
.tt-av__btn--more {
  background: #e2e8f0;
  color: #1e293b;
}
.tt-av__btn:focus-visible {
  outline: 3px solid #264653;
  outline-offset: 2px;
}
.tt-av__tip {
  position: absolute;
  bottom: 100%;
  left: 50%;
  padding-bottom: 10px;
  transform: translateX(-50%);
  opacity: 0;
  visibility: hidden;
  transition: opacity .16s ease, visibility .16s;
}
.tt-av__bubble {
  position: relative;
  display: block;
  padding: 7px 12px;
  border-radius: 10px;
  background: #1e293b;
  color: #cbd5e1;
  font-size: 12.5px;
  line-height: 1.35;
  white-space: nowrap;
  transform-origin: 50% calc(100% + 6px);
}
.tt-av__bubble strong {
  display: block;
  color: #fff;
  font-size: 13.5px;
}
.tt-av__bubble::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  border: 6px solid transparent;
  border-top-color: #1e293b;
  transform: translateX(-50%);
}
.tt-av__item:hover .tt-av__tip,
.tt-av__btn:focus-visible + .tt-av__tip {
  opacity: 1;
  visibility: visible;
}
@media (prefers-reduced-motion: no-preference) {
  .tt-av__bubble {
    transform: scale(.8) translateY(4px);
    transition: transform .2s cubic-bezier(.2, .9, .3, 1.3);
  }
  .tt-av__item:hover .tt-av__bubble,
  .tt-av__btn:focus-visible + .tt-av__tip .tt-av__bubble {
    transform: none;
  }
}
.tt-av .tt-av__item.is-dismissed .tt-av__tip {
  opacity: 0;
  visibility: hidden;
}

Light and dark theme tooltips

  • light-dark()
  • color-scheme
  • :has()

Storage

82 GB of 120 GB used

38 GB free

Photos, 42 GB Video, 31 GB Documents, 9 GB

Hover a control, or press Tab to reach it. Escape hides an open tip.

A tooltip should contrast with the surface it sits on, so it inverts with the theme: dark on the light card, light on the dark one. The colors are light-dark() pairs, and the toggle only flips color-scheme through :has(). Browsers without light-dark() get the same palette from plain custom properties.

View code
.tt-th {
  --tt-th-bg: #fff;
  --tt-th-text: #1f2430;
  --tt-th-muted: #5b6474;
  --tt-th-track: #e7eaf0;
  --tt-th-tip-bg: #1f2430;
  --tt-th-tip-text: #fff;
  --tt-th-ring: #3b5bdb;
  width: min(100%, 380px);
  padding: 22px 22px 64px;
  border-radius: 20px;
  background: var(--tt-th-bg);
  color: var(--tt-th-text);
  box-shadow: 0 14px 34px rgba(15, 23, 42, .12);
  color-scheme: light;
}
@supports (color: light-dark(#000, #fff)) {
  .tt-th {
    --tt-th-bg: light-dark(#fff, #151923);
    --tt-th-text: light-dark(#1f2430, #eef1f7);
    --tt-th-muted: light-dark(#5b6474, #a9b1c2);
    --tt-th-track: light-dark(#e7eaf0, #2a3040);
    --tt-th-tip-bg: light-dark(#1f2430, #f4f6fb);
    --tt-th-tip-text: light-dark(#fff, #151923);
    --tt-th-ring: light-dark(#3b5bdb, #91a7ff);
  }
  .tt-th:has(.tt-th__toggle[aria-pressed="true"]) {
    color-scheme: dark;
  }
}
@supports not (color: light-dark(#000, #fff)) {
  .tt-th:has(.tt-th__toggle[aria-pressed="true"]) {
    --tt-th-bg: #151923;
    --tt-th-text: #eef1f7;
    --tt-th-muted: #a9b1c2;
    --tt-th-track: #2a3040;
    --tt-th-tip-bg: #f4f6fb;
    --tt-th-tip-text: #151923;
    --tt-th-ring: #91a7ff;
    color-scheme: dark;
  }
}
.tt-th__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.tt-th__title {
  margin: 0;
  font-size: 18px;
  font-weight: 700;
}
.tt-th__toggle {
  min-height: 36px;
  padding: 0 14px;
  border: 1px solid var(--tt-th-muted);
  border-radius: 999px;
  background: transparent;
  color: var(--tt-th-text);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.tt-th__toggle[aria-pressed="true"] {
  background: var(--tt-th-text);
  color: var(--tt-th-bg);
}
.tt-th__nums {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 12px;
  margin: 14px 0 12px;
}
.tt-th__used {
  margin: 0;
  color: var(--tt-th-muted);
  font-size: 14px;
}
.tt-th__used strong {
  color: var(--tt-th-text);
  font-size: 22px;
}
.tt-th__meter {
  display: flex;
  gap: 3px;
  height: 16px;
  padding: 0;
  border-radius: 8px;
  background: var(--tt-th-track);
}
.tt-th__seg {
  position: relative;
  display: flex;
}
.tt-th__seg--photos {
  flex: 0 0 35%;
}
.tt-th__seg--video {
  flex: 0 0 25.8%;
}
.tt-th__seg--docs {
  flex: 0 0 7.5%;
}
.tt-th__part {
  flex: 1;
  min-width: 24px;
  padding: 0;
  border: 0;
  border-radius: 8px;
  cursor: pointer;
}
.tt-th__seg--photos .tt-th__part {
  background: #4c6ef5;
}
.tt-th__seg--video .tt-th__part {
  background: #f76707;
}
.tt-th__seg--docs .tt-th__part {
  background: #12b886;
}
.tt-th__part:focus-visible {
  outline: 3px solid var(--tt-th-ring);
  outline-offset: 3px;
}
.tt-th__tip {
  position: absolute;
  top: calc(100% + 10px);
  left: 0;
  padding: 6px 10px;
  border-radius: 8px;
  background: var(--tt-th-tip-bg);
  color: var(--tt-th-tip-text);
  font-size: 13px;
  font-weight: 600;
  white-space: nowrap;
  opacity: 0;
  visibility: hidden;
  transition: opacity .15s ease, visibility .15s;
}
.tt-th__tip::before {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 12px;
  border: 6px solid transparent;
  border-bottom-color: var(--tt-th-tip-bg);
}
.tt-th__tip::after {
  content: "";
  position: absolute;
  bottom: 100%;
  right: 0;
  left: 0;
  height: 12px;
}
.tt-th__seg--docs .tt-th__tip {
  right: 0;
  left: auto;
}
.tt-th__seg--docs .tt-th__tip::before {
  right: 6px;
  left: auto;
}
.tt-th__seg:hover .tt-th__tip,
.tt-th__part:focus-visible + .tt-th__tip {
  opacity: 1;
  visibility: visible;
}
.tt-th__free {
  margin: 0;
  color: var(--tt-th-muted);
  font-size: 13px;
}
.tt-th .tt-th__seg.is-dismissed .tt-th__tip {
  opacity: 0;
  visibility: hidden;
}

Accessible tooltips and toggletips

Tooltips that close on Escape, as WCAG 1.4.13 asks when a bubble covers other content, plus the click-to-open toggletips that replace them on touch screens and whenever the content has to be interactive.

Escape to dismiss on status checks

  • WCAG 1.4.13
  • keydown
  • :has(:focus-visible)

Fix currency rounding in invoices #482

Opened by Tomas Okafor, 3 checks of 4 finished

  • Lint Passed in 42 s
  • Unit tests 318 passed in 2 min 6 s
  • Build Failed: missing env BILLING_REGION
  • Deploy preview Waiting for build

Hover or Tab to a control, then press Escape.

CSS opens the bubbles, and a few lines of script add the one thing CSS cannot: Escape adds an is-dismissed class that wins over :hover and focus, and leaving the item clears it again. The keydown listener sits on document, so Escape also works while the pointer hovers and focus sits elsewhere, and it only touches items in this component that are hovered or focused.

View code
.tt-ci {
  width: min(100%, 420px);
  padding: 20px 20px 84px;
  border: 1px solid #d0d7de;
  border-radius: 14px;
  background: #fff;
}
.tt-ci__title {
  margin: 0;
  color: #1f2328;
  font-size: 17px;
  font-weight: 700;
  line-height: 1.35;
}
.tt-ci__title span {
  color: #59636e;
  font-weight: 400;
}
.tt-ci__meta {
  margin: 4px 0 16px;
  color: #59636e;
  font-size: 13.5px;
}
.tt-ci__list {
  position: relative;
  display: flex;
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.tt-ci__item {
  display: flex;
}
.tt-ci__btn {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  font: inherit;
  cursor: pointer;
}
.tt-ci__btn svg {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.tt-ci__item--pass .tt-ci__btn {
  background: #dafbe1;
  color: #116329;
}
.tt-ci__item--fail .tt-ci__btn {
  background: #ffebe9;
  color: #a40e26;
}
.tt-ci__item--wait .tt-ci__btn {
  background: #fff8c5;
  color: #7d4e00;
}
.tt-ci__btn:focus-visible {
  outline: 3px solid #0969da;
  outline-offset: 2px;
}
.tt-ci__tip {
  position: absolute;
  top: calc(100% + 10px);
  right: 0;
  left: 0;
  z-index: 2;
  padding: 8px 11px;
  border-radius: 8px;
  background: #24292f;
  color: #d1d9e0;
  font-size: 13px;
  line-height: 1.4;
  opacity: 0;
  visibility: hidden;
  transition: opacity .15s ease, visibility .15s;
}
.tt-ci__tip strong {
  display: block;
  color: #fff;
}
.tt-ci__tip::before {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 14px;
  border: 6px solid transparent;
  border-bottom-color: #24292f;
}
.tt-ci__tip::after {
  content: "";
  position: absolute;
  bottom: 100%;
  right: 0;
  left: 0;
  height: 12px;
}
.tt-ci__item:nth-child(2) .tt-ci__tip::before {
  left: 64px;
}
.tt-ci__item:nth-child(3) .tt-ci__tip::before {
  left: 114px;
}
.tt-ci__item:nth-child(4) .tt-ci__tip::before {
  left: 164px;
}
.tt-ci__item:is(:hover, :has(:focus-visible)):not(.is-dismissed) .tt-ci__tip {
  opacity: 1;
  visibility: visible;
}
.tt-ci__list:has(.tt-ci__item:hover) .tt-ci__item:not(:hover) .tt-ci__tip {
  opacity: 0;
  visibility: hidden;
}

Keyboard shortcut tooltips

  • kbd
  • aria-keyshortcuts
  • aria-pressed

Hover or Tab to a control, then press Escape.

A design-tool toolbar where each tip shows the tool name and its key. The button carries the name in aria-label and the key in aria-keyshortcuts, so the bubble is marked aria-hidden instead of being announced twice. Escape hides the open tip without moving focus, and clicking a tool updates aria-pressed.

View code
.tt-kb {
  width: min(100%, 360px);
  border: 1px solid #2e2e35;
  border-radius: 16px;
  background: #1c1c21;
  overflow: hidden;
}
.tt-kb__bar {
  display: flex;
  justify-content: center;
  gap: 4px;
  padding: 8px;
  border-bottom: 1px solid #2e2e35;
  background: #232329;
}
.tt-kb__item {
  position: relative;
  display: flex;
}
.tt-kb__btn {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: #d4d4d8;
  font: inherit;
  cursor: pointer;
}
.tt-kb__btn svg {
  width: 21px;
  height: 21px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.tt-kb__btn:hover {
  background: #2f2f37;
  color: #fff;
}
.tt-kb__btn[aria-pressed="true"] {
  background: #7c5cff;
  color: #fff;
}
.tt-kb__btn:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
}
.tt-kb__tip {
  position: absolute;
  top: calc(100% + 10px);
  left: 50%;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 6px 6px 10px;
  border-radius: 8px;
  background: #fafafa;
  color: #18181b;
  font-size: 13px;
  font-weight: 600;
  white-space: nowrap;
  transform: translateX(-50%);
  opacity: 0;
  visibility: hidden;
  transition: opacity .12s ease, visibility .12s;
}
.tt-kb__tip::after {
  content: "";
  position: absolute;
  bottom: 100%;
  right: 0;
  left: 0;
  height: 12px;
}
.tt-kb__tip kbd {
  min-width: 22px;
  padding: 2px 6px;
  border: 1px solid #d4d4d8;
  border-bottom-width: 2px;
  border-radius: 5px;
  background: #fff;
  color: #3f3f46;
  font: 600 12px/1.3 ui-monospace, Consolas, monospace;
  text-align: center;
}
.tt-kb__item:is(:hover, :has(:focus-visible)):not(.is-dismissed) .tt-kb__tip {
  opacity: 1;
  visibility: visible;
}
.tt-kb__bar:has(.tt-kb__item:hover) .tt-kb__item:not(:hover) .tt-kb__tip {
  opacity: 0;
  visibility: hidden;
}
.tt-kb__canvas {
  display: grid;
  place-items: center;
  height: 200px;
  background: #1c1c21;
}
.tt-kb__canvas span {
  width: 120px;
  height: 84px;
  border: 1.5px solid #7c5cff;
  border-radius: 6px;
  background: rgba(124, 92, 255, .12);
}

Toggletip for touch screens

  • role="status"
  • aria-expanded
  • focusout
Delivery

Hover or Tab to a control, then press Escape.

Touch screens have no hover, so the info button opens its bubble on tap or click instead and keeps it open until the next tap, Escape or focus moving away. The bubble is a live region that gets its text a moment after opening, which makes screen readers announce it right where the button is.

View code
.tt-ship {
  width: min(100%, 380px);
  margin: 0;
  padding: 18px 18px 64px;
  border: 0;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 14px 34px rgba(22, 101, 52, .1);
}
.tt-ship__legend {
  float: left;
  width: 100%;
  margin: 0 0 12px;
  padding: 0;
  color: #14281d;
  font-size: 17px;
  font-weight: 700;
}
.tt-ship__opt {
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  clear: both;
  padding: 10px 12px;
  border: 1.5px solid #dbe4dd;
  border-radius: 12px;
}
.tt-ship__opt + .tt-ship__opt {
  margin-top: 8px;
}
.tt-ship__opt:has(.tt-ship__radio:checked) {
  border-color: #166534;
  background: #f0f9f2;
}
.tt-ship__radio {
  width: 20px;
  height: 20px;
  margin: 0;
  accent-color: #166534;
}
.tt-ship__radio:focus-visible {
  outline: 3px solid #166534;
  outline-offset: 2px;
}
.tt-ship__label {
  display: flex;
  flex: 1;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 2px 10px;
  color: #26372d;
  font-size: 14.5px;
  cursor: pointer;
}
.tt-ship__label strong {
  color: #14281d;
}
.tt-ship__tg {
  position: relative;
  display: flex;
}
.tt-ship__info {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border: 1.5px solid #166534;
  border-radius: 50%;
  background: #fff;
  color: #166534;
  font: italic 700 14px/1 "Fraunces", serif;
  cursor: pointer;
}
.tt-ship__info[aria-expanded="true"] {
  background: #166534;
  color: #fff;
}
.tt-ship__info:focus-visible {
  outline: 3px solid #166534;
  outline-offset: 2px;
}
.tt-ship__bubble {
  position: absolute;
  top: calc(100% + 10px);
  right: -6px;
  z-index: 2;
  width: min(240px, 72vw);
  padding: 10px 12px;
  border-radius: 10px;
  background: #14281d;
  color: #eaf6ee;
  font-size: 13.5px;
  line-height: 1.45;
}
.tt-ship__bubble:empty {
  display: none;
}
.tt-ship__bubble::before {
  content: "";
  position: absolute;
  bottom: 100%;
  right: 14px;
  border: 7px solid transparent;
  border-bottom-color: #14281d;
}

Rich profile card as a toggletip

  • aria-expanded
  • hidden
  • Escape returns focus

Shipping the new spacing scale on Friday. Tokens are in the release branch if you want an early look.

Hover or Tab to a control, then press Escape.

Hover cards with avatars, stats and a Follow button look like tooltips, but a tooltip cannot hold interactive content: focus never moves into it. So the name is a disclosure button that opens a non-modal card, Tab reaches the Follow button inside, and Escape closes the card and puts focus back on the name.

View code
.tt-prof {
  width: min(100%, 420px);
  min-height: 250px;
}
.tt-prof__comment {
  display: flex;
  gap: 12px;
  padding: 16px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 10px 28px rgba(46, 16, 101, .1);
}
.tt-prof__avatar,
.tt-prof__big {
  display: grid;
  flex: none;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: #ede9fe;
  color: #4c1d95;
  font-size: 14px;
  font-weight: 700;
}
.tt-prof__body {
  min-width: 0;
}
.tt-prof__anchor {
  position: relative;
}
.tt-prof__name {
  min-height: 28px;
  padding: 0;
  border: 0;
  background: none;
  color: #2e1065;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  text-decoration: underline;
  text-decoration-color: #c4b5fd;
  text-underline-offset: 3px;
  cursor: pointer;
}
.tt-prof__name:hover,
.tt-prof__name[aria-expanded="true"] {
  text-decoration-color: #5b21b6;
}
.tt-prof__name:focus-visible,
.tt-prof__follow:focus-visible {
  outline: 3px solid #7c3aed;
  outline-offset: 2px;
  border-radius: 4px;
}
.tt-prof__text {
  margin: 2px 0 0;
  color: #3f3a4f;
  font-size: 14.5px;
  line-height: 1.55;
}
.tt-prof__card {
  position: absolute;
  top: calc(100% + 8px);
  left: -52px;
  z-index: 3;
  width: min(280px, calc(100vw - 64px));
  padding: 16px;
  border: 1px solid #e4defa;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 20px 44px rgba(46, 16, 101, .2);
}
.tt-prof__card[hidden] {
  display: none;
}
.tt-prof__top {
  display: flex;
  align-items: center;
  gap: 12px;
}
.tt-prof__big {
  width: 52px;
  height: 52px;
  background: #5b21b6;
  color: #fff;
  font-size: 17px;
}
.tt-prof__who {
  margin: 0;
  color: #1e1b2e;
  font-weight: 700;
}
.tt-prof__role {
  margin: 0;
  color: #5d5670;
  font-size: 13px;
}
.tt-prof__stats {
  display: flex;
  justify-content: space-between;
  margin: 14px 0;
  padding: 10px 0;
  border-block: 1px solid #efebfb;
  list-style: none;
  color: #5d5670;
  font-size: 12.5px;
}
.tt-prof__stats strong {
  display: block;
  color: #1e1b2e;
  font-size: 16px;
}
.tt-prof__follow {
  width: 100%;
  min-height: 44px;
  border: 0;
  border-radius: 10px;
  background: #5b21b6;
  color: #fff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.tt-prof__follow[aria-pressed="true"] {
  background: #ede9fe;
  color: #4c1d95;
}

Help icon tooltip on a form field

  • aria-describedby
  • label
  • preventDefault

Billing details

Northvale workspace, Team plan

Invoices and receipts go here instead of to the account owner.

Use a shared inbox your finance team can read.

Hover or Tab to a control, then press Escape.

The help icon explains why the field exists, which is optional context; the rule a user needs to fill it in stays visible under the input. The icon is a real button described by its tooltip, and Escape hides the bubble while focus stays put. The form never submits anywhere: the script cancels the submit and only updates the status line.

View code
.tt-form {
  width: min(100%, 380px);
  padding: 22px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 14px 34px rgba(29, 78, 216, .1);
}
.tt-form__title {
  margin: 0;
  color: #172554;
  font-size: 18px;
  font-weight: 700;
}
.tt-form__sub {
  margin: 2px 0 22px;
  color: #52607a;
  font-size: 13.5px;
}
.tt-form__row {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 8px;
}
.tt-form__label {
  color: #172554;
  font-size: 14.5px;
  font-weight: 700;
}
.tt-form__label span {
  color: #52607a;
  font-weight: 400;
}
.tt-form__help {
  position: relative;
  display: flex;
}
.tt-form__icon {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #dbe5ff;
  color: #1d4ed8;
  font: inherit;
  font-size: 13px;
  font-weight: 800;
  cursor: pointer;
}
.tt-form__icon:hover {
  background: #1d4ed8;
  color: #fff;
}
.tt-form__icon:focus-visible,
.tt-form__input:focus-visible,
.tt-form__submit:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 2px;
}
.tt-form__tip {
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  z-index: 2;
  width: max-content;
  max-width: min(230px, 60vw);
  padding: 8px 11px;
  border-radius: 8px;
  background: #172554;
  color: #fff;
  font-size: 13px;
  line-height: 1.4;
  transform: translateX(-50%);
  opacity: 0;
  visibility: hidden;
  transition: opacity .15s ease, visibility .15s;
}
.tt-form__tip::before {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  border: 6px solid transparent;
  border-top-color: #172554;
  transform: translateX(-50%);
}
.tt-form__tip::after {
  content: "";
  position: absolute;
  top: 100%;
  right: 0;
  left: 0;
  height: 12px;
}
.tt-form__help:is(:hover, :has(:focus-visible)):not(.is-dismissed) .tt-form__tip {
  opacity: 1;
  visibility: visible;
}
.tt-form__input {
  box-sizing: border-box;
  width: 100%;
  min-height: 46px;
  padding: 0 12px;
  border: 1.5px solid #b8c4dc;
  border-radius: 10px;
  background: #fff;
  color: #172554;
  font: inherit;
  font-size: 15px;
}
.tt-form__hint {
  margin: 6px 0 16px;
  color: #52607a;
  font-size: 13px;
}
.tt-form__submit {
  width: 100%;
  min-height: 46px;
  border: 0;
  border-radius: 10px;
  background: #1d4ed8;
  color: #fff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.tt-form__status {
  min-height: 20px;
  margin: 10px 0 0;
  color: #166534;
  font-size: 13.5px;
  overflow-wrap: anywhere;
}

Anchor positioning and popover tooltips

Tooltips that flip away from an edge, escape a scrolling container and render in the top layer. Every one keeps a working fallback for browsers without the newer feature, and Escape hides an open tip.

Anchor positioning that flips near the edge

  • position-try-fallbacks
  • anchor-scope
  • @supports

October 2026 Studio B bookings

1 Fri, Oct 22 sessions, 10:00 and 15:30 3 Sun, Oct 4Podcast taping, all day Mon, Oct 51 session, 09:00 67891011 1213 Wed, Oct 143 sessions, last ends 21:00 151617 Sun, Oct 18Choir recording, 14:00 19202122232425 Mon, Oct 26Mixing block, 10:00 to 18:00 27282930 Sat, Oct 31Late session, 20:00

Hover a highlighted day in the top row, then one lower down.

Each booked day is an anchor, and anchor-scope on its cell lets every day reuse one anchor name. The bubble asks for position-area: top, and position-try-fallbacks moves it below or sideways when the grid edge would cut it off, so the top row opens downward. Without anchor positioning, nth-child rules pick the same sides by column and row.

View code
.tt-cal {
  width: min(100%, 360px);
  padding: 18px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 14px 34px rgba(124, 45, 18, .12);
}
.tt-cal__month {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 4px 12px;
  margin: 0 0 12px;
  color: #1c1917;
  font-size: 17px;
  font-weight: 700;
}
.tt-cal__month span {
  color: #78716c;
  font-size: 13px;
  font-weight: 500;
}
.tt-cal__week,
.tt-cal__grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 4px;
}
.tt-cal__week {
  margin-bottom: 4px;
  color: #78716c;
  font-size: 12px;
  font-weight: 600;
  text-align: center;
}
.tt-cal__grid {
  position: relative;
}
.tt-cal__cell {
  display: grid;
}
.tt-cal__day {
  display: grid;
  place-items: center;
  aspect-ratio: 1;
  min-height: 36px;
  border-radius: 10px;
  color: #57534e;
  font-size: 14px;
}
button.tt-cal__day {
  padding: 0;
  border: 0;
  font: inherit;
  font-size: 14px;
  cursor: pointer;
}
.tt-cal__day.is-booked {
  background: #c2410c;
  color: #fff;
  font-weight: 700;
}
.tt-cal__day.is-booked:hover {
  background: #9a3412;
}
.tt-cal__day.is-booked:focus-visible {
  outline: 3px solid #1c1917;
  outline-offset: 2px;
}
.tt-cal__tip {
  position: absolute;
  z-index: 2;
  width: max-content;
  max-width: 170px;
  padding: 8px 10px;
  border-radius: 9px;
  background: #1c1917;
  color: #e7e5e4;
  font-size: 12.5px;
  line-height: 1.4;
  opacity: 0;
  visibility: hidden;
  transition: opacity .15s ease, visibility .15s;
}
.tt-cal__tip span {
  display: block;
  color: #fff;
  font-weight: 700;
}
.tt-cal__cell:hover .tt-cal__tip,
.tt-cal__day:focus-visible + .tt-cal__tip {
  opacity: 1;
  visibility: visible;
}
@supports (anchor-name: --tt-a) and (anchor-scope: --tt-a) and (position-try-fallbacks: flip-block) {
  .tt-cal__cell {
    anchor-scope: --tt-day;
  }
  .tt-cal__day.is-booked {
    anchor-name: --tt-day;
  }
  .tt-cal__tip {
    position-anchor: --tt-day;
    position-area: top;
    margin: 0 0 6px;
    position-try-fallbacks: flip-block, top span-right, top span-left, bottom span-right, bottom span-left;
  }
}
@supports not ((anchor-name: --tt-a) and (anchor-scope: --tt-a) and (position-try-fallbacks: flip-block)) {
  .tt-cal__cell {
    position: relative;
  }
  .tt-cal__tip {
    bottom: calc(100% + 6px);
    left: 50%;
    transform: translateX(-50%);
  }
  .tt-cal__cell:nth-child(-n + 7) .tt-cal__tip {
    top: calc(100% + 6px);
    bottom: auto;
  }
  .tt-cal__cell:nth-child(7n + 1) .tt-cal__tip,
  .tt-cal__cell:nth-child(7n + 2) .tt-cal__tip {
    left: 0;
    transform: none;
  }
  .tt-cal__cell:nth-child(7n + 6) .tt-cal__tip,
  .tt-cal__cell:nth-child(7n) .tt-cal__tip {
    right: 0;
    left: auto;
    transform: none;
  }
}
.tt-cal .tt-cal__cell.is-dismissed .tt-cal__tip {
  opacity: 0;
  visibility: hidden;
}

popover="hint" with interestfor, feature-detected

  • popover="hint"
  • interestfor
  • showPopover()

Halden Coast Radio New episode: why ferry timetables still run on paper in three harbour towns.

Hover or Tab to a reaction. The line under the post says which path your browser took.

Where interest invokers ship, the interestfor attribute opens each hint popover on hover, focus or long-press with no script, and the popover renders in the top layer so nothing can clip it. Elsewhere the script opens the same popovers with showPopover(), downgrading popover="hint" to manual when hint is unsupported, and handles Escape itself. Anchor positioning places the bubble, with a measured fallback.

View code
.tt-pop {
  width: min(100%, 400px);
  padding: 20px 20px 18px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 14px 34px rgba(131, 24, 67, .1);
}
.tt-pop__post {
  margin: 0 0 14px;
  color: #3b2f36;
  font-size: 15px;
  line-height: 1.55;
}
.tt-pop__post strong {
  display: block;
  color: #1f1a1d;
}
.tt-pop__bar {
  display: flex;
  gap: 8px;
}
.tt-pop__btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 40px;
  padding: 0 14px;
  border: 1.5px solid #f3d0df;
  border-radius: 999px;
  background: #fff;
  color: #9d174d;
  font: inherit;
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
}
.tt-pop__btn svg {
  width: 19px;
  height: 19px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.tt-pop__btn:hover {
  background: #fdf2f7;
}
.tt-pop__btn:focus-visible {
  outline: 3px solid #be185d;
  outline-offset: 2px;
}
.tt-pop__mode {
  margin: 14px 0 0;
  color: #6b5a63;
  font-size: 12.5px;
}
.tt-pop__tip {
  inset: auto;
  margin: 0;
  padding: 7px 11px;
  border: 0;
  border-radius: 8px;
  background: #3b0a24;
  color: #fff;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  line-height: 1.3;
  white-space: nowrap;
  overflow: visible;
}
.tt-pop__tip.is-measured {
  position: fixed;
  transform: translateY(calc(-100% - 8px));
}
@supports (position-area: top) and (anchor-name: --tt-x) {
  .tt-pop__btn--like {
    anchor-name: --tt-pop-like;
  }
  .tt-pop__btn--share {
    anchor-name: --tt-pop-share;
  }
  .tt-pop__btn--save {
    anchor-name: --tt-pop-save;
  }
  .tt-pop__tip--like {
    position-anchor: --tt-pop-like;
  }
  .tt-pop__tip--share {
    position-anchor: --tt-pop-share;
  }
  .tt-pop__tip--save {
    position-anchor: --tt-pop-save;
  }
  .tt-pop__tip {
    position-area: top span-right;
    margin-bottom: 8px;
    position-try-fallbacks: top span-left, flip-block;
  }
}

Escaping a scrolling table

  • position: fixed
  • anchor-name
  • overflow-x: auto

Accounts this quarter

AccountSeatsMRR Monthly recurring revenue, before taxNRR Net revenue retention over 12 monthsChurn Seats cancelled this quarter
Northvale84$6,720118%2
Kestrel Studio23$1,840104%0
Harbor Mill51$4,08096%5

Hover or Tab to a question mark in the table header.

A table inside an overflow-x: auto wrapper clips any absolutely positioned bubble. With anchor positioning the bubble switches to position: fixed, which takes it out of the scroller, while the anchor keeps it glued to its header cell during scrolling. The fallback opens the bubble below the header, inside the scroll area, where it still fits.

View code
.tt-tbl {
  width: min(100%, 440px);
  padding: 56px 18px 18px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 14px 34px rgba(15, 23, 42, .1);
}
.tt-tbl__title {
  margin: 0 0 10px;
  color: #0f172a;
  font-size: 16px;
  font-weight: 700;
}
.tt-tbl__scroll {
  overflow-x: auto;
  border: 1px solid #e2e8f0;
  border-radius: 12px;
}
.tt-tbl__scroll:focus-visible {
  outline: 3px solid #334155;
  outline-offset: 2px;
}
.tt-tbl__table {
  width: 100%;
  min-width: 520px;
  margin: 0;
  border-collapse: collapse;
  color: #334155;
  font-size: 14px;
}
.tt-tbl__table th,
.tt-tbl__table td {
  padding: 10px 12px;
  border: 0;
  border-bottom: 1px solid #eef2f6;
  text-align: left;
  white-space: nowrap;
}
.tt-tbl__table th {
  background: #f8fafc;
  color: #0f172a;
  font-size: 12.5px;
  font-weight: 700;
}
.tt-tbl__table tbody tr:last-child td {
  border-bottom: 0;
}
.tt-tbl__head {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.tt-tbl__help {
  position: relative;
  display: inline-flex;
}
.tt-tbl__q {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #e2e8f0;
  color: #1e293b;
  font: inherit;
  font-size: 12px;
  font-weight: 800;
  cursor: pointer;
}
.tt-tbl__q:hover {
  background: #334155;
  color: #fff;
}
.tt-tbl__q:focus-visible {
  outline: 3px solid #334155;
  outline-offset: 2px;
}
.tt-tbl__tip {
  position: absolute;
  top: calc(100% + 8px);
  left: 50%;
  z-index: 3;
  width: max-content;
  max-width: 190px;
  padding: 7px 10px;
  border-radius: 8px;
  background: #0f172a;
  color: #fff;
  font-size: 12.5px;
  font-weight: 500;
  line-height: 1.4;
  white-space: normal;
  transform: translateX(-50%);
  opacity: 0;
  visibility: hidden;
  transition: opacity .15s ease, visibility .15s;
}
.tt-tbl__tip::after {
  content: "";
  position: absolute;
  right: 0;
  bottom: 100%;
  left: 0;
  height: 10px;
}
.tt-tbl__help:hover .tt-tbl__tip,
.tt-tbl__q:focus-visible + .tt-tbl__tip {
  opacity: 1;
  visibility: visible;
}
@supports (position-area: top) and (anchor-name: --tt-x) {
  .tt-tbl__q--mrr {
    anchor-name: --tt-tbl-mrr;
  }
  .tt-tbl__q--nrr {
    anchor-name: --tt-tbl-nrr;
  }
  .tt-tbl__q--churn {
    anchor-name: --tt-tbl-churn;
  }
  .tt-tbl__tip--mrr {
    position-anchor: --tt-tbl-mrr;
  }
  .tt-tbl__tip--nrr {
    position-anchor: --tt-tbl-nrr;
  }
  .tt-tbl__tip--churn {
    position-anchor: --tt-tbl-churn;
  }
  .tt-tbl__tip {
    position: fixed;
    inset: auto;
    margin: 0 0 8px;
    transform: none;
    position-area: top;
    position-try-fallbacks: top span-left, top span-right, flip-block;
  }
  .tt-tbl__tip::after {
    top: 100%;
    bottom: auto;
  }
}
.tt-tbl .tt-tbl__help.is-dismissed .tt-tbl__tip {
  opacity: 0;
  visibility: hidden;
}

Tooltips in real interfaces

Glossary terms inside running text, a bar chart with data point labels and a file list that only shows a tooltip when a name is actually cut off.

Glossary terms in a paragraph

  • static position
  • aria-describedby
  • Escape

Tasting notes, Halden blend

We drop this roast about ninety seconds after First crack. The audible pop as moisture inside the bean turns to steam and splits it open., which keeps the Development time. The stretch between first crack and the end of the roast. short and the cup bright. A longer Maillard phase. Browning reactions between sugars and amino acids that build caramel and nut flavors. adds sweetness without the smoky edge of a dark roast.

Hover or Tab to a term, a bar or a file name. Escape hides an open tip.

Each term is an inline button described by its definition. The bubble is absolutely positioned against the paragraph with left: 0 and right: 0 but no top value, so it keeps its static position on the term line and a transform lifts it above that line. It always spans the text column, so a term near either edge never pushes it out, and Escape hides it because it covers the lines above.

View code
.tt-gl {
  width: min(100%, 520px);
  padding: 60px 26px 26px;
  border-radius: 18px;
  background: #fbf7ef;
  box-shadow: inset 0 0 0 1px #eadfca;
}
.tt-gl__kicker {
  margin: 0 0 8px;
  color: #6b4f3a;
  font-size: 13px;
  font-weight: 700;
}
.tt-gl__text {
  position: relative;
  margin: 0;
  color: #3f2a1d;
  font: 400 18px/1.7 "Fraunces", serif;
}
.tt-gl__term {
  display: inline;
  min-height: 24px;
  padding: 0;
  border: 0;
  background: none;
  color: #7a3412;
  font: inherit;
  text-decoration: underline dotted #b45309 2px;
  text-underline-offset: 4px;
  cursor: help;
}
.tt-gl__term:hover {
  color: #3f2a1d;
  background: #f4e4c9;
}
.tt-gl__term:focus-visible {
  outline: 3px solid #b45309;
  outline-offset: 2px;
  border-radius: 3px;
}
.tt-gl__tip {
  position: absolute;
  right: 0;
  left: 0;
  z-index: 2;
  padding-bottom: 6px;
  transform: translateY(-100%);
  opacity: 0;
  visibility: hidden;
  transition: opacity .15s ease, visibility .15s;
}
.tt-gl__bubble {
  display: block;
  padding: 10px 14px;
  border-radius: 10px;
  background: #3f2a1d;
  color: #f6ecdc;
  font-family: inherit;
  font-size: 14px;
  line-height: 1.5;
  box-shadow: 0 10px 24px rgba(63, 42, 29, .25);
}
.tt-gl__bubble strong {
  color: #fff;
}
.tt-gl__item:is(:hover, :has(:focus-visible)):not(.is-dismissed) .tt-gl__tip {
  opacity: 1;
  visibility: visible;
}

Bar chart data point tooltips

  • custom properties
  • :has()
  • nth-child

Store visits Last 7 days, 11,670 total

  • 1,240 visits, up 8%
  • 1,580 visits, up 12%
  • 1,310 visits, down 3%
  • 1,920 visits, up 15%
  • 2,210 visits, up 9%
  • 2,680 visits, best day
  • 1,730 visits, down 2%

Hover or Tab to a term, a bar or a file name. Escape hides an open tip.

Every bar is a button described by its value, so the chart works with Tab and a screen reader, not just a mouse. One --tt-ch-h custom property per bar sets both the bar height and the bubble offset, and :has() dims the other bars while one is active. The two outer bars on each side align their bubbles inward so they never leave the card.

View code
.tt-ch {
  width: min(100%, 440px);
  padding: 20px 18px 16px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 14px 34px rgba(49, 46, 129, .1);
}
.tt-ch__title {
  margin: 0;
  color: #1e1b4b;
  font-size: 16px;
  font-weight: 700;
}
.tt-ch__title span {
  display: block;
  color: #5b5a7a;
  font-size: 13px;
  font-weight: 500;
}
.tt-ch__plot {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  align-items: end;
  gap: clamp(6px, 2vw, 12px);
  height: 210px;
  margin: 0;
  padding: 0;
  border-bottom: 2px solid #e0e0f0;
  list-style: none;
}
.tt-ch__col {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  height: 100%;
  --tt-ch-h: 50%;
}
.tt-ch__col:nth-child(1) { --tt-ch-h: 46%; }
.tt-ch__col:nth-child(2) { --tt-ch-h: 59%; }
.tt-ch__col:nth-child(3) { --tt-ch-h: 49%; }
.tt-ch__col:nth-child(4) { --tt-ch-h: 72%; }
.tt-ch__col:nth-child(5) { --tt-ch-h: 82%; }
.tt-ch__col:nth-child(6) { --tt-ch-h: 100%; }
.tt-ch__col:nth-child(7) { --tt-ch-h: 65%; }
.tt-ch__bar {
  width: 100%;
  height: calc(var(--tt-ch-h) * .7);
  min-height: 24px;
  padding: 0;
  border: 0;
  border-radius: 8px 8px 2px 2px;
  background: #4338ca;
  cursor: pointer;
  transition: opacity .15s ease;
}
.tt-ch__col:nth-child(6) .tt-ch__bar {
  background: #f59e0b;
}
.tt-ch__bar:focus-visible {
  outline: 3px solid #1e1b4b;
  outline-offset: 2px;
}
.tt-ch__plot:has(.tt-ch__col:hover, .tt-ch__bar:focus-visible) .tt-ch__col:not(:hover) .tt-ch__bar:not(:focus-visible) {
  opacity: .4;
}
.tt-ch__x {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  right: 0;
  color: #5b5a7a;
  font-size: 12px;
  text-align: center;
}
.tt-ch__tip {
  position: absolute;
  bottom: calc(var(--tt-ch-h) * .7 + 10px);
  left: 50%;
  z-index: 2;
  padding: 6px 10px;
  border-radius: 8px;
  background: #1e1b4b;
  color: #d9d8f5;
  font-size: 12.5px;
  white-space: nowrap;
  transform: translateX(-50%);
  opacity: 0;
  visibility: hidden;
  transition: opacity .15s ease, visibility .15s;
}
.tt-ch__tip strong {
  color: #fff;
  font-size: 14px;
}
.tt-ch__tip::after {
  content: "";
  position: absolute;
  top: 100%;
  right: 0;
  left: 0;
  height: 12px;
}
.tt-ch__col:nth-child(-n + 2) .tt-ch__tip {
  left: 0;
  transform: none;
}
.tt-ch__col:nth-child(n + 6) .tt-ch__tip {
  right: 0;
  left: auto;
  transform: none;
}
.tt-ch__col:hover .tt-ch__tip,
.tt-ch__bar:focus-visible + .tt-ch__tip {
  opacity: 1;
  visibility: visible;
}
.tt-ch {
  padding-bottom: 36px;
}
.tt-ch .tt-ch__col.is-dismissed .tt-ch__tip {
  opacity: 0;
  visibility: hidden;
}

Tooltip only when the text is truncated

  • text-overflow
  • ResizeObserver
  • aria-hidden

Shared with you

  • 18 MB
  • 84 KB
  • 212 KB
  • 6 KB

Hover or Tab to a term, a bar or a file name. Escape hides an open tip.

text-overflow: ellipsis only hides characters visually, so screen readers already read the full file name. The bubble is there for sighted users and is aria-hidden. A ResizeObserver compares scrollWidth with clientWidth and adds is-cut only to rows that are actually shortened, so short names never get a pointless tooltip.

View code
.tt-tr {
  width: min(100%, 330px);
  padding: 18px 16px 70px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 14px 34px rgba(15, 23, 42, .1);
}
.tt-tr__title {
  margin: 0 0 10px;
  color: #111827;
  font-size: 16px;
  font-weight: 700;
}
.tt-tr__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.tt-tr__row {
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 4px;
  border-bottom: 1px solid #f0f1f4;
}
.tt-tr__row:last-child {
  border-bottom: 0;
}
.tt-tr__icon {
  display: grid;
  flex: none;
  place-items: center;
  width: 36px;
  height: 36px;
  border-radius: 8px;
  font-size: 10px;
  font-weight: 800;
}
.tt-tr__icon--fig {
  background: #ede9fe;
  color: #5b21b6;
}
.tt-tr__icon--pdf {
  background: #fee2e2;
  color: #991b1b;
}
.tt-tr__icon--doc {
  background: #dbeafe;
  color: #1e40af;
}
.tt-tr__icon--svg {
  background: #dcfce7;
  color: #166534;
}
.tt-tr__name {
  flex: 1;
  min-width: 0;
  min-height: 32px;
  padding: 0;
  border: 0;
  background: none;
  color: #1f2937;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  text-align: left;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
}
.tt-tr__name:hover {
  color: #1d4ed8;
}
.tt-tr__name:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 2px;
  border-radius: 4px;
}
.tt-tr__size {
  flex: none;
  color: #6b7280;
  font-size: 12.5px;
}
.tt-tr__tip {
  position: absolute;
  top: calc(100% + 4px);
  right: 0;
  left: 46px;
  z-index: 2;
  padding: 7px 10px;
  border-radius: 8px;
  background: #111827;
  color: #fff;
  font-size: 13px;
  line-height: 1.4;
  overflow-wrap: anywhere;
  opacity: 0;
  visibility: hidden;
  transition: opacity .15s ease, visibility .15s;
}
.tt-tr__tip::after {
  content: "";
  position: absolute;
  right: 0;
  bottom: 100%;
  left: 0;
  height: 8px;
}
.tt-tr__row.is-cut:is(:hover, :has(:focus-visible)):not(.is-dismissed) .tt-tr__tip {
  opacity: 1;
  visibility: visible;
}

How to create a CSS tooltip with HTML and CSS

The first step happens in HTML markup on the trigger. Everything after that is CSS.

  1. Put the tip text in a data-tooltip attribute on the trigger. A button or link works best.
  2. Give the trigger position: relative so the bubble anchors to it.
  3. Draw the bubble with ::after. Fill it using content: attr(data-tooltip) and set position: absolute.
  4. Hide it with opacity: 0 and visibility: hidden.
  5. Reveal it through :hover and :focus-visible on the trigger.
<button class="tip" data-tooltip="Copies the link to your clipboard">Copy link</button>

.tip { position: relative; }

.tip::after {
  content: attr(data-tooltip);
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  padding: 6px 10px;
  background: #222;
  color: #fff;
  border-radius: 4px;
  white-space: nowrap;
  opacity: 0;
  visibility: hidden;
}

.tip:hover::after,
.tip:focus-visible::after {
  opacity: 1;
  visibility: visible;
}

Opacity alone leaves an invisible bubble sitting in the hit area, where it swallows clicks meant for the page underneath. Adding visibility: hidden removes it from hit testing until the reveal rule fires.

A span or div needs tabindex="0" before it can take keyboard focus. Buttons and links with an href already do.

Plenty of tutorials also add pointer-events: none to the bubble so it cannot flicker under the cursor, OpenReplay’s pure CSS walkthrough among them. It works, but it costs hoverability, and the WCAG section below weighs that trade.

Reading tooltip text with attr()

In January 2025, Chrome for Developers announced a redesigned attr(), available from Chrome 133, that works in any CSS property and parses typed values. With no type given, the value still parses as a plain CSS string, which is the behavior a tooltip relies on.

Pseudo-elements cannot read text from the DOM on their own. The attribute carries it, and attr() copies it into content.

The result stays text-only. Markup, links and images placed in the attribute print as literal characters.

Data attribute or child element?

The data attribute is the lighter option. One shared rule styles every tooltip, there’s no extra markup, and the text stays plain. A child element costs one extra element per trigger, but it holds longer text and can carry an id, which assistive technology needs.

For short labels I’d stay with the attribute. Move to a child element once the tooltip needs an id for aria-describedby, covered in the screen reader section below.

Positioning a CSS tooltip on any side, with an arrow

Each side comes down to a pair of offsets. One pushes the bubble past the trigger edge and the other centers it along the opposite axis. Swap the pair and the tooltip lands on another side.

Placement offsets by side

  • For the top, use bottom: 100%; left: 50%; transform: translateX(-50%).
  • The bottom takes top: 100%; left: 50%; transform: translateX(-50%).
  • Left is right: 100%; top: 50%; transform: translateY(-50%).
  • And right is left: 100%; top: 50%; transform: translateY(-50%).

A data-position attribute or a modifier class on the trigger picks the side, with one override rule per value.

Spacing the bubble away with margin leaves dead space between trigger and bubble, and the pointer loses hover while crossing it. A transparent border on the bubble keeps that space part of the element instead.

Arrow with a pseudo-element

The bubble already uses ::after, so the arrow takes ::before. A zero-size box with one colored border and two transparent ones draws the triangle.

.tip::after {
  bottom: 100%;
  border-bottom: 6px solid transparent;
  background-clip: padding-box;
}

.tip::before {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  border: solid transparent;

  border-width: 6px 6px 0;
  border-top-color: #222;
  opacity: 0;
  visibility: hidden;
}

.tip:hover::before,
.tip:focus-visible::before {
  opacity: 1;
  visibility: visible;
}

Move the colored border to another edge to point the arrow at another side. A triangle generator prints the border values for each direction.

Another route is clip-path: polygon(), which cuts a triangle out of a square pseudo-element. The arrow’s size then sits in ordinary width and height values instead of border widths.

Fade-in and delay values for a CSS tooltip

Transition opacity and visibility together, and put the delay on the show rule only. Opacity draws the fade, while visibility keeps the bubble on screen until the fade-out finishes.

.tip::after {
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.15s ease, visibility 0.15s;
}

.tip:hover::after,
.tip:focus-visible::after {
  opacity: 1;
  visibility: visible;
  transition-delay: 0.3s;
}

The durations above are placeholders to tune. The browser reads transition settings from the state being entered, so the delay in the hover rule applies on the way in, and the hide runs from the base rule with no delay.

The WAI-ARIA Authoring Practices tooltip pattern says a tooltip typically appears after a small delay. That delay also stops tooltips from flashing as the pointer sweeps across a row of controls.

Preview timing values in the browser before they go into the stylesheet.

One delay is worth avoiding entirely, the timed auto-hide. A tooltip that vanishes on a timer breaks the persistent condition of WCAG 1.4.13.

Reduced-motion handling

MDN lists prefers-reduced-motion as available in every major browser since January 2020, so the rule needs no fallback.

Drop any slide or scale built on transform. A plain opacity fade can stay, or you can switch the transition off.

Inside a media query for (prefers-reduced-motion: reduce), set transition: none on the bubble and the arrow.

Making a CSS tooltip work with keyboard and screen readers

Pair every :hover rule with :focus-visible, put role="tooltip" on a real element, and point to it from the trigger with aria-describedby. A hover-only tooltip never reaches keyboard users, because tabbing fires focus and never hover.

The web-features dataset from the WebDX Community Group lists :focus-visible as Baseline Widely available since 14 September 2024. Safari 15.4, shipped in March 2022, was the last major engine to add it.

Using it instead of plain :focus keeps the bubble from lingering after a mouse click on a button.

The markup is simple. A wrapper with position: relative holds the trigger and the tooltip as siblings. The tooltip is a real element with role="tooltip" and a unique id, and the trigger gets aria-describedby set to that id.

MDN’s reference for the ARIA tooltip role puts aria-describedby on the owning element, never on the tooltip. A rule built on the adjacent sibling combinator reveals the bubble on hover or focus.

The same page rules out aria-expanded here and says role="tooltip" goes on the element that holds the tip, never on an information icon that triggers it.

Text pulled in through attr() lives in a pseudo-element, which has no id for aria-describedby to reference. OpenReplay notes that CSS alone cannot communicate tooltip semantics to assistive technology, so an accessible tooltip needs the real element above.

Per the same authoring pattern, focus stays on the trigger and the tooltip never receives focus. A hover card with links is a different widget, a non-modal dialog.

The standards are less settled than the pattern suggests. The tooltip page carries a note that it lacks task force consensus, and an aria-practices repository item (#811) raises a conflict between the tooltip role and WCAG 1.4.13.

Does a CSS tooltip meet WCAG 1.4.13?

Partly. Hoverable and persistent come free with a well-built CSS tooltip. Dismissible only passes through an exemption, and real Escape dismissal needs a script.

W3C names custom tooltips as covered content in SC 1.4.13, a Level AA criterion introduced in WCAG 2.1 (June 2018) and carried into WCAG 2.2 (5 October 2023).

ConditionWhat it requiresPure CSS resultWhere it fails
DismissibleHide the content without moving pointer or focus, unless it covers nothing else or reports an input errorPasses through the exemption when the bubble overlaps no other contentBubble sits over text or controls with no Escape handler
HoverablePointer can move onto the content without it vanishingPasses when the bubble sits inside the trigger’s hover areaGap between trigger and bubble, or pointer-events: none
PersistentStays until hover or focus leaves, the user dismisses it, or the information goes stalePasses while :hover or :focus-visible holdsTimed auto-hide

Read together, the criterion text and MDN’s tooltip guidance draw the line at Escape. MDN’s example hides the tooltip on the Escape key by toggling a class in JavaScript, and CSS has no selector for a key press.

So dismissal is the one condition CSS cannot own when the bubble covers other content. Put the bubble in clear space and the exemption applies. Over body text, a script has to handle Escape.

Pointer-events: none against the hoverable condition

OpenReplay’s pure CSS recipe adds pointer-events: none to the bubble to stop flicker. W3C’s Understanding page for 1.4.13 says users must be able to move the pointer from the trigger onto the content, because the pointer itself can cover part of it.

With the flag on, the bubble drops out of hit testing. The pointer lands on the page beneath it, hover on the trigger ends, and the tooltip closes.

The fix is to drop the flag and keep the bubble inside the trigger’s hover area. Hold any spacing as a transparent border on the bubble, the way the arrow technique in the positioning section does, instead of margin.

Where a CSS tooltip breaks: clipping, viewport edges and touch screens

An ancestor with overflow other than visible clips the bubble. The viewport edge cuts it off, since plain CSS cannot flip it. And touch screens never fire the hover that reveals it.

None of this shows up in a demo on a clean page. It surfaces in tables, card grids, sidebars and phones.

Clipped by overflow

An absolutely positioned bubble gets cut off by any ancestor with overflow: hidden, auto or scroll when the trigger sits inside that ancestor. Scrolling tables and card grids are the usual culprits.

  • Move the tooltip element out of the clipping container, which then needs script or anchor positioning to find its trigger.
  • If the ancestor only clips decoration, set it back to overflow: visible.
  • Or render the bubble in the top layer with a popover (details in the popover section below).

Stacking behaves the same way. A z-index value only competes inside its own stacking context, so a bubble inside one card can still sit behind a sibling card, however high its number.

Cut off at the viewport edge

A bubble placed above its trigger runs off the top of the screen when the trigger sits at the top, and plain CSS has no rule that flips it.

Libraries solve this with collision logic that tracks the viewport and moves the bubble. Bootstrap’s tooltip defaults to cycling through top, right, bottom and left fallback placements against its clipping parents, per the Bootstrap 5.3 documentation.

CSS now has its own version of that logic, covered in the anchor positioning section below.

No hover on touch screens

MDN lists the hover media feature as Baseline Widely available, across browsers since December 2018, and its none value matches primary inputs that cannot hover or cannot do so conveniently, such as phones that emulate hover with a long tap.

Nielsen Norman Group’s tooltip guidelines (Alita Kendrick, January 2019) say tooltips are not normally available on touchscreens and name tap-triggered popup tips as the touch counterpart.

Put only the :hover reveal inside @media (hover: hover), because browsers that emulate hover on tap can leave the bubble open until the next tap elsewhere.

Keep the :focus-visible rule outside the query so a Bluetooth keyboard on a tablet still works.

Any text a touch user needs goes onto the page itself, never into the bubble.

How does CSS anchor positioning improve a CSS tooltip?

Anchor positioning links the bubble to its trigger in CSS. anchor-name goes on the trigger, position-anchor on the tooltip, and position-area picks the side. The browser keeps the bubble tethered through scrolling and resizing, and position-try-fallbacks flips it when it would leave the viewport.

.tip { anchor-name: --tip; }

.tip-bubble {
  position: fixed;
  position-anchor: --tip;
  position-area: top;
  position-try-fallbacks: flip-block;
}

MDN requires the bubble to be absolutely or fixed positioned. A fixed-position bubble also escapes ancestor overflow clipping, unless an ancestor creates a containing block for it (a transform does).

position-area works on a 3 by 3 grid with the trigger as the center tile, so top puts the bubble above it and bottom puts it below.

  • Several triggers sharing one anchor-name send every bubble to the last trigger in source order, and anchor-scope or unique names fix it (MDN).
  • Without position-try-fallbacks the bubble overflows at the screen edge, while with it the browser tries the listed positions in order and returns to the default one if none fits. Hiding the bubble instead takes position-visibility.
  • A trigger set to display: none drops the bubble back to its nearest positioned ancestor.

As of October 2026, support looks like this:

  • Chrome and Edge from 125, and Safari from 26.
  • Firefox from 147, which tipped the feature into Baseline Newly available (web.dev, 30 January 2026).
  • Adoption sits at about 7.5% of page loads (Chrome Platform Status, via the web-features explorer; the counter moves daily).

Sources disagree on how settled this is. web.dev announced the core feature as Baseline Newly available in January 2026, while the web-features explorer’s October 2026 listing tags anchor positioning as Limited availability and shows Chrome and Firefox as unsupported.

The explorer’s underlying data shows where the gap sits. The newer position-visibility values anchor-valid and anchor-visible are listed only for Safari 27 (released 14 September 2026), and that keeps the whole entry in Limited availability. The core tethering web.dev announced is not affected. That is a cross-browser compatibility question to settle per property, so check MDN’s tables for each one you use and wrap the declarations in @supports (anchor-name: --x), with the classic offsets from the positioning section as the fallback.

Can the Popover API or interestfor replace a CSS tooltip?

Not yet for hover and focus. The Popover API gives a tooltip top-layer rendering, but interestfor, the declarative hover and focus trigger, ships only in Chromium as of the web-features explorer’s October 2026 release.

web.dev places the Popover API in Baseline Newly available since 27 January 2025, and the web-features data expects Widely available status on 27 July 2027. MDN notes that an open popover renders in the top layer, unaffected by parent position or overflow styling.

The popover attribute carries no semantics on its own, so the role="tooltip" and aria-describedby wiring from the accessibility section still applies.

<button interestfor="copy-tip" aria-describedby="copy-tip">Copy link</button>

<div id="copy-tip" popover="hint" role="tooltip">Copies the link to your clipboard</div>

A popover="hint" alone does not wire up triggers. Chrome for Developers calls its own hover-only hint demo inaccessible to anyone without a mouse, which is the gap interestfor was built to close.

The upsides are real. Top-layer rendering means no overflow clipping and no z-index fights, interestfor declares hover, focus and long-press triggers in HTML, and hint popovers leave open menus alone.

The costs are mostly about support.

  • interestfor runs in Chrome 142 (28 October 2025) and Edge 142 (31 October 2025), with no Firefox or Safari support.
  • popover="hint" arrived in Chrome 133 (4 February 2025) and Firefox 149 (24 March 2026) as an older version of the spec. MDN’s compatibility data lists full support from Firefox 153 (21 July 2026) and Chrome 151 (28 July 2026), and Safari has it only in Technology Preview.
  • Hint popovers account for roughly 0.25% of page loads (Chrome Platform Status, via the web-features explorer).

Where Firefox and Safari matter, interestfor needs a polyfill. The main open-source one, mfreed7/interestfor on GitHub, calls its touch support limited: long-press works on buttons but not on links.

CSS tooltip vs title attribute vs JavaScript library

Short labels on focusable controls suit a CSS tooltip. A JavaScript library earns its place when edge collisions or rich content matter in browsers without anchor positioning. The title attribute is a supplement at best.

OptionKeyboard and touchEdge collisionsCost
title attributeNo focus display, no touchNoneZero code, no styling
CSS-only tooltipFocus works, touch needs a fallbackNoneNo script
Anchor positioning with popoverinterestfor in Chromium onlyposition-try-fallbacksSupport gaps
JavaScript libraryHandled by the libraryFlip and shift built inDependency plus init code

TPGi’s updated guidance on the title attribute, quoting the HTML specification, says relying on it is discouraged because the tooltip needs a pointing device. That excludes keyboard-only and touch users.

The Bootstrap tooltip component needs Popper, either separately or through the bundle, and every tooltip must be initialized in JavaScript, per the Bootstrap 5.3 documentation. Floating UI, the successor to Popper, covers custom builds.

The same Bootstrap page adds a caution that the earlier tabindex="0" tip needs. Making non-interactive elements focusable that way creates confusing tab stops, and most assistive technologies do not announce tooltips there, so keep tooltips on links, buttons and form controls.

Nielsen Norman Group’s guidelines say tooltips must not carry information a user needs to finish a task, and Carbon’s design system keeps interactive elements out of them. The wider UI conventions for tooltips follow the same line.

That leaves a short set of rules.

  • An icon-only control that needs a name gets a CSS-only tooltip.
  • A bubble near a screen edge calls for anchor positioning with an @supports fallback, or a library.
  • Links, buttons or forms inside the bubble mean it isn’t a tooltip anymore. Use a popover or dialog.
  • Text a user needs to finish the task belongs on the page.

NN/g’s guidelines cite Amtrak’s account form for hiding password requirements in a hover tooltip, the textbook case of task information that belongs on screen. Alibaba’s unlabeled camera icon with a tooltip naming the image-search function is the opposite case, where the bubble names a control that has no label.

Skip the library for simple labels on focusable controls. Reach for one only when edge placement matters in browsers without anchor positioning.

CSS tooltip FAQ

How long should tooltip text be?

Short enough to read at a glance. The usual term is microcontent, a fragment that makes sense on its own. Nielsen Norman Group sets no character cap, but says lengthy text is no longer a tip and the bubble must not cover related content.

If the explanation needs a paragraph, put it on the page.

How do I make a multi-line tooltip wrap correctly?

Replace white-space: nowrap with white-space: normal, then give the bubble width: max-content and a max-width.

Without max-content, a bubble offset by left: 50% shrinks to the leftover width of its trigger and wraps after a word or two.

Why do two tooltips appear when the trigger also has a title attribute?

The browser draws its own tooltip from the title attribute while your bubble shows from data-tooltip. Remove title from the trigger and keep the text in the data attribute.

If the control has no visible text, add an aria-label so assistive technology still gets a name.

What is the difference between a tooltip and a toggletip?

A tooltip opens on hover or keyboard focus and closes once either leaves. Tap or click opens a toggletip instead, and it stays until the user closes it, which is why it suits touch screens.

Nielsen Norman Group calls the tap version a popup tip, and Carbon’s design system documents the split with a tooltip versus toggletip example.

Does Tailwind CSS need JavaScript for tooltips?

No. A Tailwind tooltip built with group-hover and group-focus utilities toggles a child bubble in pure CSS, the same mechanism as the plain version.

Tailwind ships utilities, not components, so edge collisions stay manual or come from a library.

Testing a CSS Tooltip Before It Ships

A CSS tooltip is ready to ship when Tab focus reveals it, the pointer can travel onto the bubble without closing it, and no touch user needs it to finish a task.

Do a keyboard pass first, with the mouse unplugged. Then a screen reader pass on the real trigger, and last a touch pass on a physical phone.

Keyboard goes first because it exposes a hover-only rule in seconds. Touch goes last because its fix is moving text onto the page.

The cost is that phone users see no bubble, so every label worth keeping needs visible text.

Verified on 27 October 2026, Firefox and Safari still lack interestfor. Once both ship it, a popover build could replace the CSS-only baseline.

The same three passes fit the other components in an interface, from dropdown menus to modals.