You already know how CSS positioning can turn into a pile of magic numbers. You set top: 6px, everything looks great, then a designer tweaks one thing and your layout starts slipping all over the place. That’s where css anchor positioning gets interesting, because it lets you position one thing in relation to another thing directly, even when the DOM structure is not ideal.
This post walks through two real-world patterns: pinning a badge to an image even when the badge lives somewhere else in the markup, and drawing thread lines in a chat UI by anchoring a single pseudo-element to multiple targets.
Tethering a badge to an image (without changing your markup)
Picture a common card layout. You have an image at the top, content underneath, and a little badge (a “tag” or “label”) sitting in the content area. Everything feels normal until the designer comes back with a request that sounds simple: move the badge so it sits at the bottom-right corner of the image.
If the badge lives inside the content section (not inside the image wrapper), you’ve got a problem. You can’t just absolutely position it inside the image container because it isn’t inside that container. Historically, you’d either accept hacky offsets or you’d restructure the HTML.
Anchor positioning gives you a third option: keep your markup as-is, and visually tether the badge to the image anyway.
Why position: absolute turns into “magic numbers”
The classic setup looks like this:
- The card (or product) becomes the containing block with
position: relative. - The badge becomes
position: absolute. - You offset it with
topandright.
At first, it’s clean:
top: 6px; right: 6px;puts the badge near the top-right.
But moving it to the image’s bottom-right corner is where things go sideways. Since the badge is positioned relative to the card, you start guessing the image height:
top: 154px; right: 6px;(or whatever number looks right today)
That “today” part is the trap. The moment the image height changes, your badge is wrong again.
You have a couple fallback options, but they’re not great:
- Custom properties for fixed heights: If your images truly have a fixed height, you can store it and compute offsets. This works, but you’re still babysitting numbers.
- Restructure the HTML: You can add an image wrapper and move the badge into it. Sometimes that’s fine, sometimes it breaks the content structure you actually want.
Anchor positioning is appealing because you keep the HTML that makes sense, and still get a layout that responds to real element geometry.
Set up the image as an anchor
The first step is naming the element you want to anchor to. Anchor names look like custom properties, they must start with --.
On the image (the thing you want to tether to), you assign an anchor name:
anchor-name: --product-image;
Now, on the badge, you tell CSS which anchor you want to use. Being explicit is helpful when you mean “this badge should attach to that image”:
position-anchor: --product-image;
At this point, nothing moves yet. That’s normal. You’ve only declared the relationship. You still need to tell CSS which edges should line up.
Use anchor() so offsets come from the anchor, not the containing block
This is the part that trips people up at first. If you write:
bottom: 6px; right: 6px;
you’re still thinking in the old model. Those values default to the containing block. When you’re anchor positioning, you want the offsets to reference the anchor itself.
That’s what the anchor() function is for.
To stick the badge to the bottom-right of the image, you anchor the badge’s edges to the image’s edges:
bottom: anchor(bottom);right: anchor(right);
Now the badge snaps to the image, even though it sits elsewhere in the markup.
You can also anchor to different edges if you want. For example, anchoring the badge’s bottom to the image’s top will place it above the image. If you’re using overflow: hidden or clipping on the image (common with border radius), you might not see it unless you temporarily disable overflow while testing.
Fine-tune spacing with calc(), not margins
Once the badge is pinned, you usually want a little breathing room so it doesn’t sit exactly on the corner. You could use margins, but anchor positioning plays nicely with calculated offsets.
You can nudge the badge inward or outward using calc() with the anchored position:
- Move it up from the bottom edge:
bottom: calc(anchor(bottom) - 8px);
- Move it left from the right edge:
right: calc(anchor(right) + 8px);
The nice part is that the browser handles the math for you. You’re still describing the relationship in plain terms, you’re just adding a consistent offset.
This is the difference between “place it at pixel 154” and “place it 8px from the image edge.” One survives redesigns, the other doesn’t.
When everything breaks: repeated anchor names and anchor-scope
There’s a gotcha that shows up as soon as you have multiple cards on the page.
If every image uses the same anchor name (--product-image), and your positioning context isn’t set up in a way that naturally scopes each anchor to its card, then “last one wins.” Every badge may anchor itself to the final image on the page, and all your badges stack in one spot.
This can happen when you remove a position: relative (sometimes you need to), or when other positioning rules change the containing behavior in unexpected ways.
The fix is anchor-scope.
Instead of making every anchor name globally unique (which would be painful), you scope the anchor name to a parent element, like the card itself:
anchor-scope: --product-image;
Now each card creates a boundary. Inside that card, --product-image refers to that card’s image only, not the last image on the page.
If anchor positioning starts behaving strangely, it’s usually a validity issue or a scoping issue. The most useful deep dive on this is James Stucky’s OddBird write-up on anchor positioning validity. It covers the weird edge cases that can make an anchor appear “broken” even when the syntax looks right.
Drawing thread lines by anchoring to multiple elements
Tooltips and menus get most of the attention, but anchor positioning gets more interesting when one element needs to relate to two different things at the same time.
A clean example is a chat thread UI:
- A main comment sits on the left.
- Replies are listed underneath, indented to the right.
- You want a connecting “thread” line that visually ties the comment to each reply.
You could build this with extra markup, nested wrappers, or SVG. Or you can draw it with a single pseudo-element per reply, anchored to both the comment and the reply.
Start with a pseudo-element you can actually see
Each reply gets an ::after pseudo-element that will become the thread line.
A pseudo-element won’t render unless it has content and a layout method (like display or position). A quick way to debug is to give it a loud background color and a small height, then switch into the real styling once you know it’s showing up.
Once it exists, you typically move it into place with absolute positioning. If you forget that part, you can stare at correct CSS for longer than you’d like, wondering why nothing appears.
Name your anchors: one for the comment, one for the reply
You need two anchor points:
- The main comment element becomes
--comment - Each reply element becomes
--reply
So you set:
- On the comment:
anchor-name: --comment; - On each reply:
anchor-name: --reply;
Now the pseudo-element can reference both.
Anchor the pseudo-element’s top and left to the comment
On the pseudo-element, you anchor its top edge to the bottom of the comment. That makes the line start right beneath the comment bubble:
top: anchor(bottom of --comment);
Then you anchor its left edge to the left edge of the comment:
left: anchor(left of --comment);
It’s a satisfying moment when that snaps into place, because you didn’t calculate anything. You didn’t care about heights. You just described the relationship.
Also notice what you didn’t do: you didn’t add position: relative to the reply as a containing block. You want the pseudo-element to “reach over” to the comment, so you let it.
Anchor the bottom to the reply’s center (and stop using height)
Now you need the line to end halfway down the reply bubble. Anchor positioning supports center, which makes this much easier than manually computing offsets.
You anchor the bottom edge of the pseudo-element to the center of the reply:
bottom: anchor(center of --reply);
Once you do that, you can remove any fixed height you were using. The element stretches exactly as far as it needs to.
At this stage, there’s another problem waiting: every reply’s pseudo-element may try to anchor to the wrong --reply (often the last one), which makes lines overlap.
That’s the same class of issue as the card badges. You’ve reused an anchor name, and without scoping, the browser picks a winner.
Use anchor-scope so each reply only sees itself
For thread lines, you want a reply’s pseudo-element to see:
- the global comment anchor (fine),
- but only its own reply anchor name.
So you scope the reply anchor name on the reply element itself:
anchor-scope: --reply;
This is a nice pattern because the anchor-name and the anchor-scope live together when you want to keep the name local.
Now each reply’s pseudo-element connects to its own center, not the last reply on the page.
Turn the block into a thread line using borders and radius
Once positioning is correct, you can stop using a filled rectangle and switch to borders so it looks like a line.
A clean approach is:
- Use a border to draw the shape.
- Hide the borders you don’t want.
- Add a border radius so the corner looks like a thread curve.
In the demo, the line becomes a border with increased thickness for visibility, then it gets refined:
- A thicker white border to confirm you have separate elements.
- Then back to a slimmer border for a nicer look.
- Then hide top and right borders (so you’re left with the thread shape you actually want).
- Then add
border-radius: 0 0 0 12px;to soften the bend.
You can also get rid of “width magic numbers” by anchoring the right edge of the pseudo-element to the reply itself:
- Remove fixed
width. - Set
right: anchor(left of --reply);
Now the line always reaches the reply, even if your layout spacing changes.
Bonus: this setup can animate cleanly
Because you’re anchoring a single pseudo-element between two real layout points, you can animate between positions in a way that feels natural. That opens the door to UI touches like smoothly moving indicators.
The approach shown ties into another example, Kevin Powell’s navigation anchor positioning video, which focuses on animating anchored UI parts.
Quick reminders that keep anchor positioning sane
If you’re going to use anchor positioning in real components, a few details keep you out of trouble:
- Anchor names must start with
--, just like custom properties. - Use
anchor()for offsets when you want alignment based on the anchor’s edges, not the containing block’s edges. - Expect scoping issues when you repeat the same anchor name across multiple components,
anchor-scopeis the clean fix. - When behavior feels inconsistent, keep OddBird’s anchor positioning validity guide by James Stucky nearby, it covers the real-world edge cases.
Conclusion
When you stop thinking of positioning as “offset from the parent” and start thinking in relationships, css anchor positioning feels like a missing piece finally showed up. You can pin a badge to an image without rewriting your markup, and you can draw thread lines that stay correct even as content shifts. Try one of these patterns in a small component first, then see where it fits in your own UI. Once you do, you’ll start noticing all the little “magic number” problems you can delete.
Leave a Reply