CSS anchor positioning lets you place one element relative to another element anywhere on the page. You give the reference element a name with anchor-name, point the positioned element at it with position-anchor, and then place it with position-area or the anchor() function. The browser keeps the two together while the page scrolls or resizes.

Tooltips, dropdowns and popovers used to need a JavaScript library to measure elements, work out coordinates and flip menus near the edge of the screen. Anchor positioning does that in CSS. It also works well with the HTML popover attribute: the popover gives you the top layer, Escape and click-outside, and anchor positioning decides where the popover goes.

The eight demos below cover position-area, flipping with position-try-fallbacks and @position-try, sizing with anchor-size(), animating anchor(), side notes, a pointer-anchored context menu, inline validation messages and anchor-scope. Each one still works in browsers without anchor positioning and tells you when it is using its fallback.

Tooltip placed with position-area

Each toolbar button gets an anchor name, and each tooltip names its button in position-anchor. Both use the same custom property (style="--a:--tip-bold") so one CSS rule covers every button: anchor-name: var(--a) on the button and position-anchor: var(--a) on the tooltip.

position-area treats the anchor as the middle cell of a 3x3 grid. top center puts the tooltip in the cell above the button, right center puts it to the side, and top span-right starts at the button's left edge and extends to the right. Use the buttons under the toolbar to switch between them.

Two details matter. In the top center area the space available to the tooltip is based on the button's narrow column, so even short labels wrap; width: max-content with a max-width fixes that. The popover user-agent styles also set inset: 0 and margin: auto, so reset them with inset: auto and use a one-sided margin for the gap. position-try-fallbacks: flip-block moves the tooltip below the button when there is no room above.

The tooltips are popover="manual" elements in the top layer, so the editor card can keep overflow: hidden without clipping them. A short hide delay lets the pointer move onto the tooltip, and Escape hides it, which WCAG 1.4.13 asks for.

Open the demo in a new tab
Show the code
HTML
<div class="editor">
  <div class="toolbar" role="group" aria-labelledby="ed-lbl" data-place="top">
    <button class="tb-btn" type="button" style="--a:--tip-bold" aria-label="Bold" aria-describedby="tip-bold">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z"/></svg>
    </button>
    <div class="tip" id="tip-bold" role="tooltip" popover="manual" style="--a:--tip-bold">Bold<kbd>Ctrl B</kbd></div>

    <button class="tb-btn" type="button" style="--a:--tip-italic" aria-label="Italic" aria-describedby="tip-italic">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M10 5h8M6 19h8M14 5l-4 14"/></svg>
    </button>
    <div class="tip" id="tip-italic" role="tooltip" popover="manual" style="--a:--tip-italic">Italic<kbd>Ctrl I</kbd></div>

    <button class="tb-btn" type="button" style="--a:--tip-link" aria-label="Insert link" aria-describedby="tip-link">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>
    </button>
    <div class="tip" id="tip-link" role="tooltip" popover="manual" style="--a:--tip-link">Insert link<kbd>Ctrl K</kbd></div>

    <span class="tb-sep" aria-hidden="true"></span>

    <button class="tb-btn" type="button" style="--a:--tip-comment" aria-label="Add comment" aria-describedby="tip-comment">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round" aria-hidden="true"><path d="M5 5h14v10H10l-5 4z"/></svg>
    </button>
    <div class="tip" id="tip-comment" role="tooltip" popover="manual" style="--a:--tip-comment">Comment on the selected text<kbd>Ctrl Alt M</kbd></div>
  </div>
  <p class="editor-body">Version 4.2 adds offline drafts, faster image uploads and a new keyboard shortcut for comments.</p>
</div>
CSS
/* Editor card: overflow is clipped, yet the tooltips still show,
   because each tooltip is a popover rendered in the top layer. */
.editor{overflow:hidden;border:1px solid #dbe1ea;border-radius:14px;background:#fff;box-shadow:0 1px 2px rgba(15,23,42,.06)}
.toolbar{display:flex;flex-wrap:wrap;gap:4px;padding:8px;border-bottom:1px solid #e2e8f0;background:#f8fafc}
.tb-btn{display:grid;place-items:center;width:38px;height:38px;padding:0;border:1px solid transparent;border-radius:9px;background:transparent;color:#334155;cursor:pointer}
.tb-btn:hover{background:#e2e8f0}
.tb-btn:focus-visible{outline:2px solid #4338ca;outline-offset:2px}
.tb-btn svg{width:18px;height:18px}
.tb-sep{width:1px;margin:6px 4px;background:#cbd5e1}
.editor-body{margin:0;padding:16px 18px 18px;color:#1e293b;font-size:.9375rem}

/* The tooltip itself */
.tip{margin:0;padding:6px 10px;border:0;border-radius:8px;background:#0f172a;color:#f8fafc;
  font-size:.8125rem;line-height:1.35;white-space:nowrap;pointer-events:none}
.tip kbd{margin-left:6px;padding:0 5px;border:1px solid #475569;border-radius:4px;color:#cbd5e1;font:inherit;font-size:.75rem}

/* Anchor positioning: name each button, point its tooltip at that name,
   then pick a cell of the 3x3 grid around the anchor with position-area. */
@supports (anchor-name: --a){
  .tb-btn{anchor-name:var(--a)}
  .tip{
    position-anchor:var(--a);
    position-area:top center;
    inset:auto;
    margin:0 0 8px;
    width:max-content;
    max-width:min(260px,calc(100vw - 16px));
    position-try-fallbacks:flip-block;   /* no room above? go below */
  }
  [data-place="bottom"] .tip{position-area:bottom center;margin:8px 0 0}
  [data-place="right"] .tip{position-area:right center;margin:0 0 0 8px;position-try-fallbacks:flip-inline}
  [data-place="top-start"] .tip{position-area:top span-right}
}
JavaScript
// Show on hover and keyboard focus, hide on leave, blur or Escape.
// A short delay lets the pointer travel without the tooltip flickering.
const toolbar = document.querySelector('.toolbar');
const anchored = CSS.supports('anchor-name: --a');
let hideTimer;

toolbar.querySelectorAll('.tb-btn').forEach((btn) => {
  const tip = document.getElementById(btn.getAttribute('aria-describedby'));
  const show = () => {
    clearTimeout(hideTimer);
    toolbar.querySelectorAll('.tip:popover-open').forEach((t) => t !== tip && t.hidePopover());
    if (!tip.matches(':popover-open')) tip.showPopover();
    if (!anchored) place(tip, btn);
  };
  const hide = () => { hideTimer = setTimeout(() => tip.matches(':popover-open') && tip.hidePopover(), 120); };
  btn.addEventListener('pointerenter', show);
  btn.addEventListener('focus', show);
  btn.addEventListener('pointerleave', hide);
  btn.addEventListener('blur', hide);
  btn.addEventListener('keydown', (e) => { if (e.key === 'Escape') tip.hidePopover(); });
});

// Demo control: switch the position-area variant.
document.querySelectorAll('input[name="place"]').forEach((r) =>
  r.addEventListener('change', () => { toolbar.dataset.place = r.value; }));

// Fallback for browsers without anchor positioning: measure and set top/left.
function place(tip, btn) {
  const a = btn.getBoundingClientRect();
  const t = tip.getBoundingClientRect();
  const gap = 8, where = toolbar.dataset.place;
  let top = a.top - t.height - gap;
  let left = a.left + a.width / 2 - t.width / 2;
  if (where === 'bottom' || (where !== 'right' && top < gap)) top = a.bottom + gap;
  if (where === 'right') { top = a.top + a.height / 2 - t.height / 2; left = a.right + gap; }
  if (where === 'top-start') left = a.left;
  left = Math.max(gap, Math.min(left, innerWidth - t.width - gap));
  Object.assign(tip.style, { position: 'fixed', inset: 'auto', margin: '0', top: top + 'px', left: left + 'px' });
}

Both menus share one rule: position-area: bottom span-right with position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline. The browser tries the preferred position first, then each fallback in order, and uses the first one that fits inside the viewport.

The workspace menu in the top bar fits below and to the right, so it stays there. The Export button is in the bottom-right corner. Below and to the right would overflow, flip-block alone still overflows on the right, and flip-inline alone overflows at the bottom, so the browser uses flip-block flip-inline and opens the menu up and to the left.

Flip tactics also mirror margins, so the 6px gap set with margin-top becomes a gap at the bottom when the menu flips up. The markup is a button with popovertarget and a div with the popover attribute; that pairing already gives you Escape, click-outside to close and focus that returns to the button.

Open the demo in a new tab
Show the code
HTML
<header class="bar">
  <button class="menu-btn" type="button" popovertarget="ws-menu" style="--a:--ws">
    Northwind Studio
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg>
  </button>
  <div class="menu" id="ws-menu" popover style="--a:--ws">
    <ul>
      <li><button type="button">Switch to Harbor Labs</button></li>
      <li><button type="button">Workspace settings</button></li>
      <li><button type="button">Invite people</button></li>
    </ul>
  </div>
  <h1 class="bar-title">Q4 roadmap</h1>
</header>

<main class="content">
  <h2>Two buttons, one set of rules</h2>
  <p>The workspace menu in the top bar has room below and to the right, so it uses its preferred position.</p>
  <p>The export button sits in the bottom-right corner. Below and right would overflow, so the browser flips the menu up and to the left.</p>
</main>

<footer class="foot">
  <p class="status" role="status" aria-live="polite"></p>
  <button class="menu-btn" type="button" popovertarget="export-menu" style="--a:--export">
    Export
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg>
  </button>
  <div class="menu" id="export-menu" popover style="--a:--export">
    <ul>
      <li><button type="button">Download as PDF</button></li>
      <li><button type="button">Download as CSV</button></li>
      <li><button type="button">Copy share link</button></li>
    </ul>
    <hr>
    <ul><li><button type="button" class="danger">Revoke public links</button></li></ul>
  </div>
</footer>
CSS
.menu-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 12px;border:1px solid #cbd5e1;border-radius:9px;background:#fff;color:#0f172a;font:inherit;font-size:.875rem;font-weight:550;cursor:pointer}
.menu-btn:hover{background:#f1f5f9}
.menu-btn:focus-visible{outline:2px solid #4338ca;outline-offset:2px}
.menu-btn svg{width:14px;height:14px}

.menu{min-width:200px;margin:0;padding:6px;border:1px solid #dbe1ea;border-radius:12px;background:#fff;color:#0f172a;
  box-shadow:0 12px 32px -8px rgba(15,23,42,.25)}
.menu ul{margin:0;padding:0;list-style:none}
.menu button{display:block;width:100%;padding:7px 10px;border:0;border-radius:8px;background:none;color:inherit;font:inherit;font-size:.875rem;text-align:left;cursor:pointer}
.menu button:hover{background:#eef2ff}
.menu button:focus-visible{outline:2px solid #4338ca;outline-offset:-2px;background:#eef2ff}
.menu hr{margin:6px 4px;border:0;border-top:1px solid #e2e8f0}
.menu .danger{color:#b91c1c}

/* One rule set for every menu. The menu prefers to open below and to the right
   of its button. If that overflows the viewport the browser tries each fallback
   in order and uses the first one that fits. */
@supports (anchor-name: --a){
  .menu-btn{anchor-name:var(--a)}
  .menu{
    position-anchor:var(--a);
    position-area:bottom span-right;
    inset:auto;
    margin:6px 0 0;
    position-try-fallbacks:flip-block, flip-inline, flip-block flip-inline;
  }
}
JavaScript
// popover="auto" (the default) already gives Escape, click-outside and focus return.
// Menu items just report what they did and close the menu.
const status = document.querySelector('.status');
document.querySelectorAll('.menu').forEach((menu) => {
  menu.addEventListener('click', (e) => {
    const item = e.target.closest('button');
    if (!item) return;
    status.textContent = item.textContent + ': done.';
    menu.hidePopover();
  });
});

// Fallback: without anchor positioning, place the menu with JavaScript and flip it
// when it would overflow the viewport, the same order as position-try-fallbacks.
if (!CSS.supports('anchor-name: --a')) {
  const place = (menu) => {
    const btn = document.querySelector(`[popovertarget="${menu.id}"]`);
    const a = btn.getBoundingClientRect(), m = menu.getBoundingClientRect(), gap = 6;
    let top = a.bottom + gap, left = a.left;
    if (top + m.height > innerHeight) top = Math.max(gap, a.top - gap - m.height);
    if (left + m.width > innerWidth) left = Math.max(gap, a.right - m.width);
    Object.assign(menu.style, { position: 'fixed', inset: 'auto', margin: '0', top: top + 'px', left: left + 'px' });
  };
  document.querySelectorAll('.menu').forEach((menu) => {
    menu.addEventListener('toggle', (e) => { if (e.newState === 'open') place(menu); });
  });
  addEventListener('resize', () => document.querySelectorAll('.menu:popover-open').forEach(place));
}

Select-style picker sized with anchor-size()

anchor-size() returns a dimension of the anchor element, and it works in width, height, min and max sizes, insets and margins. Here min-width: anchor-size(width) makes each option list at least as wide as the button that opened it, so a narrow size picker and a wide delivery picker share one rule.

max-height: calc(anchor-size(height) * 5) limits the list to about five button-heights before it scrolls. Use min-width rather than width when the options can be longer than the button text, or the labels will wrap.

The options are native radio buttons inside the popover. Arrow keys change the selection, Enter or a click confirms it and closes the list, and the button's accessible name combines its visible label with the current value through aria-labelledby. Chrome also has a customizable select (appearance: base-select), but it is not yet available in all browsers.

Open the demo in a new tab
Show the code
HTML
<div class="row">
  <div>
    <span class="field-label" id="size-lbl">Size</span>
    <button class="picker picker-size" type="button" id="size-btn" popovertarget="size-opts"
            aria-labelledby="size-lbl size-btn" style="--a:--size">
      <span data-value>M</span>
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg>
    </button>
    <div class="options" id="size-opts" popover style="--a:--size">
      <fieldset>
        <legend>Size</legend>
        <label><input type="radio" name="size" value="XS">XS</label>
        <label><input type="radio" name="size" value="S">S</label>
        <label><input type="radio" name="size" value="M" checked>M</label>
        <label><input type="radio" name="size" value="L">L</label>
        <label><input type="radio" name="size" value="XL">XL</label>
        <label><input type="radio" name="size" value="XXL">XXL <small>2 left</small></label>
        <label><input type="radio" name="size" value="3XL">3XL <small>Sold out</small></label>
      </fieldset>
    </div>
  </div>

  <div>
    <span class="field-label" id="del-lbl">Delivery</span>
    <button class="picker picker-delivery" type="button" id="del-btn" popovertarget="del-opts"
            aria-labelledby="del-lbl del-btn" style="--a:--delivery">
      <span data-value>Standard, 3–5 days</span>
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg>
    </button>
    <div class="options" id="del-opts" popover style="--a:--delivery">
      <fieldset>
        <legend>Delivery</legend>
        <label><input type="radio" name="delivery" value="Standard, 3–5 days" checked>Standard, 3–5 days <small>Free</small></label>
        <label><input type="radio" name="delivery" value="Express, 1–2 days">Express, 1–2 days <small>$9</small></label>
        <label><input type="radio" name="delivery" value="Pick up in store">Pick up in store <small>Free</small></label>
      </fieldset>
    </div>
  </div>
</div>
CSS
.field-label{display:block;margin:0 0 4px;color:#334155;font-size:.8125rem;font-weight:600}
.picker{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:42px;padding:8px 12px;
  border:1px solid #94a3b8;border-radius:10px;background:#fff;color:#0f172a;font:inherit;font-size:.9375rem;text-align:left;cursor:pointer}
.picker:hover{border-color:#64748b}
.picker:focus-visible{outline:2px solid #4338ca;outline-offset:2px}
.picker svg{flex:none;width:14px;height:14px;color:#475569}
.picker-size{width:120px}
.picker-delivery{width:260px;max-width:100%}

.options{margin:0;padding:4px;border:1px solid #dbe1ea;border-radius:10px;background:#fff;color:#0f172a;
  box-shadow:0 12px 28px -10px rgba(15,23,42,.3);overflow:auto}
.options fieldset{margin:0;padding:0;border:0}
.options legend{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.options label{display:flex;align-items:center;gap:8px;padding:7px 9px;border-radius:7px;font-size:.9375rem;cursor:pointer}
.options label:hover{background:#f1f5f9}
.options input{margin:0;accent-color:#4338ca}
.options input:focus-visible{outline:2px solid #4338ca;outline-offset:2px}
.options label:has(:checked){background:#eef2ff;color:#312e81;font-weight:600}
.options small{margin-left:auto;padding-left:8px;color:#475569;font-weight:400;font-size:.8125rem}

@supports (anchor-name: --a){
  .picker{anchor-name:var(--a)}
  .options{
    position-anchor:var(--a);
    position-area:bottom span-right;
    inset:auto;
    margin:4px 0 0;
    /* At least as wide as the button it belongs to, never narrower.
       anchor-size() reads the anchor's box, so one rule fits every picker. */
    min-width:anchor-size(width);
    /* Show about five rows before scrolling: five times the button height. */
    max-height:calc(anchor-size(height) * 5);
    position-try-fallbacks:flip-block;
  }
}
JavaScript
// Native radios give arrow-key selection for free. Opening moves focus to the
// checked option; a mouse click or Enter picks it and closes the list.
document.querySelectorAll('.options').forEach((list) => {
  const btn = document.querySelector(`[popovertarget="${list.id}"]`);
  const pick = () => list.hidePopover();
  list.addEventListener('toggle', (e) => {
    if (e.newState !== 'open') return;
    if (!CSS.supports('anchor-name: --a')) place(list, btn);
    list.querySelector(':checked').focus();
  });
  list.addEventListener('change', (e) => { btn.querySelector('[data-value]').textContent = e.target.value; });
  list.addEventListener('click', (e) => { if (e.target.matches('input') && e.detail > 0) pick(); });
  list.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); pick(); } });
});

// Fallback without anchor positioning: copy the button's size and position.
function place(list, btn) {
  const a = btn.getBoundingClientRect();
  Object.assign(list.style, { position: 'fixed', inset: 'auto', margin: '0', minWidth: a.width + 'px', maxHeight: a.height * 5 + 'px' });
  const h = list.getBoundingClientRect().height;
  const top = a.bottom + 4 + h > innerHeight ? Math.max(4, a.top - 4 - h) : a.bottom + 4;
  Object.assign(list.style, { top: top + 'px', left: Math.max(4, Math.min(a.left, innerWidth - list.offsetWidth - 4)) + 'px' });
}

Sliding tab highlight with anchor()

Only the selected tab carries anchor-name: --tab-active. One highlight element inside the tab list sets top, right, bottom and left to anchor(top), anchor(right), anchor(bottom) and anchor(left), so it covers whichever tab has the name. Selecting a tab moves the name and the highlight follows.

A second, fainter highlight uses the same idea for hover: hovered tabs get --tab-hover, and while the list has a hovered tab the faint box switches its position-anchor to that name. At rest it sits under the active tab, so it always slides out from there.

Chrome, Edge and Safari animate changes to anchor() values with an ordinary transition on inset. Firefox does not animate anchor() yet, so the highlight jumps there; that is a fine way for the demo to degrade. The transition is turned off under prefers-reduced-motion, and the JavaScript only handles the ARIA tab pattern (arrow keys, Home and End).

Open the demo in a new tab
Show the code
HTML
<div class="tabs" role="tablist" aria-labelledby="prod-title">
  <button class="tab" role="tab" id="t-overview" aria-controls="p-overview" aria-selected="true">Overview</button>
  <button class="tab" role="tab" id="t-specs" aria-controls="p-specs" aria-selected="false" tabindex="-1">Specifications</button>
  <button class="tab" role="tab" id="t-reviews" aria-controls="p-reviews" aria-selected="false" tabindex="-1">Reviews (128)</button>
  <button class="tab" role="tab" id="t-ship" aria-controls="p-ship" aria-selected="false" tabindex="-1">Shipping</button>
  <span class="tab-pill" aria-hidden="true"></span>
  <span class="tab-hover" aria-hidden="true"></span>
</div>

<div class="panel" role="tabpanel" id="p-overview" aria-labelledby="t-overview" tabindex="0">
  <p>A 290 g trail shoe with a waterproof membrane and a 4 mm lug outsole for wet roots and loose gravel.</p>
</div>
<div class="panel" role="tabpanel" id="p-specs" aria-labelledby="t-specs" tabindex="0" hidden>
  <ul><li>Drop: 6 mm</li><li>Stack height: 31 mm heel, 25 mm forefoot</li><li>Upper: recycled ripstop mesh</li></ul>
</div>
<div class="panel" role="tabpanel" id="p-reviews" aria-labelledby="t-reviews" tabindex="0" hidden>
  <p>4.6 out of 5 from 128 reviews. Runners mention the grip on wet rock most often; a few say the toe box runs narrow.</p>
</div>
<div class="panel" role="tabpanel" id="p-ship" aria-labelledby="t-ship" tabindex="0" hidden>
  <p>Ships in 1–2 business days. Free returns within 30 days, even if you have run in them outdoors.</p>
</div>
CSS
.tabs{position:relative;display:flex;flex-wrap:wrap;gap:2px;padding:4px;border-radius:12px;background:#eef1f6}
.tab{position:relative;z-index:1;flex:none;padding:7px 14px;border:0;border-radius:9px;background:none;color:#334155;
  font:inherit;font-size:.875rem;font-weight:550;white-space:nowrap;cursor:pointer}
.tab[aria-selected="true"]{color:#0f172a}
.tab:focus-visible{outline:2px solid #4338ca;outline-offset:1px}

/* Two highlight boxes that live once in the tab list. They take their
   size and position from whichever tab currently carries the anchor name. */
.tab-pill,.tab-hover{position:absolute;border-radius:9px;pointer-events:none}
.tab-pill{z-index:0;background:#fff;box-shadow:0 1px 3px rgba(15,23,42,.14)}
.tab-hover{z-index:0;background:rgba(255,255,255,.55);opacity:0;transition:opacity .2s}

@supports (anchor-name: --a){
  .tab[aria-selected="true"]{anchor-name:--tab-active}
  .tab:hover{anchor-name:--tab-hover}
  .tab[aria-selected="true"]:hover{anchor-name:--tab-active,--tab-hover}

  .tab-pill,.tab-hover{
    position-anchor:--tab-active;
    top:anchor(top);
    left:anchor(left);
    right:anchor(right);
    bottom:anchor(bottom);
    transition:inset .3s cubic-bezier(.3,.7,.3,1),opacity .2s;
  }
  /* While a tab is hovered the faint box follows the pointer; at rest it sits under the active tab. */
  .tabs:has(.tab:hover) .tab-hover{position-anchor:--tab-hover;opacity:1}
}
/* Without anchor positioning: no sliding box, the selected tab gets its own background. */
@supports not (anchor-name: --a){
  .tab-pill,.tab-hover{display:none}
  .tab[aria-selected="true"]{background:#fff;box-shadow:0 1px 3px rgba(15,23,42,.14)}
  .tab:hover{background:rgba(255,255,255,.55)}
}
@media (prefers-reduced-motion:reduce){
  .tab-pill,.tab-hover{transition:none}
}
JavaScript
// Standard ARIA tabs: click or arrow keys select a tab. CSS does the highlight;
// the script only flips aria-selected, so the anchor name moves with it.
const tabs = [...document.querySelectorAll('[role="tab"]')];
function select(tab) {
  tabs.forEach((t) => {
    const on = t === tab;
    t.setAttribute('aria-selected', on);
    t.tabIndex = on ? 0 : -1;
    document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
  });
  tab.focus();
}
tabs.forEach((tab, i) => {
  tab.addEventListener('click', () => select(tab));
  tab.addEventListener('keydown', (e) => {
    const n = tabs.length;
    const next = { ArrowRight: (i + 1) % n, ArrowLeft: (i - 1 + n) % n, Home: 0, End: n - 1 }[e.key];
    if (next === undefined) return;
    e.preventDefault();
    select(tabs[next]);
  });
});

Side notes anchored to their references

This demo uses anchors without any popover. On screens at least 600px wide each note leaves the list, moves into the right margin and lines up with its reference: position-anchor names the reference link, and top: anchor(top) matches their top edges. The horizontal position is plain CSS (right: 0 and a percentage width).

On narrow screens and in browsers without anchor positioning, the notes stay as an ordinary numbered list after the text. That is the same HTML, so nothing is lost.

Anchor positioning does not stop positioned elements from overlapping each other. If two references sit on nearby lines, their notes will collide, so keep notes short and references spread out, or switch to the list layout when there is not enough room.

Open the demo in a new tab
Show the code
HTML
<article class="essay">
  <p>Tim Berners-Lee's first browser, WorldWideWeb, ran on a NeXT computer at CERN in 1990.<a class="ref" href="#note-1" id="ref-1" style="--a:--ref-1" aria-label="Note 1">1</a> Early browsers decided for themselves how headings and paragraphs looked, and page authors had almost no say.</p>
  <p>In October 1994 Håkon Wium Lie, then working at CERN, published a proposal for Cascading HTML Style Sheets.<a class="ref" href="#note-2" id="ref-2" style="--a:--ref-2" aria-label="Note 2">2</a> Bert Bos joined the work, and the W3C published CSS level 1 as a Recommendation in December 1996.</p>
  <p>The idea that won was the cascade:<a class="ref" href="#note-3" id="ref-3" style="--a:--ref-3" aria-label="Note 3">3</a> the author, the reader and the browser can all supply rules, and a defined order decides which one applies.</p>
  <ol class="notes" aria-label="Notes">
    <li id="note-1" data-n="1" style="--a:--ref-1">The browser was later renamed Nexus to avoid confusion with the web itself.</li>
    <li id="note-2" data-n="2" style="--a:--ref-2">Lie's proposal, titled "Cascading HTML style sheets", is dated 10 October 1994.</li>
    <li id="note-3" data-n="3" style="--a:--ref-3">CSS 2 (May 1998) let a reader's !important rules beat the author's, reversing the CSS 1 order.</li>
  </ol>
</article>
CSS
.essay{position:relative}
.essay p{margin:0 0 12px;font-size:.96875rem}
.ref{margin-left:1px;padding:0 3px;border-radius:4px;color:#9a3412;font:700 .6875rem/1 ui-sans-serif,system-ui,sans-serif;vertical-align:super;text-decoration:none}
.ref:hover{background:#ffedd5}
.ref:focus-visible{outline:2px solid #9a3412;outline-offset:1px}

/* Default (and fallback): ordinary numbered notes after the text. */
.notes{margin:16px 0 0;padding:12px 0 0 20px;border-top:1px solid #e7e5e4;color:#44403c;font:.8125rem/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif}
.notes li{margin:0 0 6px;padding-left:2px}
.notes li::marker{color:#9a3412;font-weight:700}
.notes li:target{background:#ffedd5;border-radius:4px}

/* Wide screens with anchor positioning: each note moves into the right
   margin and lines up with the top of the reference that points to it. */
@supports (anchor-name: --a){
  @media (min-width:600px){
    .essay{padding-right:36%}
    .ref{anchor-name:var(--a)}
    .notes{margin:0;padding:0;border:0;list-style:none}
    .notes li{
      position:absolute;
      position-anchor:var(--a);
      top:anchor(top);        /* the reference's top edge... */
      right:0;                /* ...in the essay's right margin */
      width:32%;
      margin:0;
      padding:2px 0 2px 10px;
      border-left:2px solid #fdba74;
    }
    .notes li::before{content:attr(data-n) ". ";color:#9a3412;font-weight:700}
  }
}

Context menu anchored to the pointer

Anchors can be any element, including an empty one. On right-click the script moves a zero-size, position: fixed element to the pointer coordinates and gives it the anchor class. The menu uses position-area: bottom span-right with the three flip fallbacks, so near an edge it opens up or to the left without extra code.

The same menu opens from each row's actions button, anchored to that button. Those buttons sit in a scrolling list, and position-visibility: anchors-visible hides the menu when its button scrolls out of view. In our tests Chrome and Safari hid the menu, while Firefox 153 kept it visible, so test this property in each browser.

On macOS and Linux the contextmenu event fires on mousedown, and the mouseup that follows would light-dismiss a popover opened at that moment. The demo waits for pointerup before opening the menu. The menu uses role="menu" with arrow-key, Home and End navigation, the keyboard context-menu key or Shift+F10 opens it from the focused actions button, and Escape returns focus to that button.

Open the demo in a new tab
Show the code
HTML
<ul class="files" aria-labelledby="files-title">
  <li class="file" data-name="Q3-board-report.pdf"><span class="ico" aria-hidden="true">PDF</span><span class="name">Q3-board-report.pdf</span><span class="meta">2.4 MB</span>
    <button class="more" type="button" aria-haspopup="menu" aria-label="Actions for Q3-board-report.pdf"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg></button></li>
  <li class="file" data-name="brand-guidelines-v2.fig"><span class="ico" aria-hidden="true">FIG</span><span class="name">brand-guidelines-v2.fig</span><span class="meta">18 MB</span>
    <button class="more" type="button" aria-haspopup="menu" aria-label="Actions for brand-guidelines-v2.fig"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg></button></li>
  <li class="file" data-name="customer-interviews.csv"><span class="ico" aria-hidden="true">CSV</span><span class="name">customer-interviews.csv</span><span class="meta">96 KB</span>
    <button class="more" type="button" aria-haspopup="menu" aria-label="Actions for customer-interviews.csv"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg></button></li>
  <li class="file" data-name="launch-checklist.md"><span class="ico" aria-hidden="true">MD</span><span class="name">launch-checklist.md</span><span class="meta">8 KB</span>
    <button class="more" type="button" aria-haspopup="menu" aria-label="Actions for launch-checklist.md"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg></button></li>
  <li class="file" data-name="hero-video-cut3.mp4"><span class="ico" aria-hidden="true">MP4</span><span class="name">hero-video-cut3.mp4</span><span class="meta">212 MB</span>
    <button class="more" type="button" aria-haspopup="menu" aria-label="Actions for hero-video-cut3.mp4"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg></button></li>
  <li class="file" data-name="pricing-experiment.xlsx"><span class="ico" aria-hidden="true">XLS</span><span class="name">pricing-experiment.xlsx</span><span class="meta">340 KB</span>
    <button class="more" type="button" aria-haspopup="menu" aria-label="Actions for pricing-experiment.xlsx"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg></button></li>
  <li class="file" data-name="invoice-2026-0412.pdf"><span class="ico" aria-hidden="true">PDF</span><span class="name">invoice-2026-0412.pdf</span><span class="meta">180 KB</span>
    <button class="more" type="button" aria-haspopup="menu" aria-label="Actions for invoice-2026-0412.pdf"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg></button></li>
  <li class="file" data-name="team-offsite-photos.zip"><span class="ico" aria-hidden="true">ZIP</span><span class="name">team-offsite-photos.zip</span><span class="meta">1.2 GB</span>
    <button class="more" type="button" aria-haspopup="menu" aria-label="Actions for team-offsite-photos.zip"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg></button></li>
  <li class="file" data-name="api-changelog.md"><span class="ico" aria-hidden="true">MD</span><span class="name">api-changelog.md</span><span class="meta">14 KB</span>
    <button class="more" type="button" aria-haspopup="menu" aria-label="Actions for api-changelog.md"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg></button></li>
</ul>

<span class="ctx-point" aria-hidden="true"></span>
<div class="ctx" id="ctx" popover role="menu" aria-label="File actions">
  <button type="button" role="menuitem" tabindex="-1">Open</button>
  <button type="button" role="menuitem" tabindex="-1">Rename</button>
  <button type="button" role="menuitem" tabindex="-1">Duplicate</button>
  <button type="button" role="menuitem" tabindex="-1">Download</button>
  <div role="separator"></div>
  <button type="button" role="menuitem" tabindex="-1" class="danger">Move to trash</button>
</div>
CSS
.files{max-height:236px;margin:0;padding:4px;overflow:auto;list-style:none;border:1px solid #dbe1ea;border-radius:12px;background:#fff}
.file{display:flex;align-items:center;gap:10px;padding:6px 6px 6px 10px;border-radius:8px;font-size:.9375rem;user-select:none}
.file:hover,.file:focus-within{background:#f1f5f9}
.file .ico{flex:none;width:28px;height:28px;display:grid;place-items:center;border-radius:7px;background:#e0e7ff;color:#3730a3;font-size:.625rem;font-weight:700}
.file .name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file .meta{color:#475569;font-size:.8125rem;white-space:nowrap}
.more{flex:none;display:grid;place-items:center;width:32px;height:32px;padding:0;border:0;border-radius:8px;background:none;color:#334155;cursor:pointer}
.more:hover{background:#e2e8f0}
.more:focus-visible{outline:2px solid #4338ca;outline-offset:1px}
.more svg{width:18px;height:18px}

.ctx{min-width:184px;margin:0;padding:5px;border:1px solid #dbe1ea;border-radius:11px;background:#fff;color:#0f172a;box-shadow:0 14px 32px -10px rgba(15,23,42,.3)}
.ctx [role="menuitem"]{display:block;width:100%;padding:6px 10px;border:0;border-radius:7px;background:none;color:inherit;font:inherit;font-size:.875rem;text-align:left;cursor:pointer}
.ctx [role="menuitem"]:hover,.ctx [role="menuitem"]:focus-visible{background:#eef2ff;outline:none}
.ctx [role="menuitem"]:focus-visible{box-shadow:inset 0 0 0 2px #4338ca}
.ctx [role="separator"]{margin:5px 4px;border-top:1px solid #e2e8f0}
.ctx .danger{color:#b91c1c}

/* A zero-size element that JavaScript moves to the pointer. */
.ctx-point{position:fixed;left:var(--x,0);top:var(--y,0);width:0;height:0;pointer-events:none}

@supports (anchor-name: --a){
  /* Whichever element has .is-anchor (the pointer dot or a "more" button) is the anchor. */
  .is-anchor{anchor-name:--ctx}
  .ctx{
    position-anchor:--ctx;
    position-area:bottom span-right;
    inset:auto;
    margin:2px 0 0;
    position-try-fallbacks:flip-block, flip-inline, flip-block flip-inline;
    /* If the "more" button scrolls out of the list, hide the menu with it. */
    position-visibility:anchors-visible;
  }
  .ctx[data-from="button"]{position-area:bottom span-left}
}
JavaScript
const menu = document.getElementById('ctx');
const point = document.querySelector('.ctx-point');
const items = [...menu.querySelectorAll('[role="menuitem"]')];
const status = document.querySelector('.status');
const anchored = CSS.supports('anchor-name: --a');
let current = null; // { file, button }

function open(file, anchorEl, from, focusFirst) {
  document.querySelector('.is-anchor')?.classList.remove('is-anchor');
  anchorEl.classList.add('is-anchor');
  menu.dataset.from = from;
  current = { file, button: file.querySelector('.more') };
  if (menu.matches(':popover-open')) menu.hidePopover();
  menu.showPopover();
  if (!anchored) place(anchorEl, from);
  if (focusFirst) items[0].focus();
}

// Right-click: move the zero-size point to the pointer and anchor to it.
// Keyboard context menu (Shift+F10 / Menu key) has no pointer, so use the row's button.
const list = document.querySelector('.files');
let buttonDown = false; // on macOS and Linux contextmenu fires on mousedown
list.addEventListener('pointerdown', () => { buttonDown = true; });
addEventListener('pointerup', () => { buttonDown = false; });
list.addEventListener('contextmenu', (e) => {
  const file = e.target.closest('.file');
  if (!file) return;
  e.preventDefault();
  const fromKeyboard = !buttonDown && (e.target.matches('button') || (e.clientX === 0 && e.clientY === 0));
  if (fromKeyboard) return open(file, file.querySelector('.more'), 'button', true);
  point.style.setProperty('--x', e.clientX + 'px');
  point.style.setProperty('--y', e.clientY + 'px');
  const show = () => open(file, point, 'point', false);
  // Open after the button is released, or the popover's light dismiss closes it straight away.
  if (buttonDown) addEventListener('pointerup', () => setTimeout(show), { once: true });
  else show();
});

document.querySelectorAll('.more').forEach((btn) => {
  btn.addEventListener('click', (e) => open(btn.closest('.file'), btn, 'button', e.detail === 0));
});

// Menu keyboard support: arrows, Home/End; Escape is handled by popover itself.
menu.addEventListener('keydown', (e) => {
  const i = items.indexOf(document.activeElement);
  const next = { ArrowDown: i + 1, ArrowUp: i - 1, Home: 0, End: items.length - 1 }[e.key];
  if (next === undefined) return;
  e.preventDefault();
  items[(next + items.length) % items.length].focus();
});
menu.addEventListener('click', (e) => {
  const item = e.target.closest('[role="menuitem"]');
  if (!item || !current) return;
  status.textContent = `${item.textContent}: ${current.file.dataset.name}`;
  menu.hidePopover();
});
// Keep aria-expanded in sync; if focus was lost when the menu closed, return it to the row's button.
menu.addEventListener('toggle', (e) => {
  if (!current) return;
  current.button.setAttribute('aria-expanded', e.newState === 'open');
  if (e.newState === 'closed' && document.activeElement === document.body) current.button.focus();
});

// Fallback without anchor positioning: place the menu with top/left and keep it on screen.
function place(anchorEl, from) {
  const a = anchorEl.getBoundingClientRect(), m = menu.getBoundingClientRect(), gap = 4;
  let top = a.bottom + gap, left = from === 'button' ? a.right - m.width : a.left;
  if (top + m.height > innerHeight) top = a.top - gap - m.height;
  if (left + m.width > innerWidth) left = a.left - m.width;
  Object.assign(menu.style, { position: 'fixed', inset: 'auto', margin: '0',
    top: Math.max(gap, top) + 'px', left: Math.max(gap, left) + 'px' });
}

Validation messages next to form fields

Each message is anchored to its input with position-area: right span-all, so it sits beside the field and is centered on it. span-all gives the message the full height of the containing block, so a two-line message is not counted as overflowing a single field-height row.

A named fallback handles narrow screens. @position-try --below-field sets position-area: bottom span-right, a small top margin and width: anchor-size(width), and position-try-fallbacks: --below-field uses it when there is no room on the right. At phone widths every message moves under its field and matches the field's width.

Messages are written into elements linked with aria-describedby only after the user leaves a field or submits, and aria-invalid is set at the same time. On submit, focus moves to the first invalid field. Without anchor positioning the messages are in normal flow under each field.

Open the demo in a new tab
Show the code
HTML
<div class="form-wrap">
  <form class="signup" novalidate>
    <h1>Create your workspace</h1>
    <div class="field">
      <label for="ws">Workspace name</label>
      <input id="ws" name="ws" required minlength="3" autocomplete="organization" style="--a:--f-ws"
             aria-describedby="ws-msg" data-msg="Use at least 3 characters.">
      <p class="msg" id="ws-msg" style="--a:--f-ws"></p>
    </div>
    <div class="field">
      <label for="email">Work email</label>
      <input id="email" name="email" type="email" required autocomplete="email" style="--a:--f-email"
             aria-describedby="email-msg" data-msg="Enter an email like [email protected].">
      <p class="msg" id="email-msg" style="--a:--f-email"></p>
    </div>
    <div class="field">
      <label for="pw">Password</label>
      <input id="pw" name="pw" type="password" required minlength="12" autocomplete="new-password" style="--a:--f-pw"
             aria-describedby="pw-msg" data-msg="Use 12 or more characters.">
      <p class="msg" id="pw-msg" style="--a:--f-pw"></p>
    </div>
    <button class="submit" type="submit">Create workspace</button>
    <p class="done" role="status"></p>
  </form>
</div>
CSS
.form-wrap{position:relative}
.signup{width:100%;max-width:340px;padding:18px 18px 20px;border:1px solid #dbe1ea;border-radius:14px;background:#fff}
.signup h1{margin:0 0 14px;font-size:1.0625rem}
.field{margin:0 0 14px}
.field label{display:block;margin:0 0 4px;color:#334155;font-size:.8125rem;font-weight:600}
.field input{display:block;width:100%;padding:8px 10px;border:1px solid #94a3b8;border-radius:9px;background:#fff;color:#0f172a;font:inherit;font-size:.9375rem}
.field input:focus-visible{outline:2px solid #4338ca;outline-offset:1px;border-color:#4338ca}
.field input[aria-invalid="true"]{border-color:#b91c1c}
.submit{width:100%;margin-top:4px;padding:9px 14px;border:0;border-radius:9px;background:#4338ca;color:#fff;font:inherit;font-size:.9375rem;font-weight:600;cursor:pointer}
.submit:hover{background:#3730a3}
.submit:focus-visible{outline:2px solid #4338ca;outline-offset:2px}
.done{margin:12px 0 0;color:#166534;font-size:.875rem;font-weight:600}

/* The message: in normal flow under the field by default (the fallback). */
.msg{margin:4px 0 0;padding:4px 9px;border-left:3px solid #b91c1c;border-radius:6px;background:#fef2f2;color:#991b1b;font-size:.8125rem;line-height:1.35}
.msg:empty{display:none}

/* With anchor positioning the message floats to the right of its field.
   When there is no room (a narrow screen), the named fallback puts it
   underneath, exactly as wide as the field. */
@position-try --below-field{
  position-area:bottom span-right;
  margin:3px 0 0;
  width:anchor-size(width);
  max-width:none;
}
@supports (anchor-name: --a){
  .field{margin-bottom:34px}           /* room for a message underneath */
  .field input{anchor-name:var(--a)}
  .msg{
    position:absolute;
    position-anchor:var(--a);
    position-area:right span-all;  /* right column; span-all centres it on the field */
    margin:0 0 0 12px;
    width:max-content;
    max-width:260px;
    position-try-fallbacks:--below-field;
  }
}
JavaScript
// Messages are written into the page (not native bubbles) so screen readers get them
// through aria-describedby, and only after the user has left the field or submitted.
const form = document.querySelector('.signup');
const done = form.querySelector('.done');
const check = (input) => {
  const bad = !input.validity.valid;
  input.setAttribute('aria-invalid', bad);
  document.getElementById(input.getAttribute('aria-describedby')).textContent = bad ? input.dataset.msg : '';
  return !bad;
};
form.querySelectorAll('input').forEach((input) => {
  input.addEventListener('blur', () => input.value && check(input));
  input.addEventListener('input', () => input.getAttribute('aria-invalid') === 'true' && check(input));
});
form.addEventListener('submit', (e) => {
  e.preventDefault();
  const inputs = [...form.querySelectorAll('input')];
  const results = inputs.map(check);
  const first = inputs[results.indexOf(false)];
  done.textContent = first ? '' : 'Workspace created. Check your inbox to confirm the email.';
  if (first) first.focus();
});

Reusable anchor names with anchor-scope

In a repeated component you want to write the CSS once: every help button gets anchor-name: --help and every bubble uses position-anchor: --help. Without help the names clash, and each bubble attaches to the last element in the document with that name.

anchor-scope: --help on each list item limits the name to that item and its descendants. A bubble then finds the button in its own row, even though the bubble is a popover rendered in the top layer. Turn the switch off to see every bubble jump to the last row.

anchor-scope arrived later than the rest of the feature (Chrome and Edge 131), so this demo checks @supports (anchor-name: --a) and (anchor-scope: all) and uses the JavaScript fallback when either is missing.

Open the demo in a new tab
Show the code
HTML
<ul class="plan" aria-labelledby="plan-title">
  <li><svg class="check" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M8.2 13.6 4.9 10.3l-1.4 1.4 4.7 4.7 9-9-1.4-1.4z"/></svg>
    <span class="feat">Unlimited projects</span>
    <button class="help" type="button" popovertarget="h-projects" aria-label="About unlimited projects">?</button>
    <div class="bubble" id="h-projects" popover><strong>Unlimited projects.</strong> No cap on active or archived projects; storage is pooled across the team.</div></li>
  <li><svg class="check" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M8.2 13.6 4.9 10.3l-1.4 1.4 4.7 4.7 9-9-1.4-1.4z"/></svg>
    <span class="feat">200 GB shared storage</span>
    <button class="help" type="button" popovertarget="h-storage" aria-label="About shared storage">?</button>
    <div class="bubble" id="h-storage" popover><strong>200 GB shared.</strong> Every seat adds 10 GB on top of the base allowance.</div></li>
  <li><svg class="check" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M8.2 13.6 4.9 10.3l-1.4 1.4 4.7 4.7 9-9-1.4-1.4z"/></svg>
    <span class="feat">Single sign-on (SAML)</span>
    <button class="help" type="button" popovertarget="h-sso" aria-label="About single sign-on">?</button>
    <div class="bubble" id="h-sso" popover><strong>SAML SSO.</strong> Works with Okta, Microsoft Entra ID and Google Workspace.</div></li>
  <li><svg class="check" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M8.2 13.6 4.9 10.3l-1.4 1.4 4.7 4.7 9-9-1.4-1.4z"/></svg>
    <span class="feat">Audit log, 12 months</span>
    <button class="help" type="button" popovertarget="h-audit" aria-label="About the audit log">?</button>
    <div class="bubble" id="h-audit" popover><strong>Audit log.</strong> Sign-ins, permission changes and exports, kept for 12 months and exportable as CSV.</div></li>
  <li><svg class="check" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M8.2 13.6 4.9 10.3l-1.4 1.4 4.7 4.7 9-9-1.4-1.4z"/></svg>
    <span class="feat">Priority support</span>
    <button class="help" type="button" popovertarget="h-support" aria-label="About priority support">?</button>
    <div class="bubble" id="h-support" popover><strong>Priority support.</strong> First reply within 4 business hours, by email or chat.</div></li>
</ul>
CSS
.plan{margin:0;padding:0;list-style:none;border:1px solid #dbe1ea;border-radius:12px;background:#fff}
.plan li{display:flex;align-items:center;gap:10px;padding:9px 12px;border-top:1px solid #edf0f5;font-size:.9375rem}
.plan li:first-child{border-top:0}
.plan .check{flex:none;width:18px;height:18px;color:#15803d}
.plan .feat{flex:1}
.help{flex:none;display:grid;place-items:center;width:26px;height:26px;padding:0;border:1px solid #cbd5e1;border-radius:50%;
  background:#fff;color:#334155;font:700 .8125rem/1 inherit;cursor:pointer}
.help:hover{background:#f1f5f9}
.help:focus-visible{outline:2px solid #4338ca;outline-offset:2px}
.bubble{width:min(250px,calc(100vw - 32px));margin:0;padding:9px 11px;border:0;border-radius:10px;background:#1e1b4b;color:#e0e7ff;
  font-size:.8125rem;line-height:1.45;box-shadow:0 10px 24px -8px rgba(15,23,42,.35)}
.bubble strong{color:#fff}

/* Every row reuses the same two names. anchor-scope limits each name to its
   own row, so a bubble always finds the button next to it. */
@supports (anchor-name: --a) and (anchor-scope: all){
  .plan li{anchor-scope:--help}
  .help{anchor-name:--help}
  .bubble{
    position-anchor:--help;
    position-area:bottom span-left;
    inset:auto;
    margin:6px 0 0;
    position-try-fallbacks:flip-block;
  }
  .plan.no-scope li{anchor-scope:none}   /* demo switch: see what breaks */
}
JavaScript
// Demo switch only: toggles the class that sets anchor-scope: none.
const plan = document.querySelector('.plan');
document.getElementById('scope-toggle').addEventListener('change', (e) => {
  plan.classList.toggle('no-scope', !e.target.checked);
  document.querySelectorAll('.bubble:popover-open').forEach((b) => b.hidePopover());
});

// Fallback without anchor-scope: place each bubble under its own button.
if (!CSS.supports('anchor-scope: all')) {
  document.querySelectorAll('.bubble').forEach((bubble) => {
    bubble.addEventListener('toggle', (e) => {
      if (e.newState !== 'open') return;
      const a = bubble.previousElementSibling.getBoundingClientRect();
      const b = bubble.getBoundingClientRect(), gap = 6;
      let top = a.bottom + gap;
      if (top + b.height > innerHeight) top = a.top - gap - b.height;
      Object.assign(bubble.style, { position: 'fixed', inset: 'auto', margin: '0',
        top: Math.max(gap, top) + 'px', left: Math.max(gap, a.right - b.width) + 'px' });
    });
  });
}

Fallbacks and progressive enhancement

Put every anchor property inside @supports (anchor-name: --a). Browsers without support then ignore the whole block and use whatever comes before it. Each demo has a short note that appears only under @supports not (anchor-name: --a), so you can see when the fallback is active.

There are two fallback patterns. For popovers, a few lines of JavaScript run only when CSS.supports('anchor-name: --a') is false: they read getBoundingClientRect() from the button and the popover, set position: fixed with top and left, and flip the popover when it would leave the viewport. For layout effects such as the tab highlight and the side notes, skip the JavaScript and design a static version: an outlined active tab, notes listed under the text.

Check sub-features separately when you use them. position-area, position-try-fallbacks and anchor-scope arrived in Chrome after anchor-name, and position-visibility arrived in Safari 26.2. Firefox supports the core properties from version 147 but does not animate anchor() values. Libraries such as Floating UI are still worth considering if you must support browsers from before 2026 identically.

Accessibility checklist

Anchor positioning only changes where something is drawn. It does not change the DOM order, the accessibility tree or focus order, so the semantics have to come from HTML and ARIA.

Use popover with popovertarget for menus and disclosures; you get Escape, click-outside and focus return without code. Keep the popover close to its button in the source, because Tab order follows the DOM. For tooltips, link the text with aria-describedby, show it on focus as well as hover, let the pointer move onto it, and close it on Escape.

Interactive popovers should either receive focus when they open (the listbox and context menu demos do this) or follow their button in the tab order. Keep text contrast at 4.5:1 inside bubbles and menus, give every control a visible focus style, and turn off sliding transitions under prefers-reduced-motion.

Frequently asked questions

Which browsers support CSS anchor positioning?

Chrome and Edge 125, Safari 26 and Firefox 147 support the core properties, which made the feature Baseline newly available in January 2026. Some parts came later: position-area needs Chrome 129, position-try-fallbacks 128 and anchor-scope 131. Firefox does not animate anchor() values yet.

Do I still need JavaScript for tooltips and dropdowns?

Not for positioning in current browsers. The popover attribute handles showing, hiding, Escape and click-outside, and anchor positioning handles placement and flipping. You still need a little JavaScript for hover-triggered tooltips, ARIA keyboard patterns such as menus and tabs, and a fallback for older browsers.

What is the difference between position-area and anchor()?

position-area places the element in one cell, or a span of cells, of a 3x3 grid around the anchor, and handles alignment for you. anchor() returns the position of a specific anchor edge for use in top, right, bottom or left, which is useful for exact offsets, for stretching between two anchors, and for animated effects such as the tab highlight.

How do I flip a popover when it hits the edge of the screen?

Add position-try-fallbacks with one or more options, for example flip-block, flip-inline, flip-block flip-inline. The browser tries the base position and then each fallback in order, and uses the first that does not overflow. For a completely different layout, define a named @position-try rule and list its name.

Why do all my tooltips attach to the same element?

Anchor names are global by default, so when many elements share one name, a positioned element uses the last matching anchor in the document. Give each anchor a unique name, for example through a custom property, or add anchor-scope on the repeated component so the name only matches inside it.

More from the Modern CSS lab