CSS ::marker pseudo-element in Chromium
- Introduction
- Implementing
list-style-type: <string> ::markerparsing and computation- Using
::markerstyles - Support
contentin LayoutNG - Default styles
- Some fun: 99.9% performance regression
- Developer tools
- LayoutNG markers as real pseudo-elements
- Legacy markers as real pseudo-elements
- Animations & transitions
counter(list-item)inside<ol>- Support
contentin legacy ::markerenabled by default- Allowing more properties
- Overview
Introduction
Did you know that CSS makes it possible to style list markers?
In the past, if you wanted to customize the bullets or numbers in a list,
you would probably have to hide the native markers with list-style: none,
and then add fake markers with ::before.
However, now you can just use the ::marker pseudo-element
in order to style the native markers directly!
If you are not familiar with it, I suggest reading these articles first:
- https://developer.mozilla.org/en-US/docs/Web/CSS/::marker
- https://web.dev/css-marker-pseudo-element/
In this post, I will explain the deep technical details of how I implemented
::marker in Chromium.
Thanks to Bloomberg for sponsoring Igalia to do it!
Implementing list-style-type: <string>
Before starting working on ::marker itself, I decided to add support for string values in list-style-type.
It seemed a quite useful feature for authors, and Firefox already shipped it in 2015.
Also, it’s like a more limited version of content in ::marker, so it was a good introduction.
It was relatively straight-forward to implement. I did it in a single patch, https://crrev.com/704563, which landed in Chromium 79. Then I also ported it into Webkit, it’s avilable since Safari Technology Preview 115.
<ol style="list-style-type: '★ '">
<li>Lorem</li>
<li>Ipsum</li>
</ol>

Parsing and computation
The interesting thing to mention is that list-style-type had been implemented with a keyword template,
so its value would be internally stored using an enum, and it would benefit from the parser fast path for keywords.
I didn’t want to lose that, so I followed the same approach as for display,
which also used to be a single-keyword property,
until Houdini extended its syntax with layout(<ident>).
Basically, I treated list-style-type as a partial keyword property.
This means that it keeps the parser fast path for keyword values,
but in case of failure it falls back to the normal parser, where I accepted a string value.
When a string is provided,
the internal list-style-type value is set to
a special EListStyleType::kString enum value,
and the string is stored in an extra ListStyleStringValue field.
Layout
From a layout point of view, I had to modify both LayoutNG and legacy code. LayoutNG is a new layout engine for Chromium that has been designed for the needs of modern scalable web applications. It was released in Chrome 77 for block and inline layout, but some CSS features like multi-column haven’t been implemented in LayoutNG yet, so they force Chromium to use the old legacy engine.
It was mostly a matter of tweaking
LayoutNGListItem (for LayoutNG) and LayoutListMarker (for legacy)
in order to retrieve the string from ListStyleStringValue when
the ListStyleType was EListStyleType::kString,
and making sure to update the marker when ListStyleStringValue changed.
Also, string values are somewhat special because they don’t have a suffix,
unlike numeric values that are suffixed with a dot and space (like 1. ),
or symbolic values that get a trailing space (like ◾ ).
It’s noteworthy that until this point, markers didn’t have to care about mixed bidi.
But now you can have things like list-style-type: "aال", that is: U+0061 a, U+0627 ا, U+0644 ل.
Note that ا is written before ل, but since they are arabic characters, ا appears at the right.
This is relevant because the marker is supposed to be isolated from the text in the list item,
so in LayoutNG I had to set unicode-bidi: isolate to inside markers.
It wasn’t necessary for outside markers since they are implemented as inline-blocks,
which are already isolated.
In legacy layout, markers don’t actually have their text as a child, it’s just a paint-time effect.
As such, no bidi reordering happens, and aال doesn’t render correctly:
<li style="list-style: 'aال - ' inside">text</li>
LayoutNG:
vs.
legacy: 
At that point I decided to leave it this way,
but this kind of problems in legacy layout would keep haunting me while implementing ::marker.
Keep reading to know the bloody details!
::marker parsing and computation
Here I started working on the actual ::marker pseudo-element.
As a first step, in https://crrev.com/709390
I recognized ::marker as a valid selector (behind a flag), added a usage counter,
and defined a new PseudoId::kPseudoIdMarker to identify it in the node tree.
It’s important to note that list markers were still anonymous boxes,
there was no actual ::marker pseudo-element,
so kPseudoIdMarker wasn’t actually used yet.
Something that needs to be taken into account when using ::marker
is that the layout model for outside positioning is not fully defined.
Therefore, in order to prevent authors from relying on implementation-defined behaviors
that may change in the future, the CSSWG decided to restrict which properties
can actually be used on ::marker.
I implemented this restriction in https://crrev.com/710995,
using a ValidPropertyFilter just like it was done for ::first-letter and ::cue.
But note this was later refactored,
and now whether a property applies to ::marker or not
is specified in the property definition in css_properties.json5.
At this point, ::marker only allowed:
- All font properties
- Custom properties
colorcontentdirectiontext-combine-uprightunicode-bidi
Using ::marker styles
At this point, ::marker was a valid selector,
but list markers weren’t using ::marker styles.
So in https://crrev.com/711883
I just took these styles and assigned them to the markers.
This simple patch was the real deal,
making Chromium’s implementation of ::marker match WebKit’s one,
which shipped in 2017.
When enabling the runtime flag, you could style markers:
<style>
::marker {
color: green;
font-weight: bold;
}
</style>
<ol>
<li>Lorem</li>
<li>Ipsum</li>
</ol>

This landed in Chromium 80. So, how come I didn’t ship ::marker until 86?
The answer is that, while the basic functionality was working fine,
I wanted to provide a full and solid implementation.
And it was not yet the case, since content was not working,
and markers were still anonymous boxes that just happened to get assigned
the styles for ::marker pseudo-elements,
but there were no actual ::marker pseudo-elements.
Support content in LayoutNG
Adding support for the content property was relatively easy in LayoutNG,
since I could reuse the existing logic for ::before and ::after.
Roughly it was a matter of ignoring list-style-type and list-style-image
in non-normal cases, and using the LayoutObject of the ContentData
as the children. This was not possible in legacy, since LayoutListMarker
can’t have children.
It may be worth it to summarize the different LayoutObject classes for list markers:
LayoutListMarker, based onLayoutBox, for legacy markers.LayoutNGListMarker, based onLayoutNGBlockFlowMixin<LayoutBlockFlow>, for LayoutNG markers with an outside position.LayoutNGInsideListMarker, based onLayoutInline, for LayoutNG markers with an inside position.
It’s important to note that non-normal markers were actual pseudo-elements,
their LayoutNGListMarker or LayoutNGInsideListMarker were no longer anonymous,
they had an originating PseudoElement in the node tree.
This means that I had to add logic for attaching, dettaching and rebuilding
kPseudoIdMarker pseudo-elements, add LayoutObjectFactory::CreateListMarker(),
and make LayoutTreeBuilderTraversal and Node::PseudoAware* methods be
aware of ::marker.
Most of it was done in https://crrev.com/718609.
Another problem that I had to address was that, until this point,
both content: normal and content: none were considered to be synonymous,
and were internally stored as nullptr.
However, unlike in ::before and ::after,
normal and none have different behaviors in ::marker:
the former decides the contents from the list-style properties,
the latter prevents the ::marker from generating boxes.
Therefore, in https://crrev.com/732549
I implemented content: none as a NoneContentData,
and replaced the HasContent() helper function with the more specific
ContentBehavesAsNormal() and ContentPreventsBoxGeneration().
Default styles
According to the spec, markers needed to get assigned these styles in UA origin:
unicode-bidi: isolate;
font-variant-numeric: tabular-nums;
At this point, the ComputedStyle for a marker could be created in
different ways:
- If there was some
::markerselector, by running the cascade normally. - Otherwise,
LayoutListMarkerorLayoutNGListItemwould create the style from scratch.
First, in https://crrev.com/720875
I made all StyleResolver::PseudoStyleForElementInternal,
LayoutListMarker::ListItemStyleDidChange and LayoutNGListItem::UpdateMarker
set these UA rules.
Then in https://crrev.com/725913
I made it so that markers would always run the cascade,
unifying the logic in PseudoStyleForElementInternal.
But this way of injecting the UA styles was a bit hacky and problematic.
So finally, in https://crrev.com/779284
I implemented it in the proper way, using a real UA stylesheet.
However, I took care of preventing that from triggering SetHasPseudoElementStyle,
which would have defeated some optimizations.
Interestingly, these UA styles use a ::marker selector, but they also affect
nested ::before::marker and ::after::marker pseudo-elements.
That’s because I took advantage of a bug in the style resolver,
so that I wouldn’t have to implement the nested ::marker selectors.
The bug is disabled for non-UA styles.
LayoutNGListItem::UpdateMarker also had some style tweaks that I moved into
the style adjuster instead of to the UA sheet,
because the exact styles depend on the marker:
- Outside markers get
display: inline-block, because they must be block containers. - Outside markers get
white-space: pre, to prevent their trailing space from being trimmed. - Inside markers get some margins, depending on
list-style-type.
I did that in https://crrev.com/725091 and https://crrev.com/728176.
Some fun: 99.9% performance regression
An implication of my work on the default marker styles was that
the StyleType() became kPseudoIdMarker instead of kPseudoIdNone.
This made LayoutObject::PropagateStyleToAnonymousChildren() do more work,
causing the flexbox_with_list_item perf test to worsen by a 99.9%!

I fixed it in https://crrev.com/722421
by returning early for markers with content: normal,
which didn’t need that work anyways.
Once I completed the ::marker implementation, I tried reverting the fix,
and then the test only worsened by a 2-3%.
So I guess the big regression was caused by the interaction of multiple factors,
and the other factors were later fixed or avoided.
Developer tools
It was important for me to expose ::marker in the devtools
just like a ::before or ::after.
Not just because I thought it would be beneficial for authors,
but also because it helped me a lot when implementing ::marker.
So first I made the Styles panel expose the ::marker styles
when inspecting the originating list item (https://crrev.com/724094).

And then I made ::marker pseudo-elements inspectable
in the Elements panel (https://crrev.com/724122 and https://crrev.com/c/1934220).

However, note this only worked for actual ::marker pseudo-elements.
LayoutNG markers as real pseudo-elements
As previously stated, only non-normal markers were internally implemented
as actual pseudo-elements, markers with content: normal were just
annymous boxes.
So normal markers wouldn’t appear in devtools, and would yield
incorrect values in getComputedStyle:
getComputedStyle(listItem, "::marker").width; // "auto"
According to CSSOM
that’s supposed to be the used width in pixels,
but since there was no actual ::marker pseudo-element,
it would just return the computed value: auto.
So in https://crrev.com/731964 I implemented LayoutNG normal markers as real pseudo-elements. It’s a big patch, though mostly that’s because I had to update several test expectations.
Another advantage was that non-normal markers benefited from the much vaster test coverage for normal ones. For example, some accessibility code was expecting markers to be anonymous, I noticed this thanks to existing tests with normal markers. Without this change I might have missed that non-normal ones weren’t handled properly.
And a nice side-effect that I wasn’t expecting was that the flexbox_with_list_item perf test improved by a 30-40%. Nice!
It’s worth noting that until this point,
pseudo-elements could only be originated by an element.
However, ::before and ::after pseudo-elements can have display: list-item
and thus have a nested marker.
Due to the lack of support for ::before::marker and ::after::marker selectors,
I could previously assume that nested markers would have the initial content: normal,
and thus be anonymous.
But this was no longer the case, so in https://crrev.com/730531
I added support for nested pseudo-elements.
However, the style resolver is still not able to handle them properly,
so nested selectors don’t work.
A consequence of implementing LayoutNG markers as pseudo-elements
was that they became independent,
they were no longer created and destroyed by LayoutNGListItem.
But the common logic for LayoutNGListMarker and LayoutNGInsideListMarker
was still in LayoutNGListItem, so this made it difficult to keep the
marker information in sync.
Therefore, in https://crrev.com/735167
I moved the common logic into a new ListMarker class,
and each LayoutNG marker class would own a ListMarker instance.
I also renamed LayoutNGListMarker to LayoutNGOutsideListMarker,
since the old name was misleading.
Legacy markers as real pseudo-elements
Since I had already added the changes needed to implement all LayoutNG markers as pseudo-elements, I thought that doing the same for legacy markers would be easier.
But I was wrong! The thing is that legacy layout already had some bugs affecting markers, but they would only be triggered when dynamically updating the styles of the list item. But there aren’t many tests that do that, so they went unnoticed… until I tried my patch, which surfaced these issues in the initial layout, making some test fail.
So first I had to fix bug 1048672, 1049633, and 1051114.
Then there was also bug 1051685,
involving selections or drag-and-drop with pseudo-elements like ::before or ::after.
So turning markers into pseudo-elements made them have the same problem,
causing a test failure.
I could finally land my patch in https://crrev.com/745012, which also improved performance like in LayoutNG.
Animations & transitions
While I was still working on ::marker,
the CSSWG decided to expand the list of allowed properties
in order to include animations and transitions.
I did so in https://crrev.com/753752.
The tricky part was that only allowed properties could be animated. For example,
<style>
@keyframes anim {
from { color: #c0c; background: #0cc }
to { color: #0cc; background: #c0c }
}
::marker {
animation: anim 1s infinite alternate;
}
</style>
<ol><li>Non-animated text</li></ol>

Only the color of the marker is animated, not the background.
counter(list-item) inside <ol>
::before and ::after pseudo-elements already had the bug that,
when referencing the list-item counter inside an <ol>,
they would produce the wrong number, usually 1 unit greater.
Of course, ::marker inherited the same problem.
And this was breaking one of the important use-cases,
which is being able to customize the marker text with content.
For example,
<style>
::marker { content: "[" counter(list-item) "] " }
</style>
<ol>
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ol>
would start counting from 2 instead of 1:

Luckily, WebKit had already fixed this problem, so I could copy their solution. Unluckily, they mixed it with a big irrelevant refactoring, so I had to spend some time understanding which part was the actual fix. I ported it into Chromium in https://crrev.com/783493.
Support content in legacy
The only missing thing to do was adding support for content in legacy layout.
The problem was that LayoutListMarker can’t have children,
so it’s not possible to just insert the layout object produced by the ContentData.
Then, my idea was replacing LayoutListMarker with two new classes:
LayoutOutsideListMarker, for markers with outside positioning.LayoutInsideListMarker, for markers with inside positioning.
and they could share the ListMarker logic with LayoutNG markers.
However, when I started working on this, something big happened: the COVID-19 pandemic.

And Google decided the skip Chromium 82 due to the situation, which is relevant because, in order to be able to merge patches easily, they wanted to avoid big refactorings.
And a big refactoring is precisely what I needed! So I had to wait until Chromium 83 reached stable.
Also, Google engineers were not convinced by my proposal,
because it would imply that legacy markers
would use more memory and would be slower,
since they would have children even with content: normal.
So I changed my strategy as such:
- Keep
LayoutListMarkerfor normal markers. - Add
LayoutOutsideListMarkerfor non-normal outside markers. - Add
LayoutInsideListMarkerfor non-normal inside markers.
This was done in this chain of CLs: 2109697, 2109771, 2110630, 2246514, 2252244, 2252041, 2252189, 2246573, 2258732.
::marker enabled by default
Finally the ::marker implementation was complete!
To summarize, list markers ended up implemented among 5 different layout classes:
LayoutListMarker- Used for normal markers in legacy layout.
- Based on
LayoutBox. - Can’t have children, doesn’t use
ListMarker.
LayoutOutsideListMarker- Used for outside markers in legacy layout.
- Based on
LayoutBlockFlow, i.e. it’s a block container. - Has children, uses
ListMarkerto keep them updated.
LayoutInsideListMarker- Used for inside markers in legacy layout.
- Based on
LayoutInline, i.e. it’s an inline box. - Has children, uses
ListMarkerto keep them updated.
LayoutNGOutsideListMarker- Used for outside markers in LayoutNG.
- Based on
LayoutNGBlockFlowMixin<LayoutBlockFlow>, i.e. it’s a block container. - Has children, uses
ListMarkerto keep them updated.
LayoutNGInsideListMarker- Used for inside markers in LayoutNG.
- Based on
LayoutInline, i.e. it’s an inline box. - Has children, uses
ListMarkerto keep them updated.
So at this point I just landed https://crrev.com/786976
to enable ::marker by default. This happened in Chromium 86.0.4198.0.
Allowing more properties
After shipping ::marker, I continued doing small tweaks
in order to align the behavior with more recent CSSWG resolutions.
The first one was that, if you set text-transform on a list item or ancestor,
the ::marker shouldn’t inherit it by default. For example,
<ol style="list-style-type: lower-alpha; text-transform: uppercase">
<li>foo</li>
</ol>
should have a lowercase a, not A:

Therefore, in https://crrev.com/791815
I added text-tranform: none to the ::marker UA rules,
but also allowed authors to specify another value if they want so.
Then, the CSSWG also resolved that ::marker should allow
inherited properties that apply to text which don’t depend on box geometry.
And other properties, unless whitelisted, shouldn’t affect markers,
even when inherited from an ancestor.
Therefore, I added support for some text and text decoration properties,
and also for line-height.
On the other hand, I blocked inheritance of text-indent and text-align.
That was done in CLs 791815, 2382750, 2388384, 2391242, 2396125, 2438413.
The outcome was that, in Chromium, ::marker accepts these properties:
-
Animation properties:
animation-delay,animation-direction,animation-duration,animation-fill-mode,animation-iteration-count,animation-name,animation-play-state,animation-timeline,animation-timing-function -
Transition properties:
transition-delay,transition-duration,transition-property,transition-timing-function -
Font properties:
font-family,font-kerning,font-optical-sizing,font-size,font-size-adjust,font-stretch,font-style,font-variant-ligatures,font-variant-caps,font-variant-east-asian,font-variant-numeric,font-weight,font-feature-settings,font-variation-settings, -
Text properties:
hyphens,letter-spacing,line-break,overflow-wrap,tab-size,text-transform,white-space,word-break,word-spacing -
Text decoration properties:
text-decoration-skip-ink,text-shadow,-webkit-text-emphasis-color,-webkit-text-emphasis-position,-webkit-text-emphasis-style -
Writing mode properties:
direction,text-combine-upright,unicode-bidi -
Others:
color,content,line-height
However, note that they may end up not having the desired effect in some cases:
-
The style adjuster forces
white-space: prein outside markers, so you can only customizewhite-spacein inside ones. -
text-combine-uprightdoesn’t work in pseudo-elements (bug 1060007). So setting it will only affect the computed style, and will also force legacy layout, but it won’t turn the marker text upright. -
In legacy layout, the marker has no actual contents. So text properties, text decoration properties,
unicode-bidiandline-heightdon’t work.
And this is the default UA stylesheet for markers:
::marker {
unicode-bidi: isolate;
font-variant-numeric: tabular-nums;
text-transform: none;
text-indent: 0 !important;
text-align: start !important;
text-align-last: start !important;
}
The final change, in https://crrev.com/837424,
was the removal of the CSSMarkerPseudoElement runtime flag.
Since 89.0.4358.0, it’s no longer possible to disable ::marker.
Overview
Implementing ::marker needed more than 100 patches in total,
several refactorings, some existing bug fixes, and various CSSWG resolutions.
I also added lots of new WPT tests, additionally to the existing ones created by Apple and Mozilla. For every patch that had an observable improved behavior, I tried to cover it with a test. Most of them are in https://wpt.fyi/results/css/css-pseudo?q=marker, though some are in css-lists, and others are Chromium-internal since they were testing non-standard behavior.
Note my work didn’t include ::before::marker and ::after::marker selectors,
which haven’t been implemented in WebKit nor Firefox either.
What remains to be done is making the selector parser handle nested pseudo-elements properly.
Also, I kept the disclosure triangle of a <summary> as a ::-webkit-details-marker,
but since Chromium 89 it’s a ::marker as expected,
thanks to Kent Tamura.