Selectors, values, and at-rules
- Selectors
- Pseudo-classes
- Pseudo-elements
- Values, units, and functions
- Colours
- At-rules
- Restrictions specific to streaming mode
Selectors
Matching is delegated to the selectors crate from Servo, so CSS3 selectors mostly work as they are.
| Selectors | Supported | Notes |
|---|---|---|
Type (p) and universal (*) | ✅ | |
Class (.foo) and ID (#foo) | ✅ | |
Attribute ([a], [a=v], [a^=v], [a$=v], [a*=v], [a~=v], [a|=v]) | ✅ | The case-insensitive i flag works too |
Descendant (whitespace) and child (>) | ✅ | |
Adjacent sibling (+) and general sibling (~) | ✅ | |
Selector lists (,) | ✅ | |
Nesting (.a { .b { } }, & .b, &.b, > .b) | ✅ | CSS Nesting. A selector without & is treated as & .b, and one starting with a combinator as & > .b. & is replaced by :is(parent), so the specificity becomes that of the most specific parent selector. Declarations written after a nested rule join the cascade after that rule. Nested at-rules such as @media are not supported (the whole block is ignored) |
Namespaces (ns|E) | ❌ | @namespace itself is not supported |
Pseudo-classes
| Pseudo-classes | Supported | Notes |
|---|---|---|
:root | ✅ | |
:first-child, :last-child, :only-child | ✅ | |
:nth-child(), :nth-last-child() | ✅ | In streaming mode :nth-last-child() gives a different result; see below |
:first-of-type, :last-of-type, :only-of-type, :nth-of-type(), :nth-last-of-type() | ✅ | In streaming mode every one of these except :first-of-type and :nth-of-type() gives a different result; see below |
:empty | ✅ | |
:not() | ✅ | |
:is(), :where() | ✅ | The argument list is forgiving: an unsupported selector among the arguments drops only that argument. For specificity, :is() counts as its most specific argument and :where() always counts as zero |
:has() | ✅ | Descendant, >, + and ~ may all be used. Specificity follows the same rule as :is(). Nesting (a :has() inside a :has()) and pseudo-elements are not allowed, per the spec. In streaming mode :has(~ ...) never matches (see below) |
:hover, :active, :focus, :focus-within, :focus-visible, :target, :enabled, :disabled, :checked, :visited | ⚠️ | They parse but never match, since a static PDF has no interaction state for them to describe |
:link, :any-link | ✅ | Matches an <a> that has an href |
A selector containing an unsupported pseudo-element, such as ::first-line, causes the whole rule to be dropped. Ones that parse, such as :hover, survive as rules but never match. The argument list of :is() and :where() is the one exception: an unsupported argument is dropped on its own and the rule survives.
Pseudo-elements
| Pseudo-elements | Supported | Notes |
|---|---|---|
::before, ::after | ⚠️ | Generated text through content only. It is drawn with the computed style of the host element and has no box style of its own, so margin, padding, display, and the like do not apply. Nothing is generated on an element that has block children |
::first-letter | ⚠️ | Only font-family, font-size, font-weight, font-style, color, text-decoration-line, and text-transform can be overridden; float and the box model properties are not supported |
::first-line | ❌ | A parse error |
::marker, ::selection, ::placeholder | ❌ | A parse error |
Values, units, and functions
Lengths
| Unit | Supported |
|---|---|
px, em, rem | ✅ |
mm, cm, in, pt, pc, Q | ✅ |
%, on properties that take a percentage | ✅ |
A bare 0 | ✅ |
ex, ch, vw, vh, vmin, vmax, lh | ❌ |
Absolute units are read with one inch as 96px, so 10mm is 37.795px. Physical dimensions can be written directly for print, which means @page { size: 210mm 297mm; margin: 15mm; } works as written.
As an exception, size in @page also accepts page size keywords such as a4 and letter, and landscape or portrait.
Angles, used only by transform
deg, rad, grad, and turn are supported, as is a bare 0 ✅.
Function
| Function | Supported | Notes |
|---|---|---|
calc() | ⚠️ | +, -, *, /, and nesting with parentheses or calc(). The terms may be lengths, whether absolute units or em and rem, percentages, and numbers. It works on any property that takes a length or a percentage |
min(), max(), clamp() | ❌ | |
var() | ⚠️ | Custom properties (--foo) are resolved by text substitution before parsing. Fallbacks such as var(--x, 10px) and references between custom properties work. Unlike the specification, this does not follow the cascade or inheritance; it is a simple resolution in which the last declaration in the document wins |
url() | ✅ | In background-image, list-style-image, the src of @font-face, and @import. Relative URLs are resolved against <base href> or the location of the input |
attr() | ⚠️ | Only inside content |
counter(), counters() | ✅ | Inside content. The style in the second argument comes from the list-style-type values |
Gradients such as linear-gradient() | ❌ | |
env(), image-set(), element() | ❌ |
Colours
| Notation | Supported |
|---|---|
Named colours, the CSS colour keywords such as red | ✅ |
#rgb, #rgba, #rrggbb, #rrggbbaa | ✅ |
rgb(), rgba(), both comma separated and space separated | ✅ |
hsl(), hsla(), hwb() | ✅ |
lab(), lch(), oklab(), oklch() | ✅ (converted to sRGB for drawing) |
currentcolor, transparent | ✅ |
color(), such as color(display-p3 ...) | ❌ |
color-mix() | ⚠️ (see below) |
The relative colour syntax rgb(from ...) | ❌ |
Colours with alpha are drawn through a PDF ExtGState, for both fills and backgrounds.
color-mix()
color-mix(in <colorspace> [<hue-interpolation-method> hue]?, <color> <percentage>?, <color> <percentage>?) is supported. Weights are normalised as the spec requires — over 100% only the ratio matters, under 100% the result becomes transparent by the shortfall — and alpha is premultiplied before interpolating.
- Colour spaces:
srgb,srgb-linear,lab,oklab,xyz(xyz-d65),hsl,hwb,lchandoklch - Hue interpolation:
shorter(the default),longer,increasinganddecreasing - It can be nested (up to 16 levels)
The following are not supported; using one drops that declaration alone.
display-p3,a98-rgb,prophoto-rgbandrec2020. The output is PDF DeviceRGB, so accepting them would only round the result back to sRGB and the request would not mean what it sayscurrentcoloras an operand.currentcoloris resolved after the cascade, once the element’s owncoloris known, whereas the mixing happens at parse time, so the value is not available yetxyz-d50(it would need a white point conversion)
At-rules
| At-rules | Supported | Notes |
|---|---|---|
@media | ⚠️ | Only the media type is evaluated. A screen block, and any negation other than not screen, is ignored entirely, while print, all, and a missing type are applied. Feature queries such as (min-width: ...) are skipped without being evaluated, so the contents apply as long as the type matches |
@page | ⚠️ | size, taking a keyword, one or two <length> values, or landscape and portrait, plus the margin properties. The pseudo-classes :first, :left, and :right are supported on their own; named pages such as @page intro, :blank, and compound pseudo-classes such as :first:left are not |
The margin boxes inside @page | ⚠️ | All sixteen of them, @top-left-corner, @top-left, @top-center, @top-right, and so on. Only text from content is drawn; decoration such as background colours and borders is not supported. Page numbers can be printed with counter(page) and counter(pages), though counter(pages) is unavailable in streaming mode |
@font-face | ⚠️ | The font-family, src, unicode-range, font-weight, and font-style descriptors are supported, including local() and url() with format() or tech() inside src. Other descriptors such as font-display are ignored. Font files may be TTF or OTF only; WOFF and WOFF2 are not supported |
@import | ✅ | Nesting is supported to a depth of 16, beyond which the offending import alone is ignored, and cycles are detected. A media condition such as @import url(...) screen; is not evaluated; the file is always imported |
@charset | ❌ | The input is assumed to be UTF-8 |
@layer | ⚠️ | Rules inside the block are hoisted to the top level in source order (@layer a { .x {} } is the same as .x {}). Layer precedence is not implemented; the usual cascade (specificity, last wins) decides. The @layer a, b; ordering statement is ignored. A stylesheet that wraps its whole output in @layer, as Tailwind v4 does, can be passed as is |
@supports, @keyframes, @namespace, @counter-style, @container, @property | ❌ | The whole block is ignored |
Restrictions specific to streaming mode
In Mode::Batch the whole DOM is available, so none of this applies. The following holds only in Mode::Streaming.
A top-level element (a direct child of <body>) is treated as settled once its next sibling appears. Only selectors that need to know whether more elements of the same kind follow therefore give a different result, and only for those top-level elements — inside one of them the subtree is complete, so nothing changes.
:last-of-type,:only-of-type,:nth-last-child(),:nth-last-of-type()and:has(~ ...)match too much or miss altogether. Using any of them prints a warning:last-child,:empty, and:has()over descendants or the immediately next sibling, on the other hand, line up with what settling already guarantees, so they give the same result as batch mode+,~,:first-child,:nth-child(),:first-of-type,:nth-of-type()and:only-childalso give the same result as batch mode. In a document that uses any of them, a processed top-level element has only its descendants released, so the element itself stays visible as a sibling (what remains is one node per top-level element, so almost as much is still freed)- A
<style>tag after<body>starts is an error and returnsEngineError::UnsupportedInStreamingMode. Keep all<style>in<head>, so that the layout does not quietly fall apart position: absoluteandfixedare ignoredcounter(pages), the total page count, is unavailable