Position Popovers and Tooltips with CSS Anchor Positioning in Tailwind v4

Use CSS anchor positioning in Tailwind v4 to place popovers, tooltips and dropdowns with arbitrary properties, position-area and flip fallbacks. No JS library.

Steven Richardson
Steven Richardson
· 16 min read

Every dropdown I have shipped in the last five years carried the same tax: about 12 KB of Floating UI, a useFloating hook or an Alpine x-anchor directive, a resize listener, and a scroll listener — all to answer the question "where is that button?" The browser has known the answer the whole time. CSS Anchor Positioning is now Baseline newly available, and Tailwind v4 can reach every part of it without a plugin.

This guide builds one real thing: an account dropdown that tethers itself to its trigger, flips above the button near the bottom of the viewport, animates in from display: none, survives being rendered fifty times in a table, and degrades to something sensible in an old browser. Every class below was compiled against tailwindcss@4.3.3 before it was written down.

If you are still on Tailwind v3, none of the arbitrary-property behaviour here is reliable — v3.4.4 rewrote [anchor-name:--x] into anchor-name: var(--x), which is silently wrong. The Tailwind v3 to v4 Laravel upgrade walkthrough is the way out.

Name the trigger with anchor-name#

An anchor is any element carrying an anchor-name, and the value must be a dashed ident — --account-menu, not account-menu. Tailwind v4 has no first-party utility for this. I checked: a stock v4.3.3 design system exposes 23,286 utility class names and exactly zero of them touch anchor-name, position-anchor, position-area, position-try-* or position-visibility. The open feature request on the Tailwind repo has been sitting unanswered since June 2024.

So you use an arbitrary property, which is the escape hatch Tailwind provides for exactly this case:

<button
    type="button"
    class="[anchor-name:--account-menu] inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white"
>
    Account
</button>

That compiles to a single declaration, with the ident passed through literally:

.\[anchor-name\:--account-menu\] {
  anchor-name: --account-menu;
}

Resist the temptation to reach for v4's (--x) shorthand here. anchor-[(--x)]-style parenthesis syntax deliberately wraps the value in var(), and anchor-name: var(--x) is a different thing entirely — it resolves a custom property rather than declaring an ident. For anchor names you want the square brackets.

If your anchor names are generated in a Blade loop, do not try to build the class string dynamically; Tailwind cannot see classes it never scans. Either write an inline style="anchor-name: --row-{{ $row->id }}", or enumerate the names ahead of time — the guide to safelisting dynamic Blade classes with @source inline() covers the second approach properly.

Tether the panel with position-anchor#

The floating element points back at the anchor with position-anchor, and it must be absolutely or fixed positioned. This is the single most common reason a first attempt does nothing: anchor positioning is built on top of absolute positioning, so a static panel simply ignores every anchor property you set on it.

<div
    class="[position-anchor:--account-menu] absolute w-56 rounded-lg border border-slate-200 bg-white p-2 shadow-lg"
>
    <a href="/settings" class="block rounded px-3 py-2 text-sm hover:bg-slate-100">Settings</a>
    <a href="/billing" class="block rounded px-3 py-2 text-sm hover:bg-slate-100">Billing</a>
    <button type="button" class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-100">Sign out</button>
</div>

At this point the panel knows which element it is attached to, but not where to sit relative to it. It will still be laid out wherever its containing block puts it. Two properties can fix that: the anchor() function used inside inset properties, or position-area. Use anchor() when you need surgical control — [top:anchor(bottom)] and [width:anchor-size(width)] both compile fine and are how you build a select menu that exactly matches its trigger's width. For a dropdown, position-area is less code.

Place the panel with position-area#

position-area treats the anchor as the centre cell of a 3×3 grid and lets you name the cell the panel should occupy. The value takes two keywords separated by a space, and this is where Tailwind's whitespace rule bites: an underscore in an arbitrary value becomes a space at build time.

<div
    class="[position-anchor:--account-menu] [position-area:block-end_span-inline-end] absolute mt-2 w-56 rounded-lg border border-slate-200 bg-white p-2 shadow-lg"
>

Compiled:

.\[position-area\:block-end_span-inline-end\] {
  position-area: block-end span-inline-end;
}

block-end puts the panel below the anchor. span-inline-end says "start at the anchor's inline-start edge and grow towards the inline end", which is the left-aligned dropdown everybody expects in a left-to-right language. Swap in span-inline-start for a right-aligned menu, or center for a centred tooltip. Because these are logical keywords they flip automatically in RTL, which the physical top/left equivalents will not do for you.

One caveat worth knowing before you lean on the single-keyword forms: bare position-area: top and position-area: block-end only became interoperable across Chrome 144, Firefox 148 and Safari 26 on 24 February 2026. The two-keyword forms shipped much earlier. If you support anything older, prefer the explicit pair.

Keep the panel on screen with position-try-fallbacks#

A dropdown near the bottom of the window should open upwards. This is the entire reason positioning libraries exist, and it is now one property. MDN's guide to fallback options and conditional hiding for overflow is the reference to keep open. The flip-block keyword mirrors the panel across the anchor's inline axis — same distance, opposite side — and the browser only applies it when the default position would overflow.

<div
    class="[position-anchor:--account-menu] [position-area:block-end_span-inline-end] [position-try-fallbacks:flip-block] absolute mt-2 w-56 ..."
>

Comma-separated lists work too, and the browser walks them in order until one fits: [position-try-fallbacks:flip-block,flip-inline]. When the built-in keywords are not enough — usually because your margins need to change along with the position — declare a named fallback with the @position-try at-rule. At-rules cannot live in a class, so this goes in your stylesheet:

/* resources/css/app.css */
@import "tailwindcss";

@position-try --account-menu-above {
    position-area: block-start span-inline-end;
    margin: 0 0 0.5rem 0;
}

Then reference it by name, custom options first:

class="... [position-try-fallbacks:--account-menu-above,flip-block]"

Note the sibling property while you are here. position-try-order is a different tool: rather than reacting to overflow, it picks the fallback with the most available width or height before first paint, via values like most-height. It is useful for a menu that should open into whichever direction has more room. It is also the least widely supported piece of the API — Firefox only shipped it in 148, on 24 February 2026.

Move the panel to the top layer with the Popover API#

A dropdown that lives inside a scrolling table or a overflow: hidden card gets clipped. The fix is the top layer, and the native Popover API gets you there with two HTML attributes and no JavaScript: popovertarget on the trigger, popover plus a matching id on the panel. The browser handles open, close, light-dismiss on outside click, Escape, and focus management.

<button
    type="button"
    popovertarget="account-menu"
    class="[anchor-name:--account-menu] inline-flex items-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white"
>
    Account
</button>

<div
    id="account-menu"
    popover
    class="[position-anchor:--account-menu] [position-area:block-end_span-inline-end] [position-try-fallbacks:flip-block] mt-2 w-56 rounded-lg border border-slate-200 bg-white p-2 shadow-lg"
>
    <a href="/settings" class="block rounded px-3 py-2 text-sm hover:bg-slate-100">Settings</a>
    <a href="/billing" class="block rounded px-3 py-2 text-sm hover:bg-slate-100">Billing</a>
    <button type="button" class="block w-full rounded px-3 py-2 text-left text-sm hover:bg-slate-100">Sign out</button>
</div>

The absolute class is gone because the UA stylesheet already gives [popover] a position: fixed, and fixed positioning satisfies the anchor requirement. This pairing is the payoff of the whole API: the panel is in the top layer, immune to ancestor clipping and z-index wars, and still glued to a button that is not.

Chrome 133+, Safari 26+ and Firefox 147+ also treat the invoker as an implicit anchor, which means position-anchor: auto can skip the naming step entirely. I would still name the anchor explicitly. Browser-compat data for the auto keyword is patchy, and an explicit name is the thing you can grep for six months later.

Reset the popover user-agent styles#

Run the code above and the menu appears dead centre in the viewport, ignoring position-area completely. This trips up nearly everyone, and it is not a bug in your CSS. The UA stylesheet ships [popover] { position: fixed; inset: 0; margin: auto; } — that inset: 0 plus margin: auto is the classic centring trick, and it overrides the anchor placement.

Clear both. Once per project, in your base layer, is better than remembering it on every popover:

/* resources/css/app.css */
@layer base {
    [popover] {
        inset: auto;
        margin: 0;
    }
}

If you would rather keep it inline, inset-auto m-0 on the panel does the same job. The CSS Working Group has an open issue proposing that anchored popovers reset this automatically, so this reset may become unnecessary — but it is required today in every shipping browser.

Scope repeated anchors with anchor-scope#

Now render that dropdown once per row in a table. Every trigger carries anchor-name: --account-menu, and anchor names are global: when several elements share one, the last match in the DOM wins. Every menu in the table tethers itself to the final row's button. The symptom is bizarre enough that people usually blame the popover.

anchor-scope fixes it without generating unique names. Put it on each row wrapper and the anchor names declared inside that subtree stop leaking out of it:

@foreach ($users as $user)
    <tr class="[anchor-scope:all]">
        <td>{{ $user->name }}</td>
        <td class="relative text-right">
            <button type="button" popovertarget="row-menu-{{ $user->id }}"
                    class="[anchor-name:--row-menu] rounded p-2 hover:bg-slate-100"></button>

            <div id="row-menu-{{ $user->id }}" popover
                 class="[position-anchor:--row-menu] [position-area:block-end_span-inline-start] [position-try-fallbacks:flip-block] w-44 rounded-lg border border-slate-200 bg-white p-2 shadow-lg">
                <a href="{{ route('users.edit', $user) }}" class="block rounded px-3 py-2 text-sm hover:bg-slate-100">Edit</a>
            </div>
        </td>
    </tr>
@endforeach

One class, one shared anchor name, fifty rows that each behave correctly. anchor-scope landed in Chrome 131, Firefox 147 and Safari 26.

Animate the panel with starting: and transition-discrete#

A popover toggles display, and CSS historically refused to transition into display: block because there was no previous frame to interpolate from. Tailwind v4 covers both halves of the modern fix, and — usefully — its open: variant already targets :popover-open. The compiled selector is :is([open], :popover-open, :open), so one variant handles popovers, <details> and <dialog>.

<div
    id="account-menu"
    popover
    class="[position-anchor:--account-menu] [position-area:block-end_span-inline-end] [position-try-fallbacks:flip-block]
           w-56 rounded-lg border border-slate-200 bg-white p-2 shadow-lg
           opacity-0 transition-[opacity,display,overlay] transition-discrete duration-150 ease-out
           open:opacity-100 starting:open:opacity-0"
>

transition-discrete sets transition-behavior: allow-discrete so display and overlay join the timeline instead of snapping. starting:open:opacity-0 emits a @starting-style block scoped to the open state, giving the browser its missing first frame. Including overlay in the transition property list is what keeps the element in the top layer for the duration of the exit animation — leave it out and the panel drops out of the top layer instantly on close, which looks like a flicker. The mechanics of both primitives are covered in depth in the guide to starting: and transition-discrete.

Avoid animating position-area itself. It is not an interpolatable property yet, so a flip is always instant. Animate opacity and transforms and let the position snap.

Add a @supports fallback with the supports-* variant#

Anchor positioning has been Baseline newly available since 13 January 2026, which means recent versions of every major engine, not every browser your analytics still shows. Tailwind's supports-* and not-supports-* variants wrap a feature query around any utility, and they accept a bare property name:

<div
    id="account-menu"
    popover
    class="... not-supports-[anchor-name]:mx-auto not-supports-[anchor-name]:my-8"
>

That compiles to a real feature query:

@supports not (anchor-name: var(--tw)) {
  .not-supports-\[anchor-name\]\:mx-auto { margin-inline: auto; }
  .not-supports-\[anchor-name\]\:my-8   { margin-block: calc(var(--spacing) * 8); }
}

For a top-layer popover this is the fallback I actually recommend, and it is deliberately not a reimplementation of anchoring. Without anchor support the browser falls back to the UA centring you spent the last section removing, so you get a centred sheet: not where the designer drew it, but usable, dismissible and never off-screen. Trying to fake tethering with top-full left-0 in the fallback branch does not work for popovers anyway, because the top layer has no relationship to the trigger's containing block.

If your panel is not a popover — an inline tooltip inside a relative wrapper, say — then the classic approach does apply: not-supports-[anchor-name]:absolute not-supports-[anchor-name]:top-full not-supports-[anchor-name]:mt-2. Both branches are also a neat demonstration of variant negation, which the write-up on Tailwind v4's not-* variant explores beyond feature queries.

OddBird's css-anchor-positioning polyfill is the other option, but read its limitations first. It polyfills position-area by wrapping the target in an extra element, which breaks sibling and :nth-child selectors — and therefore Tailwind's peer-*, group-*, space-* and divide-* classes. It also skips position-visibility entirely. On a Tailwind codebase I would take the degraded layout over the polyfill.

Debug an anchor that refuses to attach#

When the panel ignores its anchor and there is no error anywhere, work down this list. It is ordered by how often each one is the culprit in my experience.

The panel is not absolutely positioned. Anchor properties are inert on a static element. Add absolute or fixed.

A UA style is winning. For popovers, see the inset: auto; margin: 0 reset above.

The anchor is the panel's containing block. If the anchor is an ancestor of the panel and has position: relative, a transform, a filter, or container-type, it becomes the containing block and the link is invalid. Note the trap for anyone using container queries for component-level responsive design: a @container wrapper around the trigger is enough to break anchoring. Switching the panel to fixed rescues the position-relative case but not the transform case. The reliable structure is the one OddBird recommends: make the anchor and the panel siblings, and put the anchor first in the DOM.

Source order. The anchor's box must be laid out before the panel's. If they share a containing block and the anchor comes after the panel in the DOM while being absolute or fixed, the reference is invalid. relative and sticky anchors are fine at any position, and no amount of z-index will fix a genuine ordering problem.

Two elements share an anchor name. See anchor-scope above.

You copied a tutorial from 2024. inset-area was renamed to position-area in Chrome 129 and the alias was dropped in Chrome 131; position-try-options became position-try-fallbacks; and the inset-area() wrapper function inside fallback lists no longer exists — write bare values. A surprising amount of published sample code is still using the dead names.

overflow: hidden is worth explicitly clearing of blame. It does not create a containing block for absolutely positioned elements and it is not the reason your anchor broke — the real culprits are positioning, transforms, filters and containment.

Choose between arbitrary properties and the Anchors plugin#

Arbitrary properties get verbose. [position-anchor:--account-menu] [position-area:block-end_span-inline-end] [position-try-fallbacks:flip-block] is a lot of characters to repeat across a component library. There are three ways out, and it is worth being clear-eyed about each.

Extract a Blade or Livewire component. Boring, free, and it is what I do. The verbosity lives in one file.

Write your own utility. You might expect @utility anchor-* { anchor-name: --value([*]); } to give you anchor-[--menu], and it does — I compiled it. What you cannot build is the bare-ident form anchor-menu, because --value(*) rejects idents outright ("Only valid data types are: number, integer, ratio, percentage"), and the --{*} interpolation syntax that circulates in blog posts throws an "Invalid declaration" error on every v4 release I tested. That limitation is precisely why a JS plugin exists. If you want the utility anyway, the guide to defining custom utilities with @utility covers the value-typing rules.

Install @toolwind/anchors. It is the best-known option, gives you anchor/menu, anchored-top-center/menu, try-flip-y and friends, applies position: absolute at zero specificity via :where(), and adds view-transition-name for free. Before you adopt it, read its open issues: it re-enables Preflight in projects that disabled it (#2), injects an unrequested 0.25rem offset (#3), and generates non-deterministic CSS variable names that cause hydration mismatches under SSR (#4). A fourth issue (#7, unanswered since May 2026) questions its postinstall script and a dependency pulled from a gitpkg URL. None of that makes it unusable, but it is a real dependency decision rather than a free ergonomic win.

If you do write raw @position-try blocks inside a component's scoped CSS, remember Blade-scoped stylesheets need the @reference directive to see your theme — that gotcha is covered in the @reference write-up.

Verify the build and ship it#

Run npm run build and grep the output for anchor-name. If the declaration is not in your CSS, Tailwind never saw the class — usually a dynamic string your @source globs do not cover. Then walk the panel through four states in the browser: default placement, the flipped placement (scroll the trigger near the bottom edge), the open/close transition, and the no-support branch (toggle it by temporarily renaming the property in DevTools).

Check the accessibility layer while you are in there, because anchor positioning creates no semantic relationship at all. Popovers and dialogs get focus management from the browser, but a tooltip needs aria-describedby on the trigger and role="tooltip" on the panel. Visual proximity is not an accessible name.

Finally, be honest about your support floor. The core group — anchor-name, position-anchor, anchor(), @position-try, the flip keywords — is Chrome 125+, Safari 26+, Firefox 147+, Baseline since 13 January 2026. position-area needs Chrome 129, anchor-scope needs Chrome 131, and position-try-order plus the single-keyword position-area values only became interoperable on 24 February 2026. Several widely shared articles quote "Firefox 132, Safari 18.2" for this feature; those numbers are wrong, and shipping against them will produce bug reports you cannot reproduce.

From here, the same top-layer and transition machinery underpins a lot of other JS-free UI. The snap-scrolling carousel build is the natural next one, and if you want the trigger itself to react to whether the panel is open, styling parents from their children with has-* closes that loop.

FAQ#

What is CSS anchor positioning?

CSS anchor positioning is a browser API that tethers one element's position to another element's position, without JavaScript. You mark the reference element with anchor-name: --something, point the floating element at it with position-anchor: --something, and then place it with either the anchor() function inside inset properties or the position-area property. The browser keeps the two in sync through scrolling, resizing and layout changes.

Does Tailwind v4 support anchor positioning natively?

Not with dedicated utilities. A stock Tailwind v4.3.3 install exposes 23,286 utility classes and none of them map to anchor-name, position-anchor, position-area, position-try-fallbacks or position-visibility, and the feature request opened in June 2024 has had no maintainer response. What Tailwind does support is arbitrary properties, so [anchor-name:--trigger] and [position-area:block-end_center] both compile correctly and pick up every variant. In practice that is full support with a slightly noisier syntax.

How do I keep a popover from overflowing the viewport?

Add position-try-fallbacks. The flip-block keyword tells the browser to mirror the panel to the opposite side of the anchor when the default placement would overflow, and in Tailwind that is [position-try-fallbacks:flip-block]. You can list several options — flip-block, flip-inline — and the browser tries each in order until one fits. For fallbacks that also need different margins or widths, define them with the @position-try at-rule in your stylesheet and reference them by name.

Can I replace Floating UI or Popper with CSS anchor positioning?

For the common cases, yes, and you should. Dropdowns, tooltips, menus and popovers anchored to a trigger are entirely expressible in CSS now, and dropping the library removes both the bundle weight and the scroll and resize listeners. Where Floating UI still earns its place is the long tail: arrow elements that track the anchor, virtual anchors following a cursor, collision detection against arbitrary boundary elements rather than the viewport, and any browser floor below Safari 26. Most design systems will find that a small minority of their overlays need it.

Which browsers support CSS anchor positioning in 2026?

The core of the API — anchor-name, position-anchor, anchor(), anchor-size(), @position-try and the flip keywords — is supported in Chrome and Edge 125+, Safari 26+ and Firefox 147+, and it reached Baseline newly available on 13 January 2026 when Firefox 147 shipped. Individual pieces landed later: position-area in Chrome 129, anchor-scope in Chrome 131, and position-try-order plus single-keyword position-area values only became interoperable across all three engines on 24 February 2026. Baseline widely available is roughly two and a half years out, so a @supports fallback is still the right call.

How do I anchor a tooltip to a button in Tailwind?

Put [anchor-name:--tip-trigger] on the button and [position-anchor:--tip-trigger] [position-area:block-start_center] absolute on the tooltip, then add [position-try-fallbacks:flip-block] so it drops below the button when there is no room above. If the tooltip should match or cap its width against the button, [width:anchor-size(width)] and [max-width:anchor-size(width)] both work. Add aria-describedby on the button and role="tooltip" on the panel, because the positioning relationship carries no semantics for assistive technology.

Why is my anchor positioning not working?

Nine times out of ten it is one of four things: the floating element is not absolute or fixed; a popover's user-agent inset: 0; margin: auto is still centring it; the anchor is an ancestor that creates a containing block through position: relative, a transform, a filter or container-type; or two elements share the same anchor-name so the last one in the DOM wins. Make the anchor and the panel siblings with the anchor first in the DOM and most of these disappear at once.

Does CSS anchor positioning work with the popover attribute?

It works better with it than without it. A native popover lives in the top layer, so it cannot be clipped by an ancestor's overflow or lose a z-index fight, and anchor positioning is the mechanism that tethers it back to a trigger in the normal document flow. You do need to clear the UA inset: 0; margin: auto first. Chrome 133+, Safari 26+ and Firefox 147+ additionally treat the popovertarget invoker as an implicit anchor, though naming the anchor explicitly is still more predictable.

What is the difference between position-try-fallbacks and position-try-order?

position-try-fallbacks is reactive: it lists alternative placements the browser will try, in order, only once the element would overflow its containing block or the viewport. position-try-order is proactive: it inspects the available fallback options before first paint and picks the one that yields the most width or height, via values like most-width and most-height. They compose — you supply the candidate positions with position-try-fallbacks and let position-try-order choose the initial one. Support differs, though: position-try-order only reached all three engines on 24 February 2026.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.