CreaTech
LAB-006CSS onlyExperimentalAccessible

Anchor-positioned tooltip with CSS anchor-name and position-anchor

A tooltip that pins itself to its trigger with CSS anchor positioning, and flips to the other side when there is no room.

Built 18 Aug 2026 · Last verified 18 Aug 2026

The short version

CSS anchor positioning lets one element be placed relative to another anywhere in the document, without them being siblings and without JavaScript measuring anything. The trigger takes an anchor-name, the tooltip points at it with position-anchor, and position-area places it on a three-by-three grid around the anchor. position-try-fallbacks then handles the case where it would overflow.

LAB-006live

How it works

  1. The trigger takes anchor-name: --lab-tip-1, a dashed identifier that names it as an anchor. The name is scoped to the document, not the component, so it has to be unique.
  2. The tooltip declares position-anchor: --lab-tip-1, which is the other half of the pairing, and position: absolute, which is what makes it positionable at all.
  3. position-area: top center places it on a three-by-three grid centred on the anchor. This replaces the usual dance of measuring the trigger's bounding box and writing top and left in pixels.
  4. position-try-fallbacks: flip-block tells the browser to try the mirrored position when the first one would overflow the viewport. That single declaration is the collision handling every tooltip library ships hundreds of lines for.
  5. Each of the three triggers needs its own anchor name. Give them all the same one and every tooltip anchors to whichever element came last in the document, which looks like a stacking bug and is not. A SCSS loop generates the three pairs.
  6. The whole block sits inside @supports (anchor-name: --a). Underneath it is an ordinary absolutely-positioned tooltip against a position: relative parent, so browsers without the API get the same tooltip in the same place.

Fifteen years of solving this in JavaScript

Positioning a tooltip next to the thing it describes has been, until very recently, a JavaScript problem. The element you want to position is usually not a sibling of its trigger. It probably needs to escape an ancestor's overflow: hidden. It has to flip when it would fall off the screen. Solve all three and you have written Popper, which is why almost everyone reaches for Popper.

That library, and every one like it, exists to do three things: measure the trigger's bounding box, write pixel coordinates onto the tooltip, and repeat that on every scroll and resize. It is a lot of machinery for "put this above that."

Anchor positioning removes the reason for all of it. The browser already knows where both elements are; the API is just a way of telling it that one should follow the other.

The pairing

Two properties, one on each end.

The trigger takes a name:

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

The tooltip points at it, and declares itself positionable:

.tip {
  position: absolute;
  position-anchor: --my-tip;
  position-area: top center;
}

position-area is the part that feels like cheating. It divides the space around the anchor into a three-by-three grid and lets you name a cell: top center, bottom right, block-start span-inline-end. No top, no left, no calc(), no measuring. Change top center to bottom center and the tooltip moves to the other side, correctly, including its own centring.

The name is global, and that will bite you

anchor-name is a dashed identifier scoped to the document, not to the component or the stylesheet. Give three triggers the same name and all three tooltips anchor to whichever one comes last in the document.

The symptom is strange enough to be worth naming: every tooltip appears in the same place, stacked on the last trigger, as though the others were not positioned at all. It looks like a z-index or stacking-context bug and it is neither.

So each instance needs its own name, and since a stylesheet cannot invent names for an unknown number of items, something has to generate them. Here that is a SCSS loop, because there are exactly three:

@for $i from 1 through 3 {
  .item:nth-child(#{$i}) {
    .trigger { anchor-name: --lab-tip-#{$i}; }
    .tip     { position-anchor: --lab-tip-#{$i}; }
  }
}

For a genuinely dynamic list you would set the name inline from the item's id instead, the same way the view transition morph sets its transition names.

Collision handling in one declaration

This is the part that justifies the whole feature:

position-try-fallbacks: flip-block;

If the tooltip would overflow in its chosen position, the browser tries the mirrored one. If you want more control, position-try-fallbacks takes a list of named @position-try rules and works down it until one fits.

That is the behaviour every tooltip library implements by hand, in JavaScript, re-running on scroll. Here it is one line, and it runs on the compositor.

What it does not solve

Anchor positioning places an element; it does not lift it out of an ancestor's overflow: hidden. Inside a scrolling panel, a tooltip anchored to something near the edge will still be clipped.

The fix is the top layer, which means the Popover API: popover="hint" on the tooltip and popovertarget on the trigger. The two features are designed to be used together, and in production I would. This demo deliberately does not, because popovers need JavaScript to open on hover, and I wanted to see how far the CSS gets on its own. The answer is: further than I expected.

Shipping it today

Chromium only, at the time of writing. That is fine, because the fallback is the thing you were already shipping:

/* Everyone gets this */
.tip { position: absolute; bottom: calc(100% + 8px); left: 50%; translate: -50% 0; }
 
/* Chromium also gets this */
@supports (anchor-name: --a) { /* the real thing */ }

The tooltip appears in the same place either way. What Chromium adds is the flip, and a tooltip that does not flip is a tooltip that occasionally sits half off the screen. That is a reasonable thing to give some visitors and not others.

The source

MIT licensed. Use it in anything, no attribution needed.

Read straight off the file the demo above imports, then highlighted at build time. What you are reading is what is running.

Demo.tsxtsx
import styles from "./Demo.module.scss";

const TERMS = [
  { label: "anchor-name", tip: "Names an element so others can be positioned against it." },
  { label: "position-anchor", tip: "Points a positioned element at a name to anchor to." },
  { label: "position-area", tip: "Places it on a 3x3 grid around the anchor: top center, block-end span-inline-start, and so on." },
];

/**
 * No hooks and no handlers: hover, focus and positioning are all CSS. This
 * stays a component only so the Lab can mount every experiment the same way.
 */
export default function AnchorTooltip() {
  return (
    <div className={styles.wrap}>
      <p className={styles.hint}>Hover or tab to a term</p>

      <ul className={styles.list}>
        {TERMS.map((t) => (
          <li key={t.label} className={styles.item}>
            {/* A button, not a div: it has to be reachable by keyboard for
                :focus-within to ever fire. */}
            <button type="button" className={styles.trigger}>
              {t.label}
            </button>
            <span className={styles.tip} role="tooltip">
              {t.tip}
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}
Demo.module.scssscss
.wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
  padding: clamp(48px, 9vw, 72px) clamp(16px, 4vw, 32px) clamp(20px, 4vw, 32px);
}

.hint {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--silver-500);
}

.list {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 10px;
}

.item {
  position: relative; /* the fallback's containing block */
}

.trigger {
  cursor: help;
  font-family: var(--mono);
  font-size: 12px;
  letter-spacing: 0.04em;
  padding: 8px 14px;
  border-radius: var(--r-pill);
  border: 1px dashed var(--line);
  background: var(--glass);
  color: var(--silver-200);
  transition: 0.16s ease;
}

.item:hover .trigger,
.item:focus-within .trigger {
  color: var(--silver-050);
  border-color: var(--accent);
  border-style: solid;
}

.tip {
  width: max-content;
  max-width: min(24ch, 80cqi);
  padding: 9px 13px;
  border-radius: var(--r-sm);
  border: 1px solid var(--line);
  background: var(--ink-650);
  box-shadow: var(--shadow-2);
  color: var(--silver-100, var(--silver-200));
  font-size: 12.5px;
  line-height: 1.5;
  text-align: center;

  opacity: 0;
  pointer-events: none;
  transition: opacity 0.16s ease, translate 0.16s ease;
  translate: 0 4px;
}

.item:hover .tip,
.item:focus-within .tip {
  opacity: 1;
  translate: 0 0;
}

/* Fallback: ordinary absolute positioning against .item. Shipped first so it
   is what a browser without anchor positioning uses. */
.tip {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  translate: -50% 4px;
}

.item:hover .tip,
.item:focus-within .tip {
  translate: -50% 0;
}

/* The real thing. Each trigger needs its own anchor name: give three triggers
   the same one and every tooltip anchors to whichever element came last. */
@supports (anchor-name: --a) {
  @for $i from 1 through 3 {
    .item:nth-child(#{$i}) {
      .trigger {
        anchor-name: --lab-tip-#{$i};
      }
      .tip {
        position-anchor: --lab-tip-#{$i};
      }
    }
  }

  .tip {
    position: absolute;
    inset: auto; /* clear the fallback offsets so position-area owns placement */
    left: auto;
    bottom: auto;
    translate: 0 4px;

    position-area: top center;
    margin-bottom: 8px;
    /* If there is no room above, flip below instead of overflowing. */
    position-try-fallbacks: flip-block;
  }

  .item:hover .tip,
  .item:focus-within .tip {
    translate: 0 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .tip {
    transition: opacity 0.001ms;
  }
}

Questions worth answering

Does it work in Safari?

Chrome and Edge 125+ only, at the time of writing. Safari and Firefox have both signalled support but have not shipped it. This demo ships the classic absolute-positioning fallback underneath, so everyone sees a working tooltip; only the collision handling is Chromium-only.

Does it need JavaScript?

None. Hover, focus and placement are all CSS, which is the whole argument for the feature: tooltip positioning has been a JavaScript problem for fifteen years and it stops being one here.

Is it accessible?

Reduced motion. The fade and the four-pixel rise shorten to nothing. The tooltip still appears; it simply does not travel.

Keyboard. The triggers are real buttons, so they are reachable by Tab, and the tooltip shows on :focus-within as well as :hover. It carries role="tooltip". Note that this pattern shows on focus rather than on a keypress, so it suits short definitions rather than anything a visitor needs time to read.

Want this kind of care on a build?

I build fast, production-grade sites for freelance clients. The polish is the same; there is just more of it.