Generated by AI
Purpose: This document is a comprehensive reference for developers and AI assistants implementing CSS anchor-positioned popovers with arrows that automatically flip direction when using
position-try-fallbacks. It covers the core technique, theposition-areaproperty (including the critical span keywords), and complete working examples.
- The Problem
- The Solution: clip-path margin-box
- Implementation
- Position-Area Reference
- Complete Example
- Troubleshooting
- Browser Support
- References
CSS Anchor Positioning provides @position-try rules to reposition elements when they overflow the viewport. However, @position-try can only modify properties on the positioned element itself—not pseudo-elements.
This creates a challenge: how do you flip an arrow (typically created with ::before or ::after) when the popover flips position?
Per the CSS Anchor Positioning specification, @position-try can only modify these properties:
✅ ALLOWED ❌ NOT ALLOWED
───────────────────────────────── ─────────────────────────────────
inset, top, right, bottom, left transform, rotate
margin, margin-* background, border
width, height, min-*, max-* clip-path
position-anchor, position-area display, visibility, opacity
align-self, justify-self Any pseudo-element properties
This is why traditional arrow approaches (rotating a pseudo-element, changing border colors, toggling visibility) don't work with @position-try.
Sources:
- MDN: @position-try - Full list of allowed properties
- CSSWG Spec: Position Fallback - Official specification
This technique was pioneered by Wes Goulet and detailed in his blog post.
The trick is to:
- Create arrows pointing in all four directions simultaneously using
::beforeand::afterpseudo-elements - Use
clip-path: inset() margin-boxto clip the popover based on its margin-box (not border-box) - Control which arrow is visible by changing margins (which
@position-tryCAN modify)
The key insight: arrows that extend into the margin area "escape" the clip and become visible.
⚠️ Limitation: This technique requiresbox-shadow: noneon the popover. Box-shadow interferes with the margin-box clipping.
NORMAL clip-path: inset(1px) clip-path: inset(1px) margin-box
(clips to border-box) (clips to margin-box)
┌─────────────────────┐
│ CLIP BOUNDARY │ margin-top: 8px creates space
│ ┌───────────────┐ │ ↓
│ │ │ │ ┌─────────────────────┐
│ │ POPOVER │ │ │ │
│ │ │ │ │ ┌───────────────┐ │
│ └───────────────┘ │ │ │ POPOVER │ │
│ │ │ └───────────────┘ │
└─────────────────────┘ └─────────────────────┘
Arrows outside border-box Arrow in top margin is VISIBLE
are clipped away Other arrows still clipped
The key insight: arrows extending into the margin area escape the clip.
Popover ABOVE anchor Popover BELOW anchor
margin: 0 0 8px 0 (bottom) margin: 8px 0 0 0 (top)
┌───────────┐ ▲
│ POPOVER │ ┌────────┴────────┐
└─────┬─────┘ │ POPOVER │
▼ └─────────────────┘
[ANCHOR] [ANCHOR]
Bottom arrow visible Top arrow visible
Popover LEFT of anchor Popover RIGHT of anchor
margin: 0 8px 0 0 (right) margin: 0 0 0 8px (left)
┌─────────┐► ◄┌─────────┐
│ POPOVER │ │ POPOVER │
└─────────┘ └─────────┘
[ANCHOR] [ANCHOR]
Right arrow visible Left arrow visible
Each trigger button needs a unique anchor name:
#btn-example { anchor-name: --btn-example; }Critical: Start with all: unset to remove browser popover defaults.
[popover] {
/* RESET - browsers add default styles that interfere */
all: unset;
/* CSS Variables for arrow sizing */
--tether-offset: 1px;
--tether-size: 8px;
/* Anchor positioning */
position: absolute;
inset: auto; /* Reset default inset: 0 */
position-anchor: --btn-example;
position-area: top center; /* Default: appear centered above anchor */
/* Leave room for arrow via margin */
margin: 0 0 var(--tether-size) 0;
/* THE KEY: clip based on margin-box */
clip-path: inset(var(--tether-offset)) margin-box;
/* ⚠️ IMPORTANT: box-shadow MUST be disabled */
box-shadow: none;
/* Visual styling */
display: block;
background: #16213e;
border-radius: 8px;
padding: 1rem;
}
⚠️ Critical:box-shadowmust be disabledWhen using
clip-path: inset() margin-box, you must setbox-shadow: none. Box-shadow renders outside the clip boundary and causes visible artifacts in the gap between the popover and trigger. This is especially problematic on the top layer where popovers render.
Use ::before for top/bottom arrows and ::after for left/right arrows.
Arrow shapes are hexagons with a point on each relevant side:
::before (top/bottom arrows) ::after (left/right arrows)
50% 0 0 50%
* *
/|\ /|
/ | \ / |
/ | \ / |
0 0 * | * 100% 0 0 0 * |
| | | | * 100% 50%
| | | | |
| | | | |
0 100%* | * 100% 100% 0 100% * |
\ | / \ |
\ | / \ |
\|/ \|
* *
50% 100% 100% 50%
/* Top and Bottom arrows (vertical hexagon) */
[popover]::before {
content: "";
position: absolute;
z-index: -1; /* Behind content */
background: inherit;
left: 50%;
transform: translateX(-50%);
/* Hexagon shape with points at top and bottom */
width: calc(var(--tether-size) * 2);
height: calc(100% + var(--tether-size) * 2);
top: calc(var(--tether-size) * -1);
clip-path: polygon(
0 var(--tether-size), /* top-left */
50% 0, /* top point */
100% var(--tether-size), /* top-right */
100% calc(100% - var(--tether-size)), /* bottom-right */
50% 100%, /* bottom point */
0 calc(100% - var(--tether-size)) /* bottom-left */
);
}
/* Left and Right arrows (horizontal hexagon) */
[popover]::after {
content: "";
position: absolute;
z-index: -1; /* Behind content */
background: inherit;
top: 50%;
transform: translateY(-50%);
/* Hexagon shape with points at left and right */
height: calc(var(--tether-size) * 2);
width: calc(100% + var(--tether-size) * 2);
left: calc(var(--tether-size) * -1);
clip-path: polygon(
var(--tether-size) 0, /* top-left */
calc(100% - var(--tether-size)) 0, /* top-right */
100% 50%, /* right point */
calc(100% - var(--tether-size)) 100%, /* bottom-right */
var(--tether-size) 100%, /* bottom-left */
0 50% /* left point */
);
}Each fallback changes position-area AND margin to show the correct arrow:
@position-try --top {
position-area: top center;
margin: 0 0 var(--tether-size) 0; /* Bottom margin → bottom arrow */
}
@position-try --bottom {
position-area: bottom center;
margin: var(--tether-size) 0 0 0; /* Top margin → top arrow */
}
@position-try --left {
position-area: left center;
margin: 0 var(--tether-size) 0 0; /* Right margin → right arrow */
}
@position-try --right {
position-area: right center;
margin: 0 0 0 var(--tether-size); /* Left margin → left arrow */
}#popover-example {
position-anchor: --btn-example;
position-area: top center;
position-try-fallbacks: --bottom, --left, --right;
}The position-area property places an anchor-positioned element on an implicit 3x3 grid centered on the anchor.
Sources:
- MDN: position-area - Comprehensive property reference
- CSSWG Spec: position-area - Official specification
- CSS-Tricks: position-area - Practical examples
left center right
┌──────────┬──────────┬──────────┐
top │ top left │ top │ top right│ ← ABOVE anchor
├──────────┼──────────┼──────────┤
center │ left │ [ANCHOR] │ right │ ← BESIDE anchor
├──────────┼──────────┼──────────┤
bottom │btm left │ bottom │btm right │ ← BELOW anchor
└──────────┴──────────┴──────────┘
Per MDN:
"The dimensions of the center tile are defined by the containing block of the anchor element, while the dimensions of the grid's outer edge are defined by the positioned element's containing block."
Common mistake: A single value does NOT center the popover — it spans the ENTIRE row or column.
From MDN:
position-area: right; /* equivalent to: right span-all (ALL 3 cells in right column) */
position-area: top; /* equivalent to: top span-all (ALL 3 cells in top row) */position-area: right (= right span-all)
left center right
┌──────────┬──────────┬──────────┐
top │ │ │██████████│
├──────────┼──────────┼──────────┤ ← Popover's containing block
center │ │ [ANCHOR] │██████████│ spans ALL 3 cells!
├──────────┼──────────┼──────────┤
bottom │ │ │██████████│
└──────────┴──────────┴──────────┘
Result: Popover position within span is unpredictable — NOT centered!
Two values target a specific cell. Use this for centered placements:
position-area: right center; /* center-right cell ONLY — popover is centered */
position-area: top center; /* top-center cell ONLY — popover is centered */
position-area: top left; /* top-left cell only */position-area: right center
left center right
┌──────────┬──────────┬──────────┐
top │ │ │ │
├──────────┼──────────┼──────────┤
center │ │ [ANCHOR] │██████████│ ← Single cell, popover centered ✓
├──────────┼──────────┼──────────┤
bottom │ │ │ │
└──────────┴──────────┴──────────┘
This is crucial for implementing Floating UI-style placements like right-start.
From MDN:
"The first value specifies the row or column to place the positioned element in, placing it initially in the center, and the other specifies the tiles to span."
position-area: right span-bottom
Step 1: "right" = right column, initially placed at CENTER (beside anchor)
Step 2: "span-bottom" = span from center toward bottom
left center right
┌──────────┬──────────┬──────────┐
top │ │ │ │
├──────────┼──────────┼──────────┤
center │ │ [ANCHOR] │██████████│ ← TOP of span aligns with anchor's top
├──────────┼──────────┼──────────┤
bottom │ │ │██████████│
└──────────┴──────────┴──────────┘
Result: Popover is to the RIGHT of anchor, TOP edges aligned ✓
position-area: right (= right span-all = entire column)
left center right
┌──────────┬──────────┬──────────┐
top │ │ │██████████│ ← align-self:start places popover HERE
├──────────┼──────────┼──────────┤
center │ │ [ANCHOR] │██████████│
├──────────┼──────────┼──────────┤
bottom │ │ │██████████│
└──────────┴──────────┴──────────┘
Result: Popover ends up ABOVE the anchor, not beside it ✗
This table maps common placement names (Floating UI / Popper.js convention) to position-area values:
| Placement | position-area | Explanation |
|---|---|---|
top |
top center |
Centered above anchor |
top-start |
top span-right |
Above anchor, left edges aligned |
top-end |
top span-left |
Above anchor, right edges aligned |
bottom |
bottom center |
Centered below anchor |
bottom-start |
bottom span-right |
Below anchor, left edges aligned |
bottom-end |
bottom span-left |
Below anchor, right edges aligned |
left |
left center |
Centered to left of anchor |
left-start |
left span-bottom |
Left of anchor, top edges aligned |
left-end |
left span-top |
Left of anchor, bottom edges aligned |
right |
right center |
Centered to right of anchor |
right-start |
right span-bottom |
Right of anchor, top edges aligned |
right-end |
right span-top |
Right of anchor, bottom edges aligned |
Key insights:
- For centered placements, use explicit two-value syntax (e.g.,
right center) — a single value likerightequalsright span-allwhich may not center correctly - For
-startplacements, span AWAY from the aligned edge - For
-endplacements, span TOWARD the aligned edge
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anchor Positioning Arrow Demo</title>
<style>
* { box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
min-height: 100vh;
display: grid;
place-items: center;
background: #1a1a2e;
color: #eee;
}
/* Anchor name */
#trigger { anchor-name: --trigger; }
/* Popover with auto-flipping arrow */
[popover] {
all: unset;
--tether-offset: 1px;
--tether-size: 8px;
position: absolute;
inset: auto;
position-anchor: --trigger;
position-area: top center;
position-try-fallbacks: --bottom, --left, --right;
margin: 0 0 var(--tether-size) 0;
clip-path: inset(var(--tether-offset)) margin-box;
box-shadow: none; /* Box-shadow interferes with clip-path margin-box */
display: block;
background: #16213e;
border-radius: 8px;
padding: 1rem;
}
/* Top/Bottom arrows */
[popover]::before {
content: "";
position: absolute;
z-index: -1;
background: inherit;
left: 50%;
transform: translateX(-50%);
width: calc(var(--tether-size) * 2);
height: calc(100% + var(--tether-size) * 2);
top: calc(var(--tether-size) * -1);
clip-path: polygon(
0 var(--tether-size),
50% 0,
100% var(--tether-size),
100% calc(100% - var(--tether-size)),
50% 100%,
0 calc(100% - var(--tether-size))
);
}
/* Left/Right arrows */
[popover]::after {
content: "";
position: absolute;
z-index: -1;
background: inherit;
top: 50%;
transform: translateY(-50%);
height: calc(var(--tether-size) * 2);
width: calc(100% + var(--tether-size) * 2);
left: calc(var(--tether-size) * -1);
clip-path: polygon(
var(--tether-size) 0,
calc(100% - var(--tether-size)) 0,
100% 50%,
calc(100% - var(--tether-size)) 100%,
var(--tether-size) 100%,
0 50%
);
}
/* Fallbacks */
@position-try --bottom {
position-area: bottom center;
margin: var(--tether-size) 0 0 0;
}
@position-try --left {
position-area: left center;
margin: 0 var(--tether-size) 0 0;
}
@position-try --right {
position-area: right center;
margin: 0 0 0 var(--tether-size);
}
/* Button styling */
button {
padding: 1rem 2rem;
background: #16213e;
color: #eee;
border: 2px solid #0f3460;
border-radius: 8px;
cursor: pointer;
}
</style>
</head>
<body>
<button id="trigger" popovertarget="my-popover">Click Me</button>
<div id="my-popover" popover>
<strong>Hello!</strong>
<p>I have an auto-flipping arrow.</p>
</div>
</body>
</html>For popovers at corners, you may want the arrow aligned to the edge rather than centered:
/* Arrow aligned to left side for top-right positioned popover */
#popover-top-right::before {
left: 20%; /* Instead of 50% */
}
#popover-top-right::after {
left: calc(var(--tether-size) * -1); /* Keep default */
}- Add
inset: auto- Browser popovers default toinset: 0which overrides anchor positioning - Use
all: unset- Removes all browser default styles that may interfere
- Ensure
background: inheriton pseudo-elements - Check that
clip-path: inset() margin-boxis set on the popover - Verify margin is set on the correct side
Check that your margin matches your position-area:
position-area: top center→margin-bottom(arrow points down)position-area: bottom center→margin-top(arrow points up)position-area: left center→margin-right(arrow points right)position-area: right center→margin-left(arrow points left)
Add box-shadow: none to the popover. Box-shadow interferes with clip-path: inset() margin-box and causes visual artifacts in the gap area. This is because popovers render on the top layer where box-shadow behaves differently with clip-path.
[popover] {
clip-path: inset(var(--tether-offset)) margin-box;
box-shadow: none; /* Required - box-shadow breaks the margin-box clip */
}CSS Anchor Positioning is supported in:
- Chrome 125+ (April 2024)
- Edge 125+ (April 2024)
- Firefox: Behind flag (as of Jan 2026)
- Safari: Not yet supported
Check caniuse.com/css-anchor-positioning for current support.
- Wes Goulet's CodePen - Original source of the
clip-path: inset() margin-boxtechnique - goulet.dev: Tooltip with Popover & Anchor Positioning - Detailed explanation of the arrow technique
- CSSWG Spec: CSS Anchor Positioning - Official W3C specification
- CSSWG Spec: position-area - Position area grid specification
- CSSWG Spec: @position-try - Fallback rules specification
- MDN: CSS Anchor Positioning - Overview and guide
- MDN: position-area - Property reference with examples
- MDN: @position-try - At-rule reference
- MDN: position-try-fallbacks - Fallback property reference
- CSS-Tricks: position-area - Almanac entry
- CSS-Tricks: position-try-fallbacks - Almanac entry
- Chrome Developers: CSS Anchor Positioning - Introduction and examples