CSS Anchor Positioning Guide
Not long ago, if we wanted a tooltip or popover positioned on top of another element, we would have to set our tooltip’s position to something other than static
and use its inset/transform properties to place it exactly where we want. This works, but the element’s position is susceptible to user scrolls, zooming, or animations since the tooltip could overflow off of the screen or wind up in an awkward position. The only way to solve this was using JavaScript to check whenever the tooltip goes out of bounds so we can correct it… again in JavaScript.
CSS Anchor Positioning gives us a simple interface to attach elements next to others just by saying which sides to connect — directly in CSS. It also lets us set a fallback position so that we can avoid the overflow issues we just described. For example, we might set a tooltip element above its anchor but allow it to fold underneath the anchor when it runs out of room to show it above.
Anchor positioning is different from a lot of other features as far as how quickly it’s gained browser support: its first draft was published on June 2023 and, just a year later, it was released on Chrome 125. To put it into perspective, the first draft specification for CSS variables was published in 2012, but it took four years for them to gain wide browser support.
So, let’s dig in and learn about things like attaching target elements to anchor elements and positioning and sizing them.
Quick reference
/* Define an anchor element */
.anchor {
anchor-name: --my-anchor;
}
/* Anchor a target element */
.target {
position: absolute;
position-anchor: --my-anchor;
}
/* Position a target element */
.target {
position-area: start end;
}
Basics and terminology
At its most basic, CSS Anchor Positioning introduces a completely new way of placing elements on the page relative to one another. To make our lives easier, we’re going to use specific names to clarify which element is connecting to which:
- Anchor: This is the element used as a reference for positioning other elements, hence the anchor name.
- Target: This is an absolutely positioned element placed relative to one or more anchors. The target is the name we will use from now on, but you will often find it as just an “absolutely positioned element” in the spec.
For the following code examples and demos, you can think of these as just two
<div class="anchor">anchor</div>
<div class="target">target</div>
CSS Anchor Positioning is all about elements with absolute positioning (i.e., display: absolute
), so there are also some concepts we have to review before diving in.
-
Containing Block: This is the box that contains the elements. For an absolute element, the containing block is the viewport the closest ancestor with a position other than
static
or certain values in properties likecontain
orfilter
. -
Inset-Modified Containing Block (IMCB): For an absolute element, inset properties (
top
,right
,bottom
,left
, etc.) reduce the size of the containing block into which it is sized and positioned, resulting in a new box called the inset-modified containing block, or IMCB for short. This is a vital concept to know since properties we’re covering in this guide — likeposition-area
andposition-try-order
— rely on this concept.
Attaching targets to anchors
We’ll first look at the two properties that establish anchor positioning. The first, anchor-name
, establishes the anchor element, while the second, position-anchor
, attaches a target element to the anchor element.
anchor-name
A normal element isn’t an anchor by default — we have to explicitly make an element an anchor. The most common way is by giving it a name, which we can do with the anchor-name
property.
anchor-name: none | <dashed-ident>#
The name must be a , that is, a custom name prefixed with two dashes (
--
), like --my-anchor
or --MyAnchor
.
.anchor {
anchor-name: --my-anchor;
}
This gives us an anchor element. All it needs is something anchored to it. That’s what we call the “target” element which is set with the position-anchor
property.
position-anchor
The target element is an element with an absolute position linked to an anchor element matching what’s declared on the anchor-name
property. This attaches the target element to the anchor element.
position-anchor: auto | <anchor-element>
It takes a valid . So, if we establish another element as the “anchor” we can set the target with the
position-anchor
property:
.target {
position: absolute;
position-anchor: --my-anchor;
}
Normally, if a valid anchor element isn’t found, then other anchor properties and functions will be ignored.
Positioning targets
Now that we know how to establish an anchor-target relationship, we can work on positioning the target element in relation to the anchor element. The following two properties are used to set which side of the anchor element the target is positioned on (position-area
) and conditions for hiding the target element when it runs out of room (position-visibility
).
position-area
The next step is positioning our target relative to its anchor. The easiest way is to use the position-area
property, which creates an imaginary 3×3 grid around the anchor element and lets us place the target in one or more regions of the grid.
position-area: auto | <position-area>
It works by setting the row and column of the grid using logical values like start
and end
(dependent on the writing mode); physical values like top
, left
, right
, bottom
and the center
shared value, then it will shrink the target’s IMCB into the region of the grid we chose.
.target {
position-area: top right;
/* or */
position-area: start end;
}
Logical values refer to the containing block’s writing mode, but if we want to position our target relative to its writing mode we would prefix it with the self
value.
.target {
position-area: self-start self-end;
}
There is also the center
value that can be used in every axis.
.target {
position-area: center right;
/* or */
position-area: start center;
}
To place a target across two adjacent grid regions, we can use the prefix span-
on any value (that isn’t center
) a row or column at a time.
.target {
position-area: span-top left;
/* or */
position-area: span-start start;
}
Finally, we can span a target across three adjacent grid regions using the span-all
value.
.target {
position-area: bottom span-all;
/* or */
position-area: end span-all;
}
You may have noticed that the position-area
property doesn’t have a strict order for physical values; writing position-area: top left
is the same as position-area: left top
, but the order is important for logical value since position-area: start end
is completely opposite to position-area: end start
.
We can make logical values interchangeable by prefixing them with the desired axis using y-
, x-
, inline-
or block-
.
.target {
position-area: inline-end block-start;
/* or */
position-area: y-start x-end;
}
position-visibility
It provides certain conditions to hide the target from the viewport.
position-visibility: always | anchors-visible | no-overflow
-
always
: The target is always displayed without regard for its anchors or its overflowing status. -
no-overflow
: If even after applying the position fallbacks, the target element is still overflowing its containing block, then it is strongly hidden. -
anchors-visible
: If the anchor (not the target) has completely overflowed its containing block or is completely covered by other elements, then the target is strongly hidden.
position-visibility: always | anchors-visible | no-overflow
Setting fallback positions
Once the target element is positioned against its anchor, we can give the target additional instructions that tell it what to do if it runs out of space. We’ve already looked at the position-visibility
property as one way of doing that — we simply tell the element to hide. The following two properties, however, give us more control to re-position the target by trying other sides of the anchor (position-try-fallbacks
) and the order in which it attempts to re-position itself (position-try-order
).
The two properties can be declared together with the position-try
shorthand property — we’ll touch on that after we look at the two constituent properties.
position-try-fallbacks
This property accepts a list of comma-separated position fallbacks that are tried whenever the target overflows out of space in its containing block. The property attempts to reposition itself using each fallback value until it finds a fit or runs out of options.
position-try-fallbacks: none | [ [<dashed-ident> || <try-tactic>] | <'inset-area'> ]#
-
none
: Leaves the target’s position options list empty. -
: Adds to the options list a custom
@position-try
fallback with the given name. If there isn’t a matching@position-try
, the value is ignored. -
: Creates an option list by flipping the target’s current position on one of three axes, each defined by a distinct keyword. They can also be combined to add up their effects.
- The
flip-block
keyword swaps the values in the block axis. - The
flip-inline
keyword swaps the values in the inline axis. - The
flip-start
keyword swaps the values diagonally.
- The
-
||
:
Combines a custom@try-option
and ato create a single-position fallback. The
keywords can also be combined to sum up their effects.
-
Uses the
position-area
syntax to move the anchor to a new position.
.target {
position-try-fallbacks:
--my-custom-position,
--my-custom-position flip-inline,
bottom left;
}
position-try-order
This property chooses a new position from the fallback values defined in the position-try-fallbacks
property based on which position gives the target the most space. The rest of the options are reordered with the largest available space coming first.
position-try-order: normal | most-width | most-height | most-block-size | most-inline-size
What exactly does “more space” mean? For each position fallback, it finds the IMCB size for the target. Then it chooses the value that gives the IMCB the widest or tallest size, depending on which option is selected:
most-width
most-height
most-block-size
most-inline-size
.target {
position-try-fallbacks: --custom-position, flip-start;
position-try-order: most-width;
}
position-try
This is a shorthand property that combines the position-try-fallbacks
and position-try-order
properties into a single declaration. It accepts first the order and then the list of possible position fallbacks.
position-try: < "position-try-order" >? < "position-try-fallbacks" >;
So, we can combine both properties into a single style rule:
.target {
position-try: most-width --my-custom-position, flip-inline, bottom left;
}
Custom position and size fallbacks
@position-try
This at-rule defines a custom position fallback for the position-try-fallbacks
property.
@position-try <dashed-ident> {
<declaration-list>
}
It takes various properties for changing a target element’s position and size and grouping them as a new position fallback for the element to try.
Imagine a scenario where you’ve established an anchor-target relationship. You want to position the target element against the anchor’s top-right edge, which is easy enough using the position-area
property we saw earlier:
.target {
position: absolute;
position-area: top right;
width: 100px;
}
See how the .target
is sized at 100px
? Maybe it runs out of room on some screens and is no longer able to be displayed at anchor’s the top-right edge. We can supply the .target
with the fallbacks we looked at earlier so that it attempts to re-position itself on an edge with more space:
.target {
position: absolute;
position-area: top right;
position-try-fallbacks: top left;
position-try-order: most-width;
width: 100px;
}
And since we’re being good CSSer’s who strive for clean code, we may as well combine those two properties with the position-try
shorthand property:
.target {
position: absolute;
position-area: top right;
position-try: most-width, flip-inline, bottom left;
width: 100px;
}
So far, so good. We have an anchored target element that starts at the top-right corner of the anchor at 100px
. If it runs out of space there, it will look at the position-try
property and decide whether to reposition the target to the anchor’s top-left corner (declared as flip-inline
) or the anchor’s bottom-left corner — whichever offers the most width.
But what if we want to simulataneously re-size the target element when it is re-positioned? Maybe the target is simply too dang big to display at 100px
at either fallback position and we need it to be 50px
instead. We can use the @position-try
to do exactly that:
@position-try --my-custom-position {
position-area: top left;
width: 50px;
}
With that done, we now have a custom property called --my-custom-position
that we can use on the position-try
shorthand property. In this case, @position-try
can replace the flip-inline
value since it is the equivalent of top left
:
@position-try --my-custom-position {
position-area: top left;
width: 50px;
}
.target {
position: absolute;
position-area: top right;
position-try: most-width, --my-custom-position, bottom left;
width: 100px;
}
This way, the .target
element’s width is re-sized from 100px
to 50px
when it attempts to re-position itself to the anchor’s top-right edge. That’s a nice bit of flexibility that gives us a better chance to make things fit together in any layout.
Anchor functions
anchor()
You might think of the CSS anchor()
function as a shortcut for attaching a target element to an anchor element — specify the anchor, the side we want to attach to, and how large we want the target to be in one fell swoop. But, as we’ll see, the function also opens up the possibility of attaching one target element to multiple anchor elements.
This is the function’s formal syntax, which takes up to three arguments:
anchor( <anchor-element>? && <anchor-side>, <length-percentage>? )
So, we’re identifying an anchor element, saying which side we want the target to be positioned on, and how big we want it to be. It’s worth noting that anchor()
can only be declared on inset-related properties (e.g. top
, left
, inset-block-end
, etc.)
.target {
top: anchor(--my-anchor bottom);
left: anchor(--my-anchor end, 50%);
}
Let’s break down the function’s arguments.
This argument specifies which anchor element we want to attach the target to. We can supply it with either the anchor’s name (see “Attaching targets to anchors”).
We also have the choice of not supplying an anchor at all. In that case, the target element uses an implicit anchor element defined in position-anchor
. If there isn’t an implicit anchor, the function resolves to its fallback. Otherwise, it is invalid and ignored.
This argument sets which side of the anchor we want to position the target element to, e.g. the anchor’s top
, left
, bottom
, right
, etc.
But we have more options than that, including logical side keywords (inside
, outside
), logical direction arguments relative to the user’s writing mode (start
, end
, self-start
, self-end
) and, of course, center
.
-
: Resolves to the
of the corresponding side of the anchor element. It has physical arguments (
top
,left
,bottom
right
), logical side arguments (inside
,outside
), logical direction arguments relative to the user’s writing mode (start
,end
,self-start
,self-end
) and thecenter
argument. -
: Refers to the position between the
start
(0%
) andend
(100%
). Values below0%
and above100%
are allowed.
This argument is totally optional, so you can leave it out if you’d like. Otherwise, use it as a way of re-sizing the target elemenrt whenever it doesn’t have a valid anchor or position. It positions the target to a fixed or
relative to its containing block.
Let’s look at examples using different types of arguments because they all do something a little different.
Using physical arguments
Physical arguments (top
, right
, bottom
, left
) can be used to position the target regardless of the user’s writing mode. For example, we can position the right
and bottom
inset properties of the target at the anchor(top)
and anchor(left)
sides of the anchor, effectively positioning the target at the anchor’s top-left corner:
.target {
bottom: anchor(top);
right: anchor(left);
}
Using logical side keywords
Logical side arguments (i.e., inside
, outside
), are dependent on the inset property they are in. The inside
argument will choose the same side as its inset property, while the outside
argument will choose the opposite. For example:
.target {
left: anchor(outside);
/* is the same as */
left: anchor(right);
top: anchor(inside);
/* is the same as */
top: anchor(top);
}
Using logical directions
Logical direction arguments are dependent on two factors:
- The user’s writing mode: they can follow the writing mode of the containing block (
start
,end
) or the target’s own writing mode (self-start
,self-end
). - The inset property they are used in: they will choose the same axis of their inset property.
So for example, using physical inset properties in a left-to-right horizontal writing would look like this:
.target {
left: anchor(start);
/* is the same as */
left: anchor(left);
top: anchor(end);
/* is the same as */
top: anchor(bottom);
}
In a right-to-left writing mode, we’d do this:
.target {
left: anchor(start);
/* is the same as */
left: anchor(right);
top: anchor(end);
/* is the same as */
top: anchor(bottom);
}
That can quickly get confusing, so we should also use logical arguments with logical inset properties so the writing mode is respected in the first place:
.target {
inset-inline-start: anchor(end);
inset-block-start: anchor(end);
}
Using percentage values
Percentages can be used to position the target from any point between the start
(0%
) and end
(100%
) sides. Since percentages are relative to the user writing mode, is preferable to use them with logical inset properties.
.target {
inset-inline-start: anchor(100%);
/* is the same as */
inset-inline-start: anchor(end);
inset-block-end: anchor(0%);
/* is the same as */
inset-block-end: anchor(start);
}
Values smaller than 0%
and bigger than 100%
are accepted, so -100%
will move the target towards the start and 200%
towards the end.
.target {
inset-inline-start: anchor(200%);
inset-block-end: anchor(-100%);
}
Using the center keyword
The center
argument is equivalent to 50%
. You could say that it’s “immune” to direction, so there is no problem if we use it with physical or logical inset properties.
.target {
position: absolute;
position-anchor: --my-anchor;
left: anchor(center);
bottom: anchor(top);
}
anchor-size()
The anchor-size()
function is unique in that it sizes the target element relative to the size of the anchor element. This can be super useful for ensuring a target scales in size with its anchor, particularly in responsive designs where elements tend to get shifted, re-sized, or obscured from overflowing a container.
The function takes an anchor’s side and resolves to its , essentially returning the anchor’s
width
, height
, inline-size
or block-size
.
anchor-size( [ <anchor-element> || <anchor-size> ]? , <length-percentage>? )
Here are the arguments that can be used in the anchor-size()
function:
-
: Refers to the side of the anchor element.
-
: This optional argument can be used as a fallback whenever the target doesn’t have a valid anchor or size. It returns a fixed
or
relative to its containing block.
And we can declare the function on the target element’s width
and height
properties to size it with the anchor — or both at the same time!
.target {
width: anchor-size(width, 20%); /* uses default anchor */`
height: anchor-size(--other-anchor inline-size, 100px);
}
Multiple anchors
We learned about the anchor()
function in the last section. One of the function’s quirks is that we can only declare it on inset-based properties, and all of the examples we saw show that. That might sound like a constraint of working with the function, but it’s actually what gives anchor()
a superpower that anchor positioning properties don’t: we can declare it on more than one inset-based property at a time. As a result, we can set the function multiple anchors on the same target element!
Here’s one of the first examples of the anchor()
function we looked at in the last section:
.target {
top: anchor(--my-anchor bottom);
left: anchor(--my-anchor end, 50%);
}
We’re declaring the same anchor element named --my-anchor
on both the top
and left
inset properties. That doesn’t have to be the case. Instead, we can attach the target element to multiple anchor elements.
.anchor-1 { anchor-name: --anchor-1; }
.anchor-2 { anchor-name: --anchor-2; }
.anchor-3 { anchor-name: --anchor-3; }
.anchor-4 { anchor-name: --anchor-4; }
.target {
position: absolute;
inset-block-start: anchor(--anchor-1);
inset-inline-end: anchor(--anchor-2);
inset-block-end: anchor(--anchor-3);
inset-inline-start: anchor(--anchor-4);
}
Or, perhaps more succintly:
.anchor-1 { anchor-name: --anchor-1; }
.anchor-2 { anchor-name: --anchor-2; }
.anchor-3 { anchor-name: --anchor-3; }
.anchor-4 { anchor-name: --anchor-4; }
.target {
position: absolute;
inset: anchor(--anchor-1) anchor(--anchor-2) anchor(--anchor-3) anchor(--anchor-4);
}
The following demo shows a target element attached to two elements that are registered anchors. A
allows you to click and drag it to change its dimensions. The two of them are absolutely positioned in opposite corners of the page. If we attach the target to each anchor, we can create an effect where resizing the anchors stretches the target all over the place almost like a tug-o-war between the two anchors.
The demo is only supported in Chrome at the time we’re writing this guide, so let’s drop in a video so you can see how it works.
Accessibility
The most straightforward use case for anchor positioning is for making tooltips, info boxes, and popovers, but it can also be used for decorative stuff. That means anchor positioning doesn’t have to establish a semantic relationship between the anchor and target elements. You can probably spot the issue right away: non-visual devices, like screen readers, are left in the dark about how to interpret two seemingly unrelated elements.
As an example, let’s say we have an element called .tooltip
that we’ve set up as a target element anchored to another element called .anchor
.
<div class="anchor">anchor</div>
<div class="toolip">toolip</div>
.anchor {
anchor-name: --my-anchor;
}
.toolip {
position: absolute;
position-anchor: --my-anchor;
position-area: top;
}
We need to set up a connection between the two elements in the DOM so that they share a context that assistive technologies can interpret and understand. The general rule of thumb for using ARIA attributes to describe elements is generally: don’t do it. Or at least avoid doing it unless you have no other semantic way of doing it.
This is one of those cases where it makes sense to reach for ARIA atributes. Before we do anything else, a screen reader currently sees the two elements next to one another without any remarking relationship. That’s a bummer for accessibility, but we can easily fix it using the corresponding ARIA attribute:
<div class="anchor" aria-describedby="tooltipInfo">anchor</div>
<div class="toolip" role="tooltip" id="tooltipInfo">toolip</div>
And now they are both visually and semantically linked together! If you’re new to ARIA attributes, you ought to check out Adam Silver’s “Why, How, and When to Use Semantic HTML and ARIA” for a great introduction.
Browser support
This browser support data is from Caniuse, which has more detail. A number indicates that browser supports the feature at that version and up.
Desktop
Chrome | Firefox | IE | Edge | Safari |
---|---|---|---|---|
125 | No | No | 125 | No |
Mobile / Tablet
Android Chrome | Android Firefox | Android | iOS Safari |
---|---|---|---|
129 | No | 129 | No |
Spec changes
CSS Anchor Positioning has undergone several changes since it was introduced as an Editor’s Draft. The Chrome browser team was quick to hop on board and implement anchor positioning even though the feature was still being defined. That’s caused confusion because Chromium-based browsers implemented some pieces of anchor positioning while the specification was being actively edited.
We are going to outline specific cases for you where browsers had to update their implementations in response to spec changes. It’s a bit confusing, but as of Chrome 129+, this is the stuff that was shipped but changed:
position-area
The inset-area
property was renamed to position-area
(#10209), but it will be supported until Chrome 131.
.target {
/* from */
inset-area: top right;
/* to */
position-area: top right;
}
position-try-fallbacks
The position-try-options
was renamed to position-try-fallbacks
(#10395).
.target {
/* from */
position-try-options: flip-block, --smaller-target;
/* to */
position-try-fallbacks: flip-block, --smaller-target;
}
inset-area()
The inset-area()
wrapper function doesn’t exist anymore for the position-try-fallbacks
(#10320), you can just write the values without the wrapper:
.target {
/* from */
position-try-options: inset-area(top left);
/* to */
position-try-fallbacks: top left;
}
anchor(center)
In the beginning, if we wanted to center a target from the center, we would have to write this convoluted syntax:
.target {
--center: anchor(--x 50%);
--half-distance: min(abs(0% - var(--center)), abs(100% - var(--center)));
left: calc(var(--center) - var(--half-distance));
right: calc(var(--center) - var(--half-distance));
}
The CWSSG working group resolved (#8979) to add the anchor(center)
argument to prevent us from having to do all that mental juggling:
.target {
left: anchor(center);
}
Known bugs
Yes, there are some bugs with CSS Anchor Positioning, at least at the time this guide is being written. For example, the specification says that if an element doesn’t have a default anchor element, then the position-area
does nothing. This is a known issue (#10500), but it’s still possible to replicate.
So, the following code…
.container {
position: relative;
}
.element {
position: absolute;
position-area: center;
margin: auto;
}
…will center the .element
inside its container, at least in Chrome:
Credit to Afif13 for that great demo!
Another example involves the position-visibility
property. If your anchor element is out of sight or off-screen, you typically want the target element to be hidden as well. The specification says that property’s the default value is anchors-visible
, but browsers default to always
instead.
The current implemenation in Chrome isn’t reflecting the spec; it indeed is using
always
as the initial value. But the spec is intentional: if your anchor is off-screen or otherwise scrolled off, you usually want it to hide. (#10425)
Almanac references
Anchor position properties
Almanac
on
Sep 17, 2024
anchor-name
Almanac
on
Sep 12, 2024
position-anchor
Almanac
on
Sep 8, 2024
position-area
Almanac
on
Sep 14, 2024
position-try-fallbacks
.target { position-try-fallbacks: flip-inline, bottom left; }
Almanac
on
Sep 8, 2024
position-try-order
Almanac
on
Sep 8, 2024
position-visibility
Anchor position functions
Almanac
on
Sep 14, 2024
anchor()
Almanac
on
Sep 18, 2024
anchor-size()
Anchor position at-rules
Almanac
on
Sep 28, 2024
@position-try
Further reading
- “CSS Anchor Positioning” (CSSWG)
- “Using CSS anchor positioning” (MDN)
- “Introducing the CSS anchor positioning API” (Una Kravets)
CSS Anchor Positioning Guide originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
Interview With Björn Ottosson, Creator Of The Oklab Color Space
Oklab is a new perceptual color space supported in all major browsers created by the Swedish engineer Björn Ottosson. In this interview, Philip Jägenstedt explores how and why Björn created Oklab and how it spread across the ecosystem.
Note: The original interview was conducted in Swedish and is available to watch.
About Björn
Philip Jägenstedt: Tell me a little about yourself, Björn.
Björn Ottosson: I worked for many years in the game industry on game engines and games like FIFA, Battlefield, and Need for Speed. I’ve always been interested in technology and its interaction with the arts. I’m an engineer, but I’ve always held both of these interests.
On Working With Color
Philip: For someone who hasn’t dug into colors much, what’s so hard about working with them?
Björn: Intuitively, colors can seem quite simple. A color can be lighter or darker, it can be more blue or more green, and so on. Everyone with typical color vision has a fairly similar experience of color, and this can be modeled.
However, the way we manipulate colors in software usually doesn’t align with human perception of colors. The most common color space is sRGB. There’s also HSL, which is common for choosing colors, but it’s also based on sRGB.
One problem with sRGB is that in a gradient between blue and white, it becomes a bit purple in the middle of the transition. That’s because sRGB really isn’t created to mimic how the eye sees colors; rather, it is based on how CRT monitors work. That means it works with certain frequencies of red, green, and blue, and also the non-linear coding called gamma. It’s a miracle it works as well as it does, but it’s not connected to color perception. When using those tools, you sometimes get surprising results, like purple in the gradient.
On Color Perception
Philip: How do humans perceive color?
Björn: When light enters the eye and hits the retina, it’s processed in many layers of neurons and creates a mental impression. It’s unlikely that the process would be simple and linear, and it’s not. But incredibly enough, most people still perceive colors similarly.
People have been trying to understand colors and have created color wheels and similar visualizations for hundreds of years. During the 20th century, a lot of research and modeling went into color vision. For example, the CIE XYZ model is based on how sensitive our photoreceptor cells are to different frequencies of light. CIE XYZ is still a foundational color space on which all other color spaces are based.
There were also attempts to create simple models matching human perception based on XYZ, but as it turned out, it’s not possible to model all color vision that way. Perception of color is incredibly complex and depends, among other things, on whether it is dark or light in the room and the background color it is against. When you look at a photograph, it also depends on what you think the color of the light source is. The dress is a typical example of color vision being very context-dependent. It is almost impossible to model this perfectly.
Models that try to take all of this complexity into account are called color appearance models. Although they have many applications, they’re not that useful if you don’t know if the viewer is in a light or bright room or other viewing conditions.
The odd thing is that there’s a gap between the tools we typically use — such as sRGB and HSL — and the findings of this much older research. To an extent, this makes sense because when HSL was developed in the 1970s, we didn’t have much computing power, so it’s a fairly simple translation of RGB. However, not much has changed since then.
We have a lot more processing power now, but we’ve settled for fairly simple tools for handling colors in software.
Display technology has also improved. Many displays now have different RGB primaries, i.e., a redder red, greener green, or bluer blue. sRGB cannot reach all colors available on these displays. The new P3 color space can, but it’s very similar to sRGB, just a little wider.
On Creating Oklab
Philip: What, then, is Oklab, and how did you create it?
Björn: When working in the game industry, sometimes I wanted to do simple color manipulations like making a color darker or changing the hue. I researched existing color spaces and how good they are at these simple tasks and concluded that all of them are problematic in some way.
Many people know about CIE Lab. It’s quite close to human perception of color, but the handling of hue is not great. For example, a gradient between blue and white turns out purple in CIE Lab, similar to in sRGB. Some color spaces handle hue well but have other issues to consider.
When I left my job in gaming to pursue education and consulting, I had a bit of time to tackle this problem. Oklab is my attempt to find a better balance, something Lab-like but “okay”.
I based Oklab on two other color spaces, CIECAM16 and IPT. I used the lightness and saturation prediction from CIECAM16, which is a color appearance model, as a target. I actually wanted to use the datasets used to create CIECAM16, but I couldn’t find them.
IPT was designed to have better hue uniformity. In experiments, they asked people to match light and dark colors, saturated and unsaturated colors, which resulted in a dataset for which colors, subjectively, have the same hue. IPT has a few other issues but is the basis for hue in Oklab.
Using these three datasets, I set out to create a simple color space that would be “okay”. I used an approach quite similar to IPT but combined it with the lightness and saturation estimates from CIECAM16. The resulting Oklab still has good hue uniformity but also handles lightness and saturation well.
Philip: How about the name Oklab? Why is it just okay?
Björn: This is a bit tongue-in-cheek and some amount of humility.
For the tasks I had in mind, existing color spaces weren’t okay, and my goal was to make one that is. At the same time, it is possible to delve deeper. If a university had worked on this, they could have run studies with many participants. For a color space intended mainly for use on computer and phone screens, you could run studies in typical environments where they are used. It’s possible to go deeper.
Nevertheless, I took the datasets I could find and made the best of what I had. The objective was to make a very simple model that’s okay to use. And I think it is okay, and I couldn’t come up with anything better. I didn’t want to call it Björn Ottosson Lab or something like that, so I went with Oklab.
Philip: Does the name follow a tradition of calling things okay? I know there’s also a Quite OK Image format.
Björn: No, I didn’t follow any tradition here. Oklab was just the name I came up with.
On Oklab Adoption
Philip: I discovered Oklab when it suddenly appeared in all browsers. Things often move slowly on the web, but in this case, things moved very quickly. How did it happen?
Björn: I was surprised, too! I wrote a blog post and shared it on Twitter.
I have a lot of contacts in the gaming industry and some contacts in the Visual Effects (VFX) industry. I expected that people working with shaders or visual effects might try this out, and maybe it would be used in some games, perhaps as an effect for a smooth color transition.
But the blog post was spread much more widely than I thought. It was on Hacker News, and many people read it.
The code for Oklab is only 10 lines long, so many open-source libraries have adopted it. This all happened very quickly.
Chris Lilley from the W3C got in touch and asked me some questions about Oklab. We discussed it a bit, and I explained how it works and why I created it. He gave a presentation at a conference about it, and then he pushed for it to be added to CSS.
Photoshop also changed its gradients to use Oklab. All of this happened organically without me having to cheer it on.
On Okhsl
Philip: In another blog post, you introduced two other color spaces, Okhsv and Okhsl. You’ve already talked about HSL, so what is Okhsl?
Björn: When picking colors, HSL has a big advantage, which is that the parameter space is simple. Any value 0-360 for hue (H) together with any values 0-1 for saturation (S) and lightness (L) are valid combinations and result in different colors on screen. The geometry of HSL is a cylinder, and there’s no way to end up outside that cylinder accidentally.
By contrast, Oklab contains all physically possible colors, but there are combinations of values that don’t work where you reach colors that don’t exist. For example, starting from light and saturated yellow in Oklab and rotating the hue to blue, that blue color does not exist in sRGB; there are only darker and less saturated blues. That’s because sRGB in Oklab has a strange shape, so it’s easy to end up going outside it. This makes it difficult to select and manipulate colors with Oklab or Oklch.
Okhsl was an attempt at compromise. It maintains Oklab’s behavior for colors that are not very saturated, close to gray, and beyond that, stretches out to a cylinder that contains all of sRGB. Another way to put it is that the strange shape of sRGB in Oklab has been stretched into a cylinder with reasonably smooth transitions.
The result is similar to HSL, where all parameters can be changed independently without ending up outside sRGB. It also makes Okhsl more complicated than Oklab. There are unavoidable compromises to get something with the characteristics that HSL has.
Everything with color is about compromises. Color vision is so complex that it’s about making practical compromises.
This is an area where I wish there were more research. If I have a white background and want to pick some nice colors to put on it, then you can make a lot of assumptions. Okhsl solves many things, but is it possible to do even better?
On Color Compromises
Philip: Some people who have tried Oklab say there are too many dark shades. You changed that in Okhsl with a new lightness estimate.
Björn: This is because Oklab is exposure invariant and doesn’t account for viewing conditions, such as the background color. On the web, there’s usually a white background, which makes it harder to see the difference between black and other dark colors. But if you look at the same gradient on a black background, the difference is more apparent.
CIE Lab handles this, and I tried to handle it in Okhsl, too. So, gradients in Okhsl look better on a white background, but there will be other issues on a black background. It’s always a compromise.
And, Finally…
Philip: Final question: What’s your favorite color?
Björn: I would have to say Burgundy. Burgundy, dark greens, and navy blues are favorites.
Philip: Thank you for your time, Björn. I hope our readers have learned something, and I’ll remind them of your excellent blog, where you go into more depth about Oklab and Okhsl.
Björn: Thank you!