CSS Anchor Positioning: No More JavaScript for Your Tooltips and Popovers

5 min read

As a Design Engineer, you've probably written this function at least a dozen times: calculating the position of a tooltip, dropdown, or popover relative to its trigger element. You measure getBoundingClientRect(), handle window.resize, scroll, viewport edges, and end up shipping a 30kb library for three lines of visual logic.

There's a better way. CSS Anchor Positioning has arrived, and it changes everything.

Anchor Positioning is a set of CSS properties that lets you position an element (the anchored element) relative to another element (the anchor) — directly in the rendering engine. No more measuring the DOM in JavaScript. The browser handles positioning, resizing, scrolling, and even fallback strategies automatically.

The problem: the nightmare of absolute positioning

Take the classic case of a button with a tooltip. Before Anchor Positioning, here's what your mental model (and codebase) looked like:

hooks/use-tooltip-position.ts
// The infamous useTooltipPosition hook you copied across 3 projects
const useTooltipPosition = (triggerRef, tooltipRef) => {
  const [position, setPosition] = useState({ top: 0, left: 0 });

  useEffect(() => {
    const update = () => {
      const trigger = triggerRef.current?.getBoundingClientRect();
      const tooltip = tooltipRef.current?.getBoundingClientRect();
      if (!trigger || !tooltip) return;

      // Manual calculation, edge handling, scroll...
      setPosition({
        top: trigger.bottom + 8,
        left: trigger.left + trigger.width / 2 - tooltip.width / 2,
      });
    };

    update();
    window.addEventListener("resize", update);
    window.addEventListener("scroll", update, true);
    return () => {
      window.removeEventListener("resize", update);
      window.removeEventListener("scroll", update, true);
    };
  }, [triggerRef, tooltipRef]);

  return position;
};

It's verbose, fragile, and doesn't even handle the case where the tooltip overflows the viewport. Now let's look at the CSS version.

The native solution: anchor() and position-area

1. The basic tooltip

The magic happens in two steps: declare the anchor, then position the anchored element.

index.html
<button class="trigger" popovertarget="my-tooltip">Click me</button>
<div id="my-tooltip" class="tooltip" popover>
  I'm positioned by the rendering engine!
</div>
index.css
.trigger {
  /* The trigger element becomes the anchor */
  anchor-name: --trigger-btn;
}

.tooltip {
  /* The tooltip is positioned relative to the anchor */
  position: absolute;
  position-anchor: --trigger-btn;

  /* Stick the bottom of the tooltip to the top of the anchor, horizontally centered */
  position-area: top;
  margin-bottom: 0.5rem;

  /* Smooth appearance animation */
  transition:
    opacity 0.2s ease,
    transform 0.2s ease;
}

/* Shown via the Popover API (zero JS) */
.tooltip:popover-open {
  opacity: 1;
  transform: translateY(0);
}

.tooltip {
  opacity: 0;
  transform: translateY(4px);
}

2. The smart dropdown with position zones

position-area is extremely powerful. It uses a logical grid of 9 zones around the anchor.

Alignment grid
Alignment grid

Here's a dropdown menu that opens at the bottom right by default:

index.css
.dropdown-trigger {
  anchor-name: --menu-trigger;
}

.dropdown-menu {
  position: absolute;
  position-anchor: --menu-trigger;
  position-area: bottom span-right;

  /* Aligns to the left edge of the anchor and extends to the right */
  justify-self: start;

  min-width: anchor-size(width);
  background: white;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}

The anchor-size(width) property is particularly elegant: the dropdown automatically matches the width of the trigger button. No more measuring offsetWidth in JavaScript.

3. Handling overflow with position-try

The real game-changer is automatic handling of edge cases. If your tooltip doesn't have enough room at the top, you can define a fallback strategy:

index.css
.tooltip {
  position: absolute;
  position-anchor: --trigger-btn;
  position-area: top;

  /* Defines a set of positions to try */
  position-try-fallbacks: --bottom, --left, --right;
}

@position-try --bottom {
  position-area: bottom;
  margin-top: 0.5rem;
}

@position-try --left {
  position-area: left;
  margin-right: 0.5rem;
}

@position-try --right {
  position-area: right;
  margin-left: 0.5rem;
}

The browser tries each position in order and picks the first one that fits within the viewport. You write zero collision-detection logic. The rendering engine takes care of it.

Integrating it into a Design System

In a modern design system, you can wrap all of this into utility classes or components. Here's an example with a Tailwind + custom CSS classes approach:

components/anchor-popover.tsx
interface AnchorPopoverProps {
  anchorName: string;
  position?: "top" | "bottom" | "left" | "right";
  children: React.ReactNode;
}

export const AnchorPopover = ({
  anchorName,
  position = "top",
  children,
}: AnchorPopoverProps) => {
  return (
    <div
      popover
      className={cn(
        "absolute rounded-lg border bg-white p-4 shadow-lg",
        "transition-all duration-200 ease-out",
        "translate-y-1 opacity-0 [:popover-open]:translate-y-0 [:popover-open]:opacity-100",
        // Anchor positioning via style or CSS custom properties
        "[position-anchor:var(--anchor)] [position-area:var(--pos)]",
      )}
      style={
        {
          "--anchor": anchorName,
          "--pos": position,
        } as React.CSSProperties
      }
    >
      {children}
    </div>
  );
};

And on the design system's global CSS side:

index.css
@layer components {
  .ds-anchor {
    anchor-name: var(--anchor-name);
  }

  .ds-anchored {
    position: absolute;
    position-anchor: var(--anchor);
    position-area: var(--pos, top);
    position-try-fallbacks: --flip;
  }

  @position-try --flip {
    position-area: bottom;
  }
}

What you actually gain

Before (JS)After (CSS Anchor)
40-line usePopoverPosition hook3 CSS properties
ResizeObserver + IntersectionObserverNative position-try-fallbacks
Recalculation on every scroll frameHandled by the browser's compositor
Dependency on @floating-ui/reactZero dependencies
Risk of visual layout shiftAtomic positioning at the rendering level

When to use it (and when not to)

Use Anchor Positioning for:

Stick with JS for:

Conclusion

CSS Anchor Positioning is a perfect example of what the Design Engineer's job is really about: finding the sweet spot where native CSS technology replaces a complex JavaScript solution, while improving both performance and maintainability.

Your design system doesn't need a 30kb library to position a tooltip. It needs 6 lines of CSS and a modern browser. Let the rendering engine do what it was built to do.

Read Next