Write robust, smaller, and more efficient CSS polyfills.
Author(s): Bramus
- Status of this document
- Intro
- How to (try to) polyfill CSS today
- Proposed Solution
- Practical
- Registering CSS Functions
- Registering a keyword
- Example: the random keyword
- Registering a syntax
- Registering CSS Properties
- Case Studies
This document is intended as a starting point for engaging the community and standards bodies in developing collaborative solutions fit for standardization. As the solutions to problems described in this document progress along the standards-track, we will retain this document as an archive and use this section to keep the community up-to-date with the most current standards venue and content location of future work and discussions.
- This document status: Inception, Active
- Expected venue: CSSWG
- Current version: this document
A common type of feedback heard from authors on new CSS features is that they won’t use it because said feature does not have cross-browser support. This feedback is heard for all sorts of features, including those that can be used as a Progressive Enhancement. Within their organization there are often still expectations that websites need to look exactly the same in every browser. As a result, the adoption of new CSS features – PE-capable or not – is blocked until the feature is Baseline Widely available. Assuming an average time-to-interop of ±1.5 years, this means a CSS feature is only ready for adoption after 4 years.

Figure: Screenshot of a typical comment on a new CSS feature, sourced from a video by Theo on the CSS if() function.
To aid in the adoption of new CSS features, polyfills can be created. The polyfill for container queries for example has proven its worth. However, this polyfill – like any other CSS polyfill – is not perfect and comes with limitations because polyfilling CSS features comes with a lot of challenges and pitfalls. Above that, ±65% of the code is dedicated to purely parsing the CSS file and extracting the necessary information (property values and container at-rules) from it.
CSS Parser Extensions aims to remove these limitations and to ease this information gathering by allowing authors to extend the CSS Parser with new syntaxes, properties, keywords, etc. for it to support. By tapping directly into the CSS parser, CSS polyfills become easier to author, have a reduced size & performance footprint, and become more robust.
The problem is clearly stated in the talk The Dark Side of Polyfilling CSS by Philip Walton. It is recommended to watch this presentation to get a good understanding of the problem. Below is an abbreviated and less-detailed version of the problem statement.
When authors create a polyfill for a CSS feature, they can’t rely on the CSS parser giving them the information about, for example, the declarations they want to polyfill. This is because the CSS Parser throws away rules and declarations it couldn’t successfully parse. Therefore, polyfills need to gather and re-process the stylesheets themselves in order to get the tokens for the feature that they want to polyfill.
While this looks as simple as performing these 3 steps, it’s more complicated than it looks.
- Gathering all styles
- Parsing the styles
- Applying the styles
Each step has its own challenges and limitations, detailed below, and nicely summarized by this quote by Philip Walton from 2016:
“If you’ve never tried writing a CSS polyfill yourself, then you’ve probably never experienced the pain”
– Philip Walton
Collecting all styles in itself already is challenging, as authors need to gather these from a various sources:
document.styleSheetsdocument.adoptedStyleSheets- Element attached styles
After collecting all references to these stylesheets it’s not done, as authors also need to keep an eye out for mutations in any of those sources.
With all style sheets in hand, authors can then continue to parse the contents of the style sheets. This sounds like a no-brainer but it already comes with challenges as in many cases they can’t access contents of stylesheet being served from a CORS-protected origin.
In case they do have access to the style sheet’s contents, authors need to manually tokenize and parse the contents, duplicating work that was already done by the UA.
The custom CSS parser they let loose on the source code must also work with the entire CSS Syntax. For example, when a UA ships a feature like CSS nesting, the polyfill’s CSS parser also needs to support it. As a result, CSS parsers used in CSS polyfills constantly need to play catch-up to support the latest syntax.
With the styles parsed authors must then figure out which elements they need to apply things to. For declarations for example, this basically means that they need to write their own cascade. They also need to implement CSS features such as Media Queries and take those into account. And oh, there’s also the Shadow DOM which complicates things.
What if, instead of having polyfill authors to write their own parser and cascade, they could teach the parser some new tricks?
As in: give authors access to the CSS Parser so that they can extend it with new syntaxes, properties, keywords, and functions to support. Authors can use it to register various CSS aspects:
- CSS Properties
- CSS Syntaxes
- CSS Functions
- CSS Keywords
- @TODO: CSS At-Rules
- @TODO: more?
After registering one of these features using CSS Parser Extensions, the parser won’t discard the tokens associated with it and authors can use the feature as if the parser never discarded it.
For example, when registering an unsupported CSS Property + Syntax, the parser will keep the declaration and the property will show up in calls like window.getComputedStyle(). Feature checks using CSS.supports() / @supports() will then also pass.
Furthermore some utility functions are made available to authors to, for example, compute lengths of any type to the pixel value they represent.
@TODO: Explain that this doesn’t do custom function or custom selectors or custom properties registrations, which are already handled by the platform.
@TODO: Excluded are custom selectors. Explain why.
By allowing authors to extend the CSS Parser that ships with the UA, they no longer need to gather all styles, parse stylesheets themselves, or figure out when to apply styles to an element. The resulting polyfills will be easier to author, smaller in size, perform faster, and be more robust and efficient.
With robust CSS polyfills powered by CSS Parser Extensions available, the adoption of CSS features is no longer blocked on Baseline widely available cross-browser support, leading to an increased adoption rate.
In the UA, the CSSParser is exposed to authors as CSS.parser
- CSS Functions:
CSS.parser.registerFunction(…) - CSS Syntaxes:
CSS.parser.registerSyntax(…) - CSS Declarations:
CSS.parser.registerProperty(…) - CSS Keywords:
CSS.parser.registerKeyword(…) - @TODO: At-Rules (
@container,@keyframeswith range info, …) - @TODO: more?
- .substibuteAs(match)
- .onElementMatch(match)
- .onElementUnmatch(match)
- .computeTo()
- CSS Functions:
CSS.parser.supportsFunction(…) - CSS Syntaxes:
CSS.parser.supportsSyntax(…) - CSS Declarations:
CSS.parser.supportsProperty(…) - CSS Keywords:
CSS.parser.supportsKeyword(…) - CSS At-Rules:
CSS.parser.supportsAtRule(…)
Note: These methods should typically not be used as you most likely want registrations made with CSS Parser Extensions to override UA-defined things for compatibility reasons. That way, UAs can ship new features without breaking websites that use the polyfill.
(incomplete list)
- (w3c/csswg-drafts#10002)
CSS.parser.getSpecifiedStyle(HTMLElement elt, CSSOMString? pseudoElt)- To get to know the author specified
90vhinstead of the resolved pixel value.
- To get to know the author specified
CSS.utils.computeLength(CSSUnitValue value, HTMLElement? elt, string? context = 'width')- Context: to resolve percentages (e.g.
paddingis resolved against thewidth)
- Context: to resolve percentages (e.g.
CSS.utils.isAbsoluteLengthand friends.- See https://github.com/flackr/scroll-timeline/blob/master/src/numeric-values.js#L30-L86 for example
- But: can easily be polyfilled today but would require authors to play catch up when CSS gets a new unit.
CSS.parser.animation_typesdefines if a registered property is animatable or notDISCRETE= …NOT_ANIMATABLE= …BY_COMPUTED_VALUE= …REPEATABLE_LIST= …CUSTOM= …
CSS.parser.caching_modesdefines when a computation should happenNONE= The value is not cached and needs to be recomputed every time an invalidation happensPER_MATCH(default) = The computed value is cached for the remainder of the lifecycle of the page.PER_RULE= The computed value is cached for the rule that matched the element. I.e. two occurrences in the same rule have the same value.PER_ELEMENT= The computed value is cached for the element. I.e. multiple rules that use the same keyword will have the same value.GLOBAL= The computed value is computed once and the result is cached globally. All other or successive computations yield the same result
CSS.parser.invalidation_types(@TODO: refine this)NONE= never gets invalidatedPROPERTIES= list of other properties (~ dependencies)- E.g.
color-schemeinvalidates the computation oflight-dark()
- E.g.
STATE= State changes (IDL Attribute change) – e.g.DOM_MUTATION= A DOM Mutation (Content Attribute Change)- …
@TODO: Add some API surface to figure out if something was polyfilled or not. This is a vague idea … needs work
- E.g.
CSS.parser.registrations.properties.has('animation-range'); - E.g.
CSS.parser.registrations.functions.has('animation-range');
But: makes it too easy to conditionally register a polyfill … So should we drop it? But then again: authors can already easily conditionally load something through CSS.supports() … and this call could specifically know if something was polyfilled instead of supported (which can be native or polyfilled).
@TODO: Define all the interfaces (CSSElementMatchCollection, CSSElementMatch, CSSParserMatch, …)
@TODO: Timing of the registration: must be done upfront, or the parser will already have thrown away things. Not possible in a ServiceWorker, as you need access to the DOM and other things. Registrations need to happen using blocking JavaScript in the head, before any CSS is referenced.
@TODO: Timing of the execution: parsed value time? But: if you use a var() in a polyfilled property, that var() is only known at computed value time. Robert Flack suggest to model the timing of all this after ResizeObserver, so that it can schedule a new style+layout cycle.
@TODO: Double opt-in (and even scoped per stylesheet)? Tab Atkins-Bittner suggested this. But: authors want to just drop in the polyfill and be done with it.
CSS.parser
.registerFunction(
'light-dark(light:<color>, dark:<color>):<color>',
{
invalidation: ['color-scheme'],
}
)
.computeTo((match, args) => {
const { element, property, propertyValue } = match;
const colorScheme = CSS.parser.getSpecifiedStyle(element).getPropertyValue('color-scheme');
if (colorScheme == 'light') return args.light;
return args.dark;
})
);This polyfills the at-rule() function which authors can use to feature detect support for certain at-rules such as @property, @view-transition, @starting-style, etc. Only the simple form that checks for @keyword support is implemented.
CSS.parser
.registerFunction(
'at-rule(keyword:<string>):<boolean>',
{
caching_mode: CSS.parser.computation_modes.GLOBAL, // The values may be cached; as at-rule is global
}
)
.computeTo((match, args) => {
const { keyword } = args;
switch (keyword) {
case '@property':
return ("registerProperty" in CSS);
case '@view-transition':
return ("CSSViewTransitionRule" in window);
case '@starting-style':
return ("CSSStartingStyleRule" in window);
case '@layer':
return ("CSSLayerStatementRule" in self);
case '@page':
const stylesheet = new CSSStyleSheet();
stylesheet.replaceSync("@page {margin: 1cm;}");
const instance = stylesheet.cssRules.item(0);
return !!instance;
case '@container':
return ("CSSContainerRule" in self && "containerQuery" in CSSContainerRule.prototype);
// @TODO: Add more at-rules …
default:
return false;
}
})
;CSS.parser
.registerFunction(� 'randomBetween(min:<number> = 0, max:<number> = 1):<number>',
{
computation: CSS.parser.computation_modes.ONCE_PER_MATCH,
}
)
.computeTo((match, args) => {
const { element, property, specifiedValue } = match;
const { min, max } = args;�
return Math.floor(min + Math.random() * max);
})
;@TODO: What about allowing the value to change when a MQ matches?
@TODO: Write this. Don’t forget to add a MutationObserver invalidation.
CSS.parser
.registerKeyword('random:<number>', {
caching_mode: CSS.parser.caching_modes.PER_MATCH,
invalidation: CSS.parser.invalidation.NONE,
})
.computeTo((match) => {
return Math.random();
});
;CSS.parser.registerSyntax('<scroll-timeline-name>', '[ none | <dashed-ident> ]#');@TODO: This needs work…
CSS.parser.registerSyntax('<foo-bar>', function(str) {
const { value1, value2 } = parseFooBarThing(str);
return {
prop1: value1,
prop2: value2,
}
});CSS.parser.registerSyntax('<position-arg>', 'layout | visual | visual-unzoomed');
CSS.parser.registerProperty('position', {
extends: 'position', // Take the existing `position` property as the base
syntax: '<position> [/ arg:<position-arg>]',
});CSS.parser.registerProperty('scroll-timeline-name', {
syntax: '<scroll-timeline-name>', // References the registered syntax from earlier
initialValue: 'none',
inherits: false,
animatable: CSS.parser.ANIMATABLE_DISCRETE,
});CSS.parser
.registerProperty('size', {
syntax: '[<length-percentage [0,∞]> | auto]{1,2}',
initialValue: 'auto',
inherits: false,
percentages: 'inline-size'
animatable: CSS.parser.animation_types.BY_COMPUTED_VALUE,
})
.computeTo(match => {
const { element, specifiedValue } = match;
const compute = (value, element) => {
if (value == 'auto') {
// @TODO: Manual value computation for auto keyword
}
if (CSS.utils.isLength(value) || CSS.utils.isPercentage(value)) {
return CSS.utils.computeLength(value, element);
}
throw new Error('Cannot resolve length'); // = IACVT
}
if (specifiedValue[1]) {
return `${compute(specifiedValue[0], element)} ${compute(specifiedValue[1], element)}`;
}
return `${compute(specifiedValue[0], element)}`;
})
.onMatch((match, computedValue) => {
const { element, specifiedValue } = match;
return {
'width': computedValue[0],
'height': computedValue[1] ?? computedValue[0],
};
})
;This implements position: fixed / visual, as discussed in w3c/csswg-drafts#7475
@TODO: is .with() the correct way to prefilter matches?
// Register syntaxes used by the polyfill.
// No .computeTo … the computed value is the specified value
CSS.parser.registerSyntax('<position>', 'static | relative | absolute | sticky | fixed');
CSS.parser.registerSyntax('<position-arg>', 'layout | visual | visual-unzoomed');
// Extend the existing `position` property registration, only overriding certain parts.
// The non-overriden parts remain untouched
const positionWithArgRegistration = CSS.parser
.registerProperty('position', {
extends: 'position',
syntax: '<position> [/ arg:<position-arg>]?',
})
// No .computeTo … so the syntax will compute individually
;
const cssPositionFixed =
positionWithArgRegistration
.with('position', 'fixed') // Only `position: fixed`
.with('arg') // Any arg value
.onMatch((match) => {
const { element, specifiedValue } = match;
const { position, arg } = specifiedValue;
const styles = CSS.parser.getSpecifiedStyle(element);
const visualViewport = determineVisualViewport();
switch (arg) {
case 'layout':
return {
position: 'fixed',
};
case 'visual':
return {
position: 'fixed',
bottom: (() => {
if (styles.bottom.toString() != 'auto') {
return styles.bottom.add(CSS.px(visualViewport.height));
}
})(),
};
case 'visual-unzoomed':
return {
position: 'fixed',
// @TODO: change all other properties
};
}
})
;
window.visualViewport.addEventListener('resize', () => {
cssPositionFixed.triggerMatch();
});@TODO: This example is unfinished
@TODO: Replace .matchProperty with the result of the registerProperty call (?)
CSS.parser.registerProperty('scroll-timeline-name', {
syntax: '[ none | <dashed-ident> ]#',
initialValue: 'none',
inherits: false,
animatable: CSS.parser.ANIMATABLE_DISCRETE,
});
CSS.parser.registerProperty('scroll-timeline-axis', {
syntax: '[ block | inline | x | y ]#',
initialValue: 'block',
inherits: false,
animatable: CSS.parser.ANIMATABLE_DISCRETE,
});
CSS.parser.registerProperty('scroll-timeline', {
syntax: '[ name:<scroll-timeline-name> axis:<scroll-timeline-axis>? ]#',
shorthand: true,
});
const scrollTimelineMatches = CSS.parser
.matchProperty('scroll-timeline')
// No .computeTo … so it would just return the declared value
.onMatch(parserMatch => {
const resizeObserver = new ResizeObserver((entries) => {
// Change total size
});
resizeObserver.observe(parserMatch.element);
parserMatch.data.set('ro', resizeObserver);
})
.onElementUnmatch(parserMatch => {
const resizeObserver = parserMatch.data.get('ro');
resizeObserver.disconnect();
});
CSS.parser.registerSyntax('<single-animation-timeline>', 'auto | none | <dashed-ident> | <scroll()> | <view()>');
CSS.parser
.registerProperty('animation-timeline', {
syntax: '<single-animation-timeline>#',
initialValue: 'auto',
inherits: false,
animatable: CSS.parser.ANIMATABLE_NO,
})
// No .computeTo … so it would just return the declared value
.onMatch(match => {
switch(match.specifiedValue.toString()) {
case 'auto':
// …
break;
case 'none':
// …
break;
case 'scroll()': // @TODO: Accept params
// …
break;
case 'view()': // @TODO: Accept params
// …
break;
default: // Catches <dashed-ident>
// …
// @TODO: What about the order of things?
// animation-timeline relies on scroll-timeline
break;
}
})
.onElementUnmatch(elementMatch => {
// @TODO: Get all animations and set their timeline to DocumentTimeline?
})
;
CSS.parser.registerSyntax(
'<single-animation-range>',
'[ normal | <length-percentage> | <timeline-range-name> <length-percentage>? ]#'
);
CSS.parser.registerProperty('animation-range-start', {
syntax: '<single-animation-range>',
initialValue: 'none',
inherits: false,
percentages: customPercentageComputationFunction,
animatable: CSS.parser.ANIMATABLE_DISCRETE,
});
CSS.parser.registerProperty('animation-range-end', {
syntax: '<single-animation-range>',
initialValue: 'none',
inherits: false,
percentages: customPercentageComputationFunction,
animatable: CSS.parser.ANIMATABLE_DISCRETE,
});
CSS.parser.registerProperty('animation-range', {
syntax: "[ <'animation-range-start'> <'animation-range-end'>? ]#",
shorthand: true,
});
CSS.parser
.matchProperty(['animation-range-start', 'animation-range-end'])
.computeTo(match => {
const { element, specifiedValue } = match;
const animationTimeline = CSS.parser.getSpecifiedStyle(element).getPropertyValue('animation-timeline');
// Parse the range to pixel values based on the the timeline
const toReturn = parseRange(specifiedValue, animationTimeline);
// The computed value is the declared one
return toReturn;
})
;Taking a look at some CSS Polyfills and how CSS Parser Extensions would influence these.
- Source Code: https://github.com/oddbird/css-anchor-positioning
- Source Size: 120kB
- Bundlephobia: https://bundlephobia.com/package/@oddbird/css-anchor-positioning@0.3.1
- Bundle size: 221.5kB minified, 62.6kB minified + gzipped
tl;dr projected savings = 85%
One thing that immediately stands out is that css-anchor-positioning relies on css-tree to parse all the CSS. As a result, of the 221.5kB this polyfill weighs, ±79% is taken up by css-tree@3.0.0 which weighs 174.8kB. With CSS Parser Extensions this can immediately be removed.
Taking a closer look at the raw source (which weighs 120kB in total), CSS Parser Extensions allows removal of the following files (weighing ±34kB combined):
cascade.ts(2.2kB, 78 LOC) which replaces the original properties with custom properties to allow future retrieval throughgetComputedStyleand friendsfetch.ts(3.9kB, 119 LOC) which is responsible for gathering the inline and external style sheetsparse.ts(25kB, 728 LOC) which is responsible for parsing all properties, functions, and keywords related to anchor positioning.transform.ts(2.3kB, 71 LOC)
Furthermore some util functions can also be replaced by the util functions CSS Parser Extensions offers.
All combined, a similar polyfill powered by CSS Parser Extensions would be ±15% of the current package’s size.
(15% = (1 - 174/221)* (1 - 34 / 120)).
- Source Code: https://github.com/GoogleChromeLabs/container-query-polyfill
- Source Size: 156kB
- Bundlephobia: https://bundlephobia.com/package/container-query-polyfill@1.0.2
- Bundle size: 29.6kB minified, 9.8kB minified + gzipped
tl;dr projected savings = 64%
Taking a look at the raw source, the following can be removed thanks to CSS Parser Extensions:
- The entire
utilsfolder that includes a full CSS Parser can be removed. This immediately scrapes off 60kB (±38%) of the source code.ast.ts(44 LOC)css.ts(1577 LOC)parse-media-feature.ts(246 LOC)parse-media-query.ts(198 LOC)
evaluate.ts(11kB, 476 LOC) which converts units and moreparser.ts(11kB, 445 LOC) which parses container query syntaxtransform.ts(18kB, 723 LOC) which transforms the collected stylesheets using the parser
Combined, that’s 100kB (64%) that can be shaved off the total of 156kB.
Finally the engine.ts can be adjusted to remove functions such as registerStyleSheet which no longer needs to happen.
CSS Parser Extensions would remove the biggest limitation the polyfill currently has:
CSS first: The polyfill currently only supports <style> and same-origin <link> elements. Inline styles via the style attribute or CSSOM methods are not polyfilled. Likewise, JavaScript APIs like CSSContainerRule are not polyfilled, and APIs like CSS.supports() are not monkey-patched.
Of that limitation, only the lack of CSSContainerRule would remain.
- Source Code: https://github.com/flackr/scroll-timeline
- Source Size: 236kB
- Bundlephobia: https://bundlephobia.com/package/scroll-timeline-polyfill
- Bundle size: 61.34kB minified, 17.6kB minified + gzipped
tl;dr projected savings = 48%
A big part of the source code is dedicated to parsing CSS and CSSOM calculations. With CSS Parser Extensions, the polyfill could be rewritten to be a pure JS polyfill with some additional hooks to make the CSS part work. The following files can be removed in that case:
numeric-values.js(31kB, 844 LOC) – (Partial) JS implementation of the https://www.w3.org/TR/css-typed-om-1/ specproxy-cssom.js(6.3kB, 261 LOC)scroll-timeline-css-parser.js(25kB, 808 LOC) – Parses CSS Property valuesscroll-timeline-css.js(6.7kB, 190 LOC) – Initializes the CSS polyfillsimplify-calculation.js(13kB, 318 LOC) – To supportcalc()expressions in the values (https://www.w3.org/TR/css-values-4/ spec)tokenizer.js(28kB, 799 LOC) – CSS Tokenizerutils.js(2.2kB, 80 LOC) – Can partially be replaced
Combined, that’s 112.2kB that can immediately be removed from the source.
Currently there are a bunch of open bugs and shortcomings that still need to be addressed. With CSS Parser Extensions, these bugs would not have existed in the first place:
- #38 – Safari: Unhandled unit type %
- #80 – Polyfill does not play nice with @layer
- #98 – ViewTimeline does not respond to layout changes
- #100 – prefers-reduced-motion messes up the parser
- #123 – Support timeline-scope
- #130 – Support all animation-timing-function values in the animation shorthand
- #133 – Have the parser play nice with nested CSS
- #134 – Support setting the view-timeline-inset in the view-timeline shorthand
- #163 – I can't get the polyfill to work
- #236 – Parse more animation-range values
- #240 – Issue Integrating scroll-timeline with Next.js
- #248 – Support cross-origin stylesheets when CORS enabled
- #254 – Not working on firefox
- #261– CSS src url changes relative paths to root
- #262 - Can't get working in firefox/safari. Am I missing something?
- #265 – Relative units don't work
- #266 – Add support for Viewport Units
- #283 – animation-timeline: view(); not working in Astro
- #290 – `exit-crossing` with a `px` value not working consistently
(This list excludes a bunch of already closed duplicates)