Typed reads and change detection for CSS custom properties, in browsers with or without CSS Typed OM and @property.
Typed OM is the engine under Surface. It reads every --ax-* property of a surface as numbers, colors and keywords, and tells the surface when one of them changed. You need it only to build your own elements.
Describe your custom properties once in a schema, bind the schema to an element and read. Common forms such as 12px, 50%, calc(50% + 4px), rgb() and hex are parsed directly. Everything else, such as calc(1em + 2px) or red, goes through a hidden probe element, so the browser does the math.
Every example on this page is live. The controls set custom properties on an element, and the code of the package reads them back. The text in the preview is what the package returned.
Installation
npm install @ax-design/typed-omand import it:
import { StyleSchema, StyleBinding } from '@ax-design/typed-om';Lengths and calc()
A length-percentage property is read as an object: px is the pixel part and percent is the percentage part, where 50 means 50%. Type a value into the box and the text shows what binding.get returns.
Try 12px, 50%, calc(10% + 4px) and calc(100% - 2px). Values with em, rem or vw have no fixed form, so the package asks the browser: the last line counts how many properties needed the probe.
Try it
const schema = new StyleSchema([
{ name: '--my-size', syntax: { type: 'length-percentage' }, initial: '0px', inherits: true }
]);
// host is your element, root its shadow root.
const binding = new StyleBinding(schema, host, root);
binding.read();
binding.get('--my-size');.my-element {
--my-size: calc(10% + 4px);
}Colors
A color property is read as [r, g, b, a]: red, green and blue from 0 to 255, and alpha from 0 to 1. A value that is not a color gives the initial color of the schema.
rgb() and hex are parsed directly. Named colors, hsl() and anything else go through the probe, so try rebeccapurple, hsl(200 80% 50%) or color-mix(in srgb, red, blue).
Try it
const schema = new StyleSchema([
{ name: '--my-color', syntax: { type: 'color' }, initial: 'black', inherits: true }
]);
const binding = new StyleBinding(schema, host, root);
binding.read();
binding.get('--my-color');.my-element {
--my-color: rgb(255 200 0 / 60%);
}Keywords and skipped properties
A keyword property returns one of the values you allowed, as a string. Any other word gives the initial value, so the property is always one of your keywords.
A property can be skipped when another one makes it irrelevant. Here the depth is only read while the mode is hard. While the mode is soft, the schema keeps the initial depth and the browser is not asked at all, which saves work on every read.
Try it
const schema = new StyleSchema([
{ name: '--my-mode', syntax: { type: 'keyword', values: ['soft', 'hard'] }, initial: 'soft', inherits: false },
{
name: '--my-depth',
syntax: { type: 'length' },
initial: '0px',
inherits: false,
skipWhen: { property: '--my-mode', values: ['soft'] }
}
]);
const binding = new StyleBinding(schema, host, root);
binding.read();
binding.get('--my-mode');
binding.get('--my-depth');.my-element {
--my-mode: hard;
--my-depth: 80px;
}Watching changes
binding.observe tells you when something in the schema changed, whatever the cause: a class, a media query, a transition. It does not tell you the new values. Read them on the next frame, once, however many times the callback ran.
Move the slider. The bar follows the CSS transition, the text is read only from the callback. Turn Watch off and the package stops: the callback does not run and nothing is read. The detector costs a little on every style change of the page, so watch only while your element is visible.
Try it
const schema = new StyleSchema([
{ name: '--my-level', syntax: { type: 'number' }, initial: '0', inherits: true, register: true }
]);
const binding = new StyleBinding(schema, host, root);
let frame = 0;
binding.observe(() => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
binding.read();
draw(binding.get('--my-level'));
});
});
// Watch: true
binding.unobserve();.my-element {
--my-level: 0;
transition: --my-level 1500ms;
}Registered properties
An unregistered custom property is text to the browser, so a transition on it jumps from the start to the end. With register: true the package calls CSS.registerProperty for you, and the transition passes through every value in between.
Move the slider. Both bars are drawn from values read with the package: the registered one follows the transition, the plain one jumps. Where CSS.registerProperty does not exist, both jump and everything else still works.
Try it
const schema = new StyleSchema([
{ name: '--my-registered', syntax: { type: 'number' }, initial: '0', inherits: false, register: true },
{ name: '--my-plain', syntax: { type: 'number' }, initial: '0', inherits: true }
]);
schema.registered.has('--my-registered'); // true where CSS.registerProperty exists
schema.registered.has('--my-plain'); // false.my-element {
--my-registered: 0;
transition: --my-registered 1500ms;
}Reference
StyleSchema method
new StyleSchema(specs: PropertySpec[])
Describes the properties you read. Validates the specs, registers the ones with register: true, and compiles the reader and the detector once. Share one schema between all elements of the same kind. Throws when a name does not start with --, when a keyword is a CSS-wide keyword or not an identifier, or when skipWhen names a property that is not earlier in the list.
schema.specs property
PropertySpec[]
The specs you passed, in order.
schema.registered property
Set<string>
The names that are registered after the constructor, including names another copy of the package registered before. Empty where CSS.registerProperty does not exist.
schema.reader property
StyleReader
The reader that StyleBinding.read uses.
schema.detector property
DetectorTemplate
The compiled detector that StyleBinding.observe attaches.
schema.indexOf method
(name: string) => number
The position of a property in the schema, and in binding.values. Throws when the name is not in the schema.
StyleBinding method
new StyleBinding(schema: StyleSchema, host: Element, root: ShadowRoot)
The state of one element: its latest values and its detector. The host is the element whose computed style is read, root is the shadow root the probe and the detector live in. The root must be a shadow root of the host or inherit its custom properties.
binding.read method
() => PropertyValue[]
Reads every property of the schema from the host and returns binding.values. Run it after something changed, not on every frame.
binding.get method
(name: string) => PropertyValue
The value of one property from the last read. Throws when the name is not in the schema.
binding.values property
PropertyValue[]
The values of the last read, in the order of the schema.
binding.stats property
{ slow: number }, default { slow: 0 }
slow is the number of properties that needed the probe in the last read.
binding.observe method
(onChange: () => void) => void
Attaches the detector to the shadow root. onChange runs once right after attaching and after every later change of a property in the schema. It runs possibly several times per frame, so coalesce it with requestAnimationFrame. Does nothing when already observing.
binding.unobserve method
() => void
Removes the detector. Call it while your element is not visible. Does nothing when not observing.
binding.observing property
boolean, default false
True while the detector is attached.
binding.schema, binding.host, binding.root property
StyleSchema, Element, ShadowRoot
The arguments of the constructor.
name property
string
PropertySpec: the custom property, including the leading --.
syntax property
{ type: 'length' } | { type: 'length-percentage' } | { type: 'number' } | { type: 'color'; keywords?: string[] } | { type: 'keyword'; values: string[] }
PropertySpec: what the property holds, and so what binding.get returns. length gives a number of pixels, length-percentage gives { px, percent }, number gives a number, color gives [r, g, b, a] or one of the extra keywords as a string, keyword gives one of the values. CSS-wide keywords are rejected. Avoid none as a keyword: a change to none is not seen by the detector.
initial property
string
PropertySpec: the CSS text of the initial value. It is the value when the property is not set or not valid, and the var() fallback when the property is not registered.
inherits property
boolean
PropertySpec: whether the property inherits. Used for registering. For properties you do not register, set it to what the property does in your CSS.
register property
boolean, default false
PropertySpec: register the property with CSS.registerProperty where it exists, so transitions on it interpolate. Where it does not exist, transitions jump to the end value and nothing else changes. The first registration of a name wins, so never change the definition of a registered property between versions.
skipWhen property
{ property: string; values: string[] }
PropertySpec: do not read this property, and keep its initial value, while the earlier property named here holds one of the values. The other property must be earlier in the schema.
PropertyValue property
LengthPercentage | number | string | Rgba | null
What binding.get returns. LengthPercentage is { px: number; percent: number } with percent as 50 for 50%. Rgba is [number, number, number, number] with alpha from 0 to 1.
canRegister method
() => boolean
True when CSS.registerProperty exists.
registerProperties method
(specs: PropertySpec[]) => Set<string>
Registers the specs with register: true and returns the names registered afterwards. StyleSchema calls it for you.
validateSchema method
(specs: PropertySpec[]) => void
Throws the errors of the StyleSchema constructor. StyleSchema calls it for you.
cssSyntax method
(syntax: PropertySyntax) => string
The CSS syntax string of a syntax, e.g. <length-percentage> or soft | hard. It is what register passes to CSS.registerProperty.
StyleReader method
new StyleReader(specs: PropertySpec[])
The reader behind binding.read. Its method read(element, probeParent, out = [], stats?) parses getComputedStyle of the element and falls back to the probe, which is put into probeParent. Use StyleBinding unless you need the reader alone.
ReadStats property
{ slow: number }
The type of binding.stats.
parseNumber method
(text: string) => number | null
A plain number such as 1.5 or 2e3. Null for anything else.
parseLength method
(text: string) => number | null
The pixels of 0 or a value in px. Null for anything else.
parseLengthPercentage method
(text: string) => LengthPercentage | null
0, a value in px, a value in %, or the calc() of a percentage and px as computed styles serialize it, such as calc(50% + 4px). Null for anything else.
parseColor method
(text: string) => Rgba | null
rgb(), rgba() and hex with 3, 4, 6 or 8 digits. Null for named colors and other color spaces.
parseKeyword method
(text: string, values: string[]) => string | null
The text when it is one of the values, null otherwise.
probeLengthPercentage method
(parent: Node, name: string) => LengthPercentage
Asks the browser for the value of a property in any length form, such as calc(1em + 2px). The probe is added to parent for the read and removed again.
probeNumber method
(parent: Node, name: string) => number
Asks the browser for the value of a number property, such as calc(1 + 1).
probeColor method
(parent: Node, name: string) => Rgba | null
Asks the browser for the value of a color property, in any color syntax. Null when the value is not a color.
buildDetector method
(specs: PropertySpec[], registered?: Set<string>) => DetectorTemplate, default registered = new Set()
Compiles the CSS of the detector as { css, elementCount }. Pass the names that are registered, so the detector can reach non-inherited ones. StyleSchema calls it for you.
attachDetector method
(root: ShadowRoot, template: DetectorTemplate, onChange: () => void) => DetectorHandle
Puts the detector into the shadow root. Returns { detach(): void }. StyleBinding.observe calls it for you.