CSS ::marker pseudo-element in Chromium
- Introduction
- Implementing
list-style-type: <string>
::marker
parsing and computation- Using
::marker
styles - Support
content
in 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
content
in legacy ::marker
enabled 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
color
content
direction
text-combine-upright
unicode-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
::marker
selector, by running the cascade normally. - Otherwise,
LayoutListMarker
orLayoutNGListItem
would 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
LayoutListMarker
for normal markers. - Add
LayoutOutsideListMarker
for non-normal outside markers. - Add
LayoutInsideListMarker
for 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
ListMarker
to keep them updated.
LayoutInsideListMarker
- Used for inside markers in legacy layout.
- Based on
LayoutInline
, i.e. it’s an inline box. - Has children, uses
ListMarker
to keep them updated.
LayoutNGOutsideListMarker
- Used for outside markers in LayoutNG.
- Based on
LayoutNGBlockFlowMixin<LayoutBlockFlow>
, i.e. it’s a block container. - Has children, uses
ListMarker
to keep them updated.
LayoutNGInsideListMarker
- Used for inside markers in LayoutNG.
- Based on
LayoutInline
, i.e. it’s an inline box. - Has children, uses
ListMarker
to 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: pre
in outside markers, so you can only customizewhite-space
in inside ones. -
text-combine-upright
doesn’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-bidi
andline-height
don’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.