Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Selectors, values, and at-rules

Selectors

Matching is delegated to the selectors crate from Servo, so CSS3 selectors mostly work as they are.

SelectorsSupportedNotes
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-classesSupportedNotes
: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
:host, :host()⚠️Parses but never matches, because there are no shadow trees. This keeps the other items in a selector list from being dropped: Tailwind v4 writes its theme variables on :root, :host and its preflight on html, :host. :host-context() is a parse error

A selector containing an unsupported pseudo-element, such as ::first-line, causes the whole rule to be dropped. Ones that parse, such as :hover and ::placeholder, 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-elementsSupportedNotes
::before, ::after⚠️Each generates a box of its own, as the first and last child of the element. Declarations on the pseudo-element (font-size, color, display, margin, position, transform, and so on) apply, and what they leave out is inherited from the element. They are generated on an element with block children too, and display: none generates nothing. counter-reset and counter-increment on the pseudo-element itself are not applied. Nothing is generated on a table (display: table)
::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
::backdrop, ::file-selector-button, ::placeholder, ::selection, ::marker⚠️Parses but never matches. A static PDF has no top layer, file inputs, empty form fields, or selections, and ::marker has no styles of its own, since list markers come from list-style-*. This keeps the other items in a selector list from being dropped, such as *, ::after, ::before, ::backdrop, ::file-selector-button { ... } in the Tailwind v4 preflight
Vendor-prefixed ones such as ::-webkit-input-placeholder❌A parse error

Values, units, and functions

Lengths

UnitSupported
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 ✅.

CSS-wide keywords

KeywordSupportedNotes
inherit, initial, unset✅Usable on every property, shorthands included. inherit takes the parent’s computed value as it is (an em length is the value already resolved on the parent). A var() fallback can supply one too (var(--x, inherit)). On the content of ::before and ::after every keyword gives normal (no box is generated), and on counter-reset and counter-increment it only cancels the earlier declarations. Ignored inside @page and on ::first-letter
revert, revert-layer❌The whole declaration is ignored

Function

FunctionSupportedNotes
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) cascade per element like any other property (specificity, source order, !important, the style attribute). Unregistered ones and those registered by @property with inherits: true are inherited; those with inherits: false start from the registered initial-value on each element. var() is substituted with the element’s custom properties at computed-value time, and the result is then parsed. Fallbacks such as var(--x, 10px) (nestable), references between custom properties (a cycle is invalid), and initial, inherit, and unset work. A registered custom property that cannot be resolved is treated as unset. @page (size, margins, margin boxes) has no element, so it is resolved against :root (<html>), and registered initial values can be referenced there too. Custom properties written inside @page are not visible. content of ::before/::after uses the element’s values. !important on ordinary properties (color: var(--c) !important) is not supported
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

NotationSupported
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, lch and oklch
  • Hue interpolation: shorter (the default), longer, increasing and decreasing
  • It can be nested (up to 16 levels)

The following are not supported; using one drops that declaration alone.

  • display-p3, a98-rgb, prophoto-rgb and rec2020. The output is PDF DeviceRGB, so accepting them would only round the result back to sRGB and the request would not mean what it says
  • currentcolor as an operand. currentcolor is resolved after the cascade, once the element’s own color is known, whereas the mixing happens at parse time, so the value is not available yet
  • xyz-d50 (it would need a white point conversion)

At-rules

At-rulesSupportedNotes
@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), in streaming mode as well
@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
@property⚠️syntax, inherits, and initial-value are read; initial-value may be omitted with syntax: "*". An invalid rule is ignored as a whole, and among rules with the same name the last valid one in document order wins. It also works inside applicable @media and @layer. syntax is accepted, but everything is currently treated as "*", so there is no type checking and em and the like are not computed on the declaring element
@supports⚠️The condition is evaluated, and when it is true the rules inside the block are hoisted to the top level, as with @media. (property: value) is true when the declaration parser accepts it (with var() in the value, when the property is a known one; !important is allowed), and selector(...) is true when the selector parses. They combine with not, and, or and parentheses (and and or cannot be mixed without parentheses). Unknown functions such as font-tech() and font-format() are false. It nests with @media and @layer
@keyframes, @namespace, @counter-style, @container❌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-child also 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 returns EngineError::UnsupportedInStreamingMode. Keep all <style> in <head>, so that the layout does not quietly fall apart
  • position: absolute and fixed are ignored
  • counter(pages), the total page count, is supported by drawing it when the PDF is completed