uture CSS: Anchor Positioning

Anchor positioning might be one of the most exciting features coming to CSS. It is currently available under an experimental flag in Chrome Canary, and after playing with it for a bit, I couldn’t stop myself from sharing what I found. In this article, I will show you some of my experiments.

he Disclaimers

This article is quite long! Before we dive in, I want to mention a few disclaimers:

  1. My testing only covers what is present in Chrome Canary’s experimental implementationGo to a sidenote. There is no guarantee that if things change, I will come back and modify the examples to work with the new version of the specs/implementation. Each demo comes with a video recording, allowing you to see how things work even if you cannot access this version of Chrome right now. However, I highly recommend checking everything in it for the complete experience.
  2. Even though things could change, I still urge you to read the current Editor’s Draft of the “CSS Anchor Positioning” spec and play with the current implementation yourself. If you would then give your feedback to the CSSWG, that would be even better!
  3. My experiments in this article do not touch all parts of the specs & the implementation. For example, I haven’t had a chance to experiment with all of the fallback stuff or explore the anchor-scroll property yet. More to think about for future experiments!
  4. I did submitGo to a sidenote most of my feedback about the specs & implementation in GitHub issues and provided the links to them where appropriate.
  5. I would not go too deep into the details of my experiments' implementation — things could change, and my goal is to show what is possible rather than how to do stuff. The code can also be messy — it is not of production quality, could be improved a lot, and so on. You know, it is “experimental”.
  6. I’m sure my experiments have room for improvement from the accessibility standpoint. If you’d notice something you know could be improved, let me know about it! My goal with the examples was to get to a proof-of-concept, so I probably cut some corners.
  7. Again, this is experimental and unstable — don’t use it in production, even with graceful degradation/progressive enhancement.

Side note: Version 113.0.5653.0 at the time of publishing. Jump to this sidenote’s context.

Side note: Total, for now, I did submit seven new issues and commented on two more. Jump to this sidenote’s context.

hat is Anchor Positioning

Jhey Tompkins (one of the spec editors) did publish an article that describes the basics rather nicely — Tether elements to each other with CSS anchor positioning — you could want to read it to get an introduction to the whole concept of anchoring things, and for what we could use it.

I won’t try to re-explain the specs or rephrase what Jhey wrote (I did most of my experiments before his article and without looking into his prior demos), but I will attempt to provide a brief description of anchor positioning as I understand it.

In short, anchor positioning augments absoluteGo to a sidenote positioning by allowing us to use the positions and dimensions of elements other than the element’s usual positioning context.

In our current CSS, absolute-positioned blocks are usually positioned relative to their closest positioned ancestor or the initial containing block, so if we say something like left: 1em or width: 100%, it is that ancestor that the browser would use.

Anchor positioning allows us to make our absolutely-positioned elements rely on other elements' positions and dimensions. How it works:

  1. First, we need to mark our elements via a new anchor-name property, allowing us to use those named elements as our targetsGo to a sidenote.

  2. Then, on our absolutely positioned elements, we would need to use the anchor() value or anchor-size() to retrieve the corresponding properties of our target elements.

  3. Use anchor-scroll or position fallback for more complicated cases.

And that’s mostly it.

y Experiments

I would present you with what I thought we could do by using anchor positioningGo to a sidenote — through the years, I often dreamt about something like that, so it was not so hard to come up with the examples. Note that this is just a brief exploration of what is possible — this feature already looks very-very powerful, and there are many exciting possibilities to explore.

My examples will progress from basic to advanced, with elements using more and more anchors for their positioning. All examples would contain additional styling, which I won’t describe in the article (but you’re welcome to look into the source — though I did not make the code pretty, it is all very experimental and raw).

ross-Referencing

I will start with an example that I’d already want to use in production — otherwise, we either need to use JS, hard-coded positions, or some hacks.

That is the ability to visually transfer hover or focus state from one element to another, highlighting the context that might be in a different place on the page.

Which property corresponds with which value?

  1. display
  2. visibility
  3. opacity
  1. hidden
  2. 0
  3. none

It becomes possible to highlight something in a completely different place on the page, allowing elements to “know of each other”.

This kind of visual technique allows making it easier to discover connections between items and is often used in the wild:

I saw this in other places — dataviz, code explainers, docs — the use cases are everywhere. And anchor positioning makes implementing this very easy — below is the whole CSS for the example above.

.example1 [style*='--is:'] {
  anchor-name: var(--is);
}

.example1 [style*='--for:']:is(:hover, :focus-visible)::after {
  content: "";

  position: absolute;
  top:    anchor(var(--for) top);
  right:  anchor(var(--for) right);
  bottom: anchor(var(--for) bottom);
  left:   anchor(var(--for) left);

  box-shadow: 0 0 0 4px hotpink;
}

And all we need to do in HTML is to provide the names and targets of our elements via --is and --for variablesGo to a sidenote.

<li style="--for: --none" tabindex="0">
  <code style="--is: --display">display</code>
</li>

That’s it! We define which elements are anchors, then use their positions for our absolutely positioned pseudo-element that works as this “hover” state.

A few things to note:

  1. One common mistake I initially had in my experiments — using the names without the double dashes in the beginning. The spec defines the names as <dashed-ident>, so they won’t be your usual idents, like with animation names or grid areas, but more like a CSS custom property’s name.

  2. While the examples use simple names for those idents, in reality, those currently behave closer to the HTML’s ids, so in practice, those would need to be unique.

    It is possible to have multiple anchors with the same name, but only if they’re isolated by some positioning context. The experiments that reuse the same anchor names would have the whole example wrapped with a position: relative. That is a bit limiting, and I’d wish there was a way to control this further — and I did open an issue where we could discuss this.

  3. Ideally, I would want to use something like inset: anchor(var(--for)) here, but the spec does not define in any way how the inset shorthand properties should behave, so this doesn’t work (or doesn’t do what I’m expecting it to). I did open an issue about this.

ransitions

So, we can position elements over other elements, but can we change our targets dynamically? Yes, and one thing I tried immediately (and that seemed to work, partially) — transitions between those states!

HTML is, again, very straightforward — just our regular list with links inside and our “hover/focus ring” being the list’s ::before pseudo-element.

CSS, though, is a bit hard-coded. The basics for positioning the “hover” element are the same as in the previous example, while the way we’re attaching things is different:

.slider {
  anchor-name: --slider-menu;
  --target: --slider-menu;
}

.slider::before {
  content: "";
  position: absolute;
  top:    anchor(var(--target) top);
  left:   anchor(var(--target) left);
  right:  anchor(var(--target) right);
  bottom: anchor(var(--target) bottom);
  transition: all 0.3s;
}

.slider-link {
  anchor-name: var(--is);
}

.slider-item:nth-child(1) { --is: --item-1 }
.slider:has(:nth-child(1) > .slider-link:is(:hover, :focus-visible)) {
  --target: --item-1;
}

.slider-item:nth-child(2) { --is: --item-2 }
.slider:has(:nth-child(2) > .slider-link:is(:hover, :focus-visible)) {
  --target: --item-2;
}

/* …and so on */
  1. We’re using a --target CSS variable to control to which anchor we’re attaching our pseudo-element.
  2. Our anchor-positioned pseudo-element has the usual anchor() for the positioning, using the --target CSS variable.
  3. By default, we’re using our wrapper as the target, making the initial transition come from “around” the items (I tried using the fallback values for the anchors — this didn’t work for the transition).
  4. Then, we’re using a transition both for the positioning (essentially animating the top-right-bottom-left properties) and other visuals.
  5. Each link inside would get an anchor-name from an --is CSS variable on its parent list-item.
  6. For eachGo to a sidenote :nth-child(N) list-item we need to:
    • Set the --is variable with its index.
    • On a slider, check if there is a hovered or focused link in this list-item via a :has() pseudo-class, and if so, assign this item as the --target.

This method seems to work fine — when all the anchor names are statically assigned, and we dynamically change which anchor we use at any given time — the transitions work perfectly.

I also tried to do something like this, which did not work:

.slider::before {
  content: "";
  position: absolute;
  top:    anchor(--target top);
  left:   anchor(--target left);
  right:  anchor(--target right);
  bottom: anchor(--target bottom);
  transition: all 0.3s;
}

.slider-link:is(:hover, :focus) {
  /* Does not trigger the transition :( */
  anchor-name: --target;
}

But while the positioning itself works — we can dynamically assign the anchor-name to the hovered items — the transitions do not. But how elegant it could’ve been if re-assigning the anchor-name resulted in the same transition between the old and new states! I did weight in on at in an issue related to this.

“Four Quadrants”

In the previous experiments, I only connected each element to one other. However, nothing prevents us from using multiple anchors simultaneously on a single element. And with two or more connections, things immediately become much more captivating.

I think the technique I would be talking about now would get a lot of tractionGo to a sidenote — the ability to anchor things to multiple elements simultaneously, thus creating connections between them. It could be an invaluable feature for displaying various decorations — one that previously required dynamic on-the-fly calculations, probably involving resize or intersection observers.

The examples in this section would contain two elements inside a box, with their positions animated, allowing us to conveniently check how the anchor positioning would work for various arrangements of those elements relative to each other.

A small disclaimer about this animation’s limitations:

  1. I’m using the animation for top & left in a position: relative context. Ideally, for better performance, we could want to use a transform or maybe even an offset-path, but — anchoring would be calculated before any transforms and offset-path, so we cannot (hopefullyGo to a sidenote, yet?) rely on these.

  2. I could position the elements absolutely instead of relatively, but for this, I would need to change the layout, as there are currently limitations in attaching things to other absolutely positioned elements. More on this later.

he Challenge

To beginGo to a sidenote, I’ll describe some of the details of the challenge to help illustrate the problem.

In the first example, let us do this:

.example2--intro .connection {
  top:    anchor(var(--_from) center);
  left:   anchor(var(--_from) center);
  right:  anchor(var(--_to)   center);
  bottom: anchor(var(--_to)   center);
}

Here we use one element in anchors for top and left, and another for right and bottom:

We can see that this allows us to draw a rectangle between both elements, but only when they’re positioned in a very particular way — the first element should have x and y coordinates smaller than the second one. Otherwise, the final box would have a computed width or height equal to zero, so we would essentially see our box in ¼ of all the cases.

Now, my first idea to fix this was to do the following:

.example2--min .connection {
  top: min(
    anchor(var(--_from) center),
    anchor(var(--_to)   center)
  );
  left: min(
    anchor(var(--_from) center),
    anchor(var(--_to)   center)
  );
  right: min(
    anchor(var(--_from) center),
    anchor(var(--_to)   center)
  );
  bottom: min(
    anchor(var(--_from) center),
    anchor(var(--_to)   center)
  );
}

Because math functions like calc() or min() support anchor values inside, we can always choose an appropriate element’s position and get the box drawn always!

This shows how we can “draw” a rectangle between two points! And there is no transition or animation on the rectangle — it just gets its top, right, bottom, and left from the two circles.

Why is this not the final technique? I don’t think there are a lot of use cases for this exact solution — all due to the rectangle not being aware of the actual circle positions. It might seem that the rectangle “rotates”, but it really doesn’t. We can easily see this if we would add a background with a gradient that has corners matching our elements:

We can see that the red color is always in the top left, and violet — in the bottom right, not corresponding with the appropriate element’s color.

What we could want is to be able to determine the actual direction here. However, the current specGo to a sidenote doesn’t allow us to. If only we could use the anchor() and anchor-size() for things like background-size, background-position and transform — this could potentially help us determine the relative positions of our elements and could allow us to achieve this and much more complicated effects!

he Technique Itself

Until we would get something that would allow us to do this on one element, here is a demonstration of the technique that I propose:

What did we do to achieve this? Well, this is kind of a hack where we’re using the behavior from the very first example but on multiple elements:

<div class="connection"></div>
<div class="connection connection--flip-x"></div>
<div class="connection connection--flip-y"></div>
<div class="connection connection--flip-x connection--flip-y"></div>

And the (slightly simplified) CSS:

.example2--with-workaround .connection {
  top:    anchor(var(--_from) center);
  left:   anchor(var(--_from) center);
  right:  anchor(var(--_to)   center);
  bottom: anchor(var(--_to)   center);
  --flip-x: 0;
  --flip-y: 0;
  transform:
    scaleX(calc(1 - 2 * var(--flip-x)))
    scaleY(calc(1 - 2 * var(--flip-y)));
}
.example2--with-workaround .connection--flip-x {
  left:  anchor(var(--_to)   center);
  right: anchor(var(--_from) center);
  --flip-x: 1;
}
.example2--with-workaround .connection--flip-y {
  top:    anchor(var(--_to)   center);
  bottom: anchor(var(--_from) center);
  --flip-y: 1;
}

Instead of one element with the min(), we can use four, only one of which would be visible at any point. The rest would have one or both dimensions equal to zero. Then, we are also flipping the element via a transform, so we won’t need to modify anything in how the gradient in its background is implemented (it is possible to also just provide different content for each quadrant when necessary, but for a visual-only effect that does not contain any text inside I find the “transform” the simplest).

This method allows us to create various effects:

And so on — even with this non-ideal workaround, we could already do a lot of different effects using this type of connection as a building block.

This ability to connect elements visually can be incredibly versatile, and we did not have anything like that available with just plain HTML&CSS before.

sing the Connections

There are so many things I want to implement using these connectors! But if I tried to do all that was there in my head, I would never finish this article.

So, for this one, I would put just one practical example:

How cool is that? This list does not have any positions hard-coded — the only thing we need to set up is all the connections in HTML. Then, anchor positioning would be responsible for placing all the connectors.

.tree-item-label {
  line-height: var(--lh);
  anchor-name: var(--is);
}
.tree-item-label::before,
.tree-item-label::after {
  position: absolute;
  content: "";
  left:  anchor(var(--to) right);
  right: anchor(var(--is) left);
  /* background with an SVG for the connectors */;
}
.tree-item-label::before {
  top:    calc(anchor(var(--to) top) + 0.5 * var(--lh));
  bottom: anchor(var(--is) center);
}
.tree-item-label::after {
  bottom: calc(anchor(var(--to) top) - 0.5 * var(--lh));
  top:    anchor(var(--is) center);
  transform: scaleY(-1);
}

We can see how we can connect our pseudo-elements to two elements and also modify the value with a calc() — in this case, I’m attaching the left part not to the center of the element but to the center of the first line — as if the text would wrapGo to a sidenote. Due to an uneven right edge, it is better not to point the connectors to the center. An alternative to a calculation could be placing an inline element at the item’s start, then targeting its vertical position instead.

Sadly, the HTML structure is not super lean here:

<ul class="tree">
  <li class="tree-item" style="--is: --node-1">
    <p class="tree-item-label">CSS selectors</p>
    <ul class="tree" style="--to: --node-1">
      <li class="tree-item" style="--is: --node-1-1">
        <p class="tree-item-label">Basic selectors</p>
        <ul class="tree" style="--to: --node-1-1">
          <li class="tree-item" style="--is: --node-1-1-1">
            …

Here we have to:

  1. Define the current root node on each li that has nested items.
  2. Reuse it, setting as --to on the nested ul.

Potentially, we could eliminate the second part if we could have something like inherit() in CSSGo to a sidenote.

ables

This experiment is more of a continuation of the first one — where we’re highlighting something, but this time we’re basing things on two or more elements at the same time:

Types of support: no support, partial, full support.

Browsers shown: Chrome, Safari, Firefox.

position: sticky browser support
Support Chrome Safari Firefox
No ≤ 55.0 ≤ 6.0 ≤ 31.0
Partial 56 – 90.0 6.1 – 7.0 32 – 58.0
Full ≥ 91.0 ≥ 6.1 ≥ 59.0
  • A parent with overflow set to auto will prevent position: sticky from working in Safari
  • Firefox 58 & below, Chrome 63 & below and Safari 7 & below do not appear to support sticky table headers.
  • Hover or focus me to highlight two completely different spans!

In the example above, we have a regular HTML table where we can highlight any cells we want from the outside, declaring them based on which rows/columns we want to highlight.

Without anchor positioning, this is almost impossible to achieve with just CSS — this can be kinda possible if we would implement the table using CSS grids, but we’d have a lot of problems, starting from accessibility (as we’d need to override table display), and finishing with the “outside of the table” requirement — we’d need to hack things in some way structure-wise; otherwise, the grid’s named rows and columns won’t be available outside of it.

And even though we could do something similar when using :has(), we could not style only specific cells and not any arbitrary spans without heavy hard-coding.

But with anchors, things are so easy!

Like, here is the whole CSS that defines our anchors and the API to access them on a .pointer class:

.example1b tbody {
  anchor-name: --tbody;
}
.example1b [style*='--column-id:'] {
  anchor-name: var(--column-id);
}
.example1b [style*='--row-id:'] {
  anchor-name: var(--row-id);
}
.example1b .first-column::before {
  content: "";
  display: block;
  margin-inline: -0.5em;
  anchor-name: --first-column;
}
.example1b .pointer {
  --column: initial;
  --row: --tbody;
  --column-start: var(--column, --first-column);
  --column-end:   var(--column, --tbody);
  --row-start:    var(--row);
  --row-end:      var(--row);
  text-decoration: underline;
  text-decoration-style: dotted;
}
.example1b .pointer:is(:hover, :focus-visible)::before {
  content: "";
  position: absolute;
  top:    anchor(var(--row-start)    top);
  bottom: anchor(var(--row-end)      bottom);
  left:   anchor(var(--column-start) left);
  right:  anchor(var(--column-end)   right);
  outline: 2px solid hotpink;
  border-radius: 0.5em;
}

In HTML, the header cells and rows are the only elements that require anchors. No need to duplicate anything on the cells themselves! And then, it becomes trivial to add a highlight by using a .pointer with CSS API:

<span
  tabindex="0"
  class="pointer"
  style="
    --column: --firefox;
    --row-end: --partial;
  "
>
  Firefox 58 & below
</span>

In this example, whenever we target something, we can use from 2 to 4 different elements as targets for our absolutely positioned element.

A few notes:

  1. Look at how we don’t have anything hard-coded in CSS — we only mention rows & columns, we don’t mention any specific names, and so on.
  2. For targeting the first column, we have to create a pseudo-element, as we can’t assign two different anchor names to the same element, so we have to add an extra one just for the additional anchor name. While we could mention the --chrome anchor there, that would mean hard-coding it into CSS, making it less reusable. Another option would be to define an additional API for the component, where we could provide the “first column” via a separate CSS variable.
  3. We can use a CSS-like API with “shorthands” for rows/columns when mentioning them, by default targeting everything from the first column and row to the end of the tbody and allowing specifying any of the boundaries manually, either as a whole row or column, or only a specific part like a start or end.

I’m fascinated by how we can use modern CSS to define such expressive APIs for our components!

idenotes Layout

For the final experiment, I wanted to do something different. Maybe not as shiny and captivating visually, but something that tries to get to the maximum of what we could do with anchor positioning — trying to apply it for layoutsGo to a sidenote.

When we talk about absolute positioning in the context of layouts, we often think of it as this very fragile/hard-coded way of doing them, as absolutely positioned elements do not know anything about each other, so it is hard to make things responsive or context-aware.

With anchor positioning, we can make them behave with more sophisticated rules, especially when we can attach one absolutely-positioned element to another. Though currently there is a significant limitation in when this works, making the actual usage of this behavior complicated and probably not production-friendly — let’s first look at the example.

For the exampleGo to a sidenote, I chose, once again, a thing that is present in the design of my blog, which is, again, the side-notes!

This is the first paragraph of this example, which contains a sidenote reference1, with its content displayed in the second column, starting from the same line as its reference. A one-sided cross-referencing is also implemented in this example, so feel free to hover over a sidenote to locate its reference!

Hello, I am the second paragraph. The sidenote in this paragraph would go after a figure that is displayed in the second column before2 it. It would be displayed after the figure, even though its reference comes vertically before the figure ends.

An in-grid figure that moves the next sidenote down

Hello, I am the third paragraph, which starts from some text that does not have any sidenotes, so we could start the next sidenote somewhere later, padded by a few more text that I’m currently writing to pad the vertical space alongside its reference3.

The next paragraph contains a sidenote4 right away, which would be shifted down because the previous sidenote is there above it.

We would also need an extra paragraph with some extra words, just so there would be space for that last sidenote — because it is absolutely positioned, otherwise it would go out of this exmaple’s bounds, oops.

I won’t show you the complete code of this experiment, as a lot is going on in it, and the basics of anchor positioning are the same as for the previous examples, with the following exceptions:

  1. Here is the CSS declaration that does most of the work:

    top: max(
      anchor(var(--for)  top),
      anchor(var(--prev) bottom) + 0.5em
    );
    

    Here we’re using the max() to decide where we would show the sidenote — either on the line with its designated reference (top of the --for one) or 0.5em below the sidenote or figure before it (bottom of the --prev; note how we can use calculations inside max() without calc()).

  2. The worst part of this demo is the HTML. While aligning the elements on the same line as their references works as expected, the other sidenotes with --prev have absolute position. It leads to very limiting consequences — a need to hack the HTML to make this work. And this hack is very ugly.

he Problem

Let me quote the place from the specs — the definition of the acceptable anchor that describes our limitations:

An element el is a acceptable anchor element for an absolutely positioned element query el if any of the following are true:

For the purposes of this algorithm, an element is in a particular root layer corresponding to the closest inclusive ancestor that is in the top layer, or the document if there isn’t one. Root layers are “higher” if their corresponding element is later in the top layer list; the layer corresponding to the document is lower than all other layers.

Ok, so the first problem — this description sounds maybe a bit too complicated. Did you get what it talks about right away?

I imagine there are a lot of nuances, but I’ll try to rephrase it at least in the context of our example:

Our absolutely-positioned element cannot target another absolutely-positioned one if they exist inside the same positioning context. So, the two siblings could not target one another.

However, if the structure is such that one of the elements has a relatively positioned wrapper, the outer element could target the inner, but not vice versa.

At least, this is how it works for the current implementation: we cannot just place our sidenotes as siblings one after another in the end. We need to hack around this limitation.

he Hack

The hack is “simple”: for each sidenote, we need to add another wrapper around our content, then place our sidenotes in the end, at each level. Here is how it looks approximately:

<div class="wrapper">
  <div class="wrapper">
    <div class="wrapper">
      <div class="wrapper">
        <h1 />, <p /> and other content

        <aside class="sidenote" />
      </div>
      <aside class="sidenote" />
    </div>
    <aside class="sidenote" />
  </div>
  <aside class="sidenote" />
</div>

It works! But oh, how bad this is to handle!

I find this behavior very unnecessarily limiting. I know why it is there — to prevent the circularity issues, but I think there must be other ways to solve this.

For example — what if one more acceptable condition (when nothing else works) would be the dependence on the DOM order? Something like “an element later/deeper in the DOM tree can target an element earlier/upper, but not the other way around.” This way, we would not have an issue with circularity — as the elements could create a dependency chain/tree only in one direction! Combined with the other cases, where we are free to target non-positioned elements in any direction, this could create quite a versatile way of doing things.

At least, the case above with the sidenotes would work without extra wrappers — we would only need to mention the previous item.

We would still not have the ability to cross-reference elements in both ways — I don’t think we can target things inside those absolutely positioned elements from our references, and there could be other limitations, or maybe even other workarounds — but unlocking the DOM-order-dependent targeting for absolutely positioned elements would be tremendously helpful.

If you have your thoughts on this topic as well, it would be nice if you would write them down in an issue about this.

pdate from 2023-05-05

The issue was closed by simplifying the acceptance criteria, allowing targeting absolutely positioned elements that are earlier in the DOM (see this PR)! And, as an addition to that, when multiple acceptable anchors share a name, the last one of them would be used, which would be very helpful for this exact case with the sidenotes. Can’t wait for this change to appear in Chrome Canary to test it out!

onclusion

That’s it for now. I did a lot of other experiments — some of which were also very promising, but if I had tried to fit everything into this article, I would’ve never finished it. The last few years have been very good for CSS in general, with so many great features coming our ways, and we have so many other things on the roadmap! Maybe I would write more about anchor positioning, or I would write about something else — I don’t know yet. But the future is bright, and so many once-impossible things become closer and closer to us.

It was quite long since I submerged myself that deep into experimenting, and I already know that I missed so many things! I strongly encourage you to try experimenting, and see if you could solve your use cases with all the new tools we’re getting, and if not — give your feedback to the spec-writers and browser developers. It would make things better for everyone.


Let me know what you think about this article on Mastodon!