Anchor Positioning Fallbacks: A Graceful Floor for Older Browser Versions

CSS anchor positioning is in Chrome and Edge from version 125, Safari from version 26, and Firefox from version 147. Every current engine ships it, so the old question — "can I use this at all?" — is settled. The question that remains is the ordinary one you ask of any recent CSS feature: what does the page do in the browser versions that are still out there and predate the ship date? The narrow problem this page solves is how to answer that with one component, one set of markup, one stylesheet, in which the placement gracefully degrades to something that has worked since 2005 without you maintaining two parallel implementations. It belongs to the CSS anchor positioning and overlays section, which covers what the properties do; this page is only about how to deploy them.

Why a layered override beats a parallel component

There are three ways to handle a recently shipped layout feature, and two of them go badly.

The first bad option is a runtime check that swaps components — detect support in JavaScript, render the anchored version or a library-positioned version. This produces two components that drift, doubles your visual test surface, and moves a styling decision into the render path where a hydration mismatch can flash the wrong one.

The second bad option is writing everything twice in CSS: a full @supports not (anchor-name: --x) block containing a complete copy of the component, and a full positive block containing another. Every change to padding, colour, or typography now has to be made in two places, and the negated query has a subtle extra failure mode — an engine so old it does not implement @supports at all ignores both blocks and gets nothing.

The option that works is neither. Write the component once, unguarded, using a placement that every browser has understood for two decades. Then add a single @supports block that overrides only the declarations that describe placement — typically five or six lines. Nothing about the component's appearance is duplicated, and an engine without support never sees the block at all.

Baseline placement with a narrow enhancement on top The unguarded baseline supplies appearance and an absolute placement; the supports block replaces only the placement declarations. One component, one narrow override baseline — every browser, no guard position: absolute; inset-block-end: 100% plus all colour, spacing and type — written once enhancement — Chrome 125+, Safari 26+, Firefox 147+ position: fixed; position-anchor: --hint placement declarations only — nothing else repeated an engine without support never parses the second block

Complete working implementation

The frame below renders the anchored placement in any current browser and the baseline in anything older, from the single stylesheet that follows it.

Live demoAbsolute baseline enhanced by anchor positioning
Every browser gets the absolute placement; browsers that pass the @supports test get the anchored one.
<div class="hint">
  <button type="button" class="hint__trigger" aria-describedby="hint-1">What is this?</button>
  <span role="tooltip" id="hint-1" class="hint__panel">Same panel, two placement engines.</span>
</div>
/* ---- Baseline: parsed and applied by every browser ---- */

/* The wrapper is a containing block ONLY for the fallback placement. */
.hint { position: relative; display: inline-block; }

.hint__trigger {
  font: inherit;
  padding: 0.45rem 0.8rem;
  border: 1px solid #d3d8e0;
  border-radius: 0.45rem;
  background: #fff;
}

.hint__panel {
  position: absolute;
  inset-block-end: calc(100% + 0.5rem);  /* above the trigger, with a gap */
  inset-inline-start: 50%;
  translate: -50% 0;                      /* the classic centring trick */
  width: max-content;
  max-width: 16rem;
  padding: 0.45rem 0.7rem;
  border-radius: 0.45rem;
  background: #1e2430;
  color: #fff;
  font-size: 0.8rem;
  opacity: 0;
  transition: opacity 0.15s ease;
}

/* ---- Enhancement: only placement is restated ---- */

@supports (anchor-name: --x) {
  /* The wrapper no longer needs to be a containing block; the anchor is
     the reference frame now, and static positioning frees the panel from
     any ancestor clipping. */
  .hint { position: static; }

  .hint__trigger { anchor-name: --hint; }

  .hint__panel {
    position: fixed;
    position-anchor: --hint;
    position-area: block-start center;
    position-try-fallbacks: flip-block;

    /* Undo the baseline's manual placement so it cannot interfere. */
    inset: auto;
    translate: none;
    margin-block-end: 0.5rem;
  }
}

/* State handling is shared by both branches. */
.hint__trigger:hover + .hint__panel,
.hint__trigger:focus-visible + .hint__panel { opacity: 1; }

The block inside @supports is nine declarations. Everything else — colour, radius, typography, the reveal transition, the hover and focus selectors — exists once and is unaffected by which branch runs.

The technique: undoing the baseline is the part people forget

Two lines in that enhancement block do more work than they look like they do, and omitting either produces a bug that is hard to attribute.

inset: auto clears the baseline's inset-block-end and inset-inline-start. Those declarations do not stop applying just because the element is now anchored — position-area and an explicit inset value both feed into the same placement computation, and a leftover inset-block-end: calc(100% + 0.5rem) will shrink the inset-modified containing block that position-try-fallbacks measures against. The symptom is the one described as a mystery in most bug reports: a tooltip that flips even though there is obviously room. Reset the insets explicitly.

translate: none clears the centring transform. position-area: block-start center already centres the panel over its anchor, so leaving the baseline's translate: -50% 0 in place shifts it half its own width to the left. This one is at least visible immediately, which is small mercy.

The general rule: any property the baseline uses to place the element must be neutralised in the enhancement, because @supports blocks add declarations, they do not replace a rule wholesale. It is worth keeping the baseline's placement declarations grouped together in the source purely so the list of things to undo is obvious at a glance.

.hint { position: static; } is the third piece. The baseline needs position: relative on the wrapper to serve as a containing block; the enhancement does not, and dropping it back to static is what lets the anchored panel escape any ancestor with overflow: hidden. This is the one respect in which the two branches are not merely cosmetically different — the fallback placement can be clipped by an overflowing ancestor and the anchored one cannot. Design the surrounding layout so that the fallback is not clipped, rather than assuming the enhancement will rescue it.

Choosing the detection test

@supports evaluates a property/value pair, and which pair you pick matters more here than usual.

/* Good: a value only a supporting parser accepts. */
@supports (anchor-name: --x) { }

/* Also fine, and a stricter test of the placement machinery. */
@supports (position-area: block-start) { }

/* Avoid: this property was renamed late; a positive result is not
   proof that the current syntax is understood. */
@supports (inset-area: top) { }

anchor-name: --x is the recommended test because the property is the entry point to the whole feature — an engine that parses it has the rest — and because a dashed ident is a value no unsupporting engine will accept by accident. Avoid testing inset-area, the property's pre-standardisation name, since a browser could in principle support the old spelling and not the new one.

Combine conditions with and when your enhancement genuinely needs two features, but do not over-constrain. If your component uses anchoring plus @starting-style, testing both means a browser with anchoring and no @starting-style falls all the way back to the static placement when it could have had the good placement without the animation. Separate concerns into separate blocks. The syntax for compound and negated queries is covered in feature detection with @supports, and the same layering discipline applied to a different feature is worked through in handling container query fallbacks for older browsers.

Variation: keeping the branches apart with cascade layers

On a component small enough to read in one screen, the baseline and the @supports block can sit next to each other. On a design system with dozens of tethered surfaces, it helps to separate them by cascade layer so that the enhancement can never be accidentally outranked by a more specific baseline selector written later.

@layer placement.base, placement.anchored;

@layer placement.base {
  .hint__panel {
    position: absolute;
    inset-block-end: calc(100% + 0.5rem);
    inset-inline-start: 50%;
    translate: -50% 0;
  }
}

@layer placement.anchored {
  @supports (anchor-name: --x) {
    .hint__panel {
      position: fixed;
      position-anchor: --hint;
      position-area: block-start center;
      inset: auto;
      translate: none;
      margin-block-end: 0.5rem;
    }
  }
}

Because later layers win regardless of selector specificity, the anchored rules apply even if someone later writes .card .hint__panel { inset-block-end: 2rem } in the base layer. That is a meaningful safety property once more than one person maintains the stylesheet; cascade layers for reset and tokens covers how to order layers across a whole system.

To review the fallback without switching browsers, break the test on purpose:

/* Temporarily forces every engine down the baseline path. */
@supports (anchor-name: --x) and (nonsense-property: 1) { }

Browser support

Anchor positioning is available in Chrome 125+, Edge 125+, Safari 26+, and Firefox 147+; position-try-fallbacks arrived in Chrome and Edge 128, and position-area in Chrome and Edge 129, so a Chromium build between 125 and 128 parses anchor-name while ignoring parts of the placement layer. That is the practical reason to keep the baseline in place even though the feature is interoperable at head: your support floor, not your support ceiling, decides whether the fallback runs.

@supports itself is universal in every browser you would target, and cascade layers are supported in Chrome 99+, Edge 99+, Firefox 97+, and Safari 15.4+, so the layering strategy above is safe everywhere. The baseline placement uses position: absolute with logical inset properties, which have been interoperable for years — comfortably older than anything still in circulation.

FAQ

What is the right @supports test for anchor positioning? Test a property and value pair that only a supporting engine parses, such as @supports (anchor-name: --x). Testing the property name alone is not enough, and testing position-try-fallbacks is riskier because its name changed late in development.

Should I use @supports not to write the fallback? Prefer writing the baseline unguarded and putting only the enhancement inside @supports. A negated query means engines that do not understand @supports at all skip your fallback too, and it doubles the number of rules that must be kept in sync.

How do I review the fallback when every current browser supports the feature? Temporarily change the test to something no engine supports, such as @supports (anchor-name: --x) and (nonsense: 1). Every browser then fails the query and renders the baseline, which lets you review it in the browser you already have open without hunting down an old build.

Is a JavaScript positioning library still worth loading as a fallback? Rarely, now that every current engine positions natively. Consider it only when the placement is genuinely load-bearing, such as a menu that would cover the control it belongs to, and your support floor still includes pre-2026 browser versions. For hints and dropdowns a static CSS placement is good enough.

Related articles

More pages in the same section.