Skip to content

Instantly share code, notes, and snippets.

@alexreardon
Last active January 23, 2026 04:36
Show Gist options
  • Select an option

  • Save alexreardon/2008517fcbebe03f1e41e10b4104174c to your computer and use it in GitHub Desktop.

Select an option

Save alexreardon/2008517fcbebe03f1e41e10b4104174c to your computer and use it in GitHub Desktop.
Arrows on popovers with CSS anchor positioning

Generated by AI

CSS Anchor Positioning: Auto-Flipping Arrows with Popovers

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, the position-area property (including the critical span keywords), and complete working examples.

Table of Contents

  1. The Problem
  2. The Solution: clip-path margin-box
  3. Implementation
  4. Position-Area Reference
  5. Complete Example
  6. Troubleshooting
  7. Browser Support
  8. References

The Problem

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?

@position-try Property Limitations

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:


The Solution: clip-path: inset() margin-box

This technique was pioneered by Wes Goulet and detailed in his blog post.

The trick is to:

  1. Create arrows pointing in all four directions simultaneously using ::before and ::after pseudo-elements
  2. Use clip-path: inset() margin-box to clip the popover based on its margin-box (not border-box)
  3. Control which arrow is visible by changing margins (which @position-try CAN modify)

The key insight: arrows that extend into the margin area "escape" the clip and become visible.

⚠️ Limitation: This technique requires box-shadow: none on the popover. Box-shadow interferes with the margin-box clipping.


How It Works: Visual Explanation

The Clip-Path Margin-Box Concept

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

Margin Controls Arrow Visibility

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

Implementation

Step 1: Set Up Anchor Names

Each trigger button needs a unique anchor name:

#btn-example { anchor-name: --btn-example; }

Step 2: Base Popover Styles

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-shadow must be disabled

When using clip-path: inset() margin-box, you must set box-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.

Step 3: Create All Four Arrows

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 */
  );
}

Step 4: Define @position-try Fallbacks

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 */
}

Step 5: Apply Fallbacks to Popover

#popover-example {
  position-anchor: --btn-example;
  position-area: top center;
  position-try-fallbacks: --bottom, --left, --right;
}

Position-Area Reference

The position-area property places an anchor-positioned element on an implicit 3x3 grid centered on the anchor.

Sources:

The 3x3 Grid

              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."

Value Syntax

Single Values (Implicit span-all) ⚠️

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 (Single Cell) — Use for Centering

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  │          │          │          │
           └──────────┴──────────┴──────────┘

Span Keywords (Critical for Start/End Alignment)

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."

Why right span-bottom works for right-start:

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 ✓

Why position-area: right with align-self: start does NOT work:

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 ✗

Complete Placement Mapping

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 like right equals right span-all which may not center correctly
  • For -start placements, span AWAY from the aligned edge
  • For -end placements, span TOWARD the aligned edge

Complete Minimal Example

<!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>

Customizing Arrow Position

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 */
}

Troubleshooting

Popover not positioning correctly

  1. Add inset: auto - Browser popovers default to inset: 0 which overrides anchor positioning
  2. Use all: unset - Removes all browser default styles that may interfere

Arrow not showing

  1. Ensure background: inherit on pseudo-elements
  2. Check that clip-path: inset() margin-box is set on the popover
  3. Verify margin is set on the correct side

Arrow showing on wrong side

Check that your margin matches your position-area:

  • position-area: top centermargin-bottom (arrow points down)
  • position-area: bottom centermargin-top (arrow points up)
  • position-area: left centermargin-right (arrow points right)
  • position-area: right centermargin-left (arrow points left)

Strange color/artifact between popover and trigger

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 */
}

Browser Support

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.


References

Arrow Technique

CSS Anchor Positioning Specification

MDN Documentation

Tutorials & Articles

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment