├── .gitignore ├── .npmignore ├── .prettierrc.json ├── .vscode └── settings.json ├── CHANGELOG.md ├── README.md ├── _build ├── eleventy.js └── includes │ └── page.njk ├── _headers ├── assets ├── default.css ├── logo.svg ├── prism.css ├── prism.js ├── social.gif ├── social.njk ├── social.png └── style.css ├── index.js ├── package-lock.json ├── package.json ├── src ├── element-style-observer.js ├── rendered-observer.js ├── style-observer.js ├── util.js └── util │ ├── MultiWeakMap.js │ ├── adopt-css.js │ ├── detect-bugs.js │ ├── detect-bugs │ ├── adopted-style-sheet.js │ ├── transitionrun-loop.js │ └── unregistered-transition.js │ ├── gentle-register-property.js │ └── is-registered-property.js ├── tests ├── basic.js ├── change.js ├── constructor.js ├── display.js ├── index-fn.js ├── index.html ├── index.json ├── multiple.js ├── nested.js ├── records.js ├── reflow.js ├── shadow.js ├── syntax.js ├── tests.js ├── transition.js ├── util.js ├── util │ ├── gentle-register-property.js │ └── types.js └── utilities │ ├── getTimesFor.js │ ├── isRegisteredProperty.js │ └── splitCommas.js ├── tsconfig.json └── typedoc.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | Thumbs.db 4 | 5 | # Build artifacts 6 | api/ 7 | types/ 8 | index.html 9 | social.html 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Common gitignored 2 | node_modules 3 | .DS_Store 4 | Thumbs.db 5 | 6 | # Website files 7 | _build 8 | assets 9 | typedoc.json 10 | *.html 11 | 12 | # Build artifacts 13 | api/ 14 | index.html 15 | 16 | # Tests 17 | tests/ 18 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier-plugin-brace-style", 4 | "prettier-plugin-space-before-function-paren", 5 | "prettier-plugin-merge" 6 | ], 7 | "braceStyle": "stroustrup", 8 | "arrowParens": "avoid", 9 | "bracketSpacing": true, 10 | "endOfLine": "auto", 11 | "semi": true, 12 | "singleQuote": false, 13 | "tabWidth": 4, 14 | "useTabs": true, 15 | "trailingComma": "all", 16 | "printWidth": 100 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "prettier.enable": true, 5 | "debug.enableStatusBarColor": false 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## v0.1.0 (2025-05-22) 4 | 5 | ### Optimizations 6 | 7 | - Use adopted stylesheets (if supported) for shadow root hosts instead of inline styles (by [@LeaVerou](https://github.com/LeaVerou) in [#110](https://github.com/LeaVerou/style-observer/pull/110); tests by [@DmitrySharabin](https://github.com/DmitrySharabin) in [#112](https://github.com/LeaVerou/style-observer/pull/112)). 8 | 9 | ### Bugfixes 10 | 11 | - Do not break in DOM-less environments, like NodeJS (by [@benface](https://github.com/benface) in [#115](https://github.com/LeaVerou/style-observer/pull/115)). 12 | - Setting `transition-property: none` should not stop properties observation (by [@LeaVerou](https://github.com/LeaVerou) in [3a0f0d9](https://github.com/LeaVerou/style-observer/commit/3a0f0d988cfd9ea0601774b573886e5bc2890ee5) and [@DmitrySharabin](https://github.com/DmitrySharabin) in [#118](https://github.com/LeaVerou/style-observer/pull/118)). 13 | 14 | #### TypeScript 15 | 16 | - Make options optional in `StyleObserver()` and `ElementStyleObserver()` constructors (by [@LeaVerou](https://github.com/LeaVerou) in [f14bb02](https://github.com/LeaVerou/style-observer/commit/f14bb0264ef2f47680b6991e923ba63031ab6547)). 17 | - Add type overloads for `observe()` and `unobserve()` so that TypeScript allows providing their arguments in any order (by [@LeaVerou](https://github.com/LeaVerou) in [151b0c2](https://github.com/LeaVerou/style-observer/commit/151b0c24e38e4e227215f3198e9f92bfdc8f7e1f) and [@DmitrySharabin](https://github.com/DmitrySharabin) in [#116](https://github.com/LeaVerou/style-observer/pull/116)). 18 | 19 | **Full Changelog:** [0.0.9...0.1.0](https://github.com/LeaVerou/style-observer/compare/0.0.9...0.1.0) 20 | 21 | ### New Contributors 22 | 23 | - [@benface](https://github.com/benface) made their first contribution in [#115](https://github.com/LeaVerou/style-observer/pull/115) 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Style Observer

4 | 5 | 14 | 15 |
16 |
17 | 26 |
27 | 28 |

29 | A robust, production-ready library to observe CSS property changes. 30 | Detects browser bugs and works around them, so you don't have to. 31 |

32 | 33 | [![npm](https://img.shields.io/npm/v/style-observer)](https://www.npmjs.com/package/style-observer) 34 | [![gzip size](https://img.shields.io/badge/gzip-2.73kB-blue)](https://pkg-size.dev/style-observer) 35 | 36 | - Observe changes to custom properties 37 | - Observe changes to standard properties (except `transition` and `animation`) 38 | - Observe changes on any element (including those in Shadow DOM) 39 | - [Lightweight](https://pkg-size.dev/style-observer), ESM-only code, with no dependencies 40 | - [200+ unit tests](tests) you can run in your browser of choice 41 | - Throttling per element 42 | - Does not overwrite existing transitions 43 | 44 | ## Compatibility 45 | 46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 |
Feature Chrome Safari Firefox% of global users
Custom properties11717.412989%
Custom properties (registered with an animatable type)9716.412893%
Standard properties (discrete) 74 |
75 |
11717.412989%
Standard properties (animatable)9715.410495%
90 |
91 | 92 | ## Install 93 | 94 | The quickest way is to just include straight from the [Netlify](https://www.netlify.com/) CDN: 95 | 96 | ```js 97 | import StyleObserver from "https://observe.style/index.js"; 98 | ``` 99 | 100 | This will always point to the latest version, so it may be a good idea to eventually switch to a local version that you can control. 101 | E.g. you can use npm: 102 | 103 | ```sh 104 | npm install style-observer 105 | ``` 106 | 107 | and then, if you use a bundler like Rollup or Webpack: 108 | 109 | ```js 110 | import StyleObserver from "style-observer"; 111 | ``` 112 | 113 | and if you don’t: 114 | 115 | ```js 116 | import StyleObserver from "node_modules/style-observer/dist/index.js"; 117 | ``` 118 | 119 | ## Usage 120 | 121 | You can first create the observer instance and then observe, like a `MutationObserver`. 122 | The simplest use is observing a single property on a single element: 123 | 124 | ```js 125 | const observer = new StyleObserver(records => console.log(records)); 126 | observer.observe(document.querySelector("#my-element"), "--my-custom-property"); 127 | ``` 128 | 129 | You can also observe multiple properties on multiple elements: 130 | 131 | ```js 132 | const observer = new StyleObserver(records => console.log(records)); 133 | const properties = ["color", "--my-custom-property"]; 134 | const targets = document.querySelectorAll(".my-element"); 135 | observer.observe(targets, properties); 136 | ``` 137 | 138 | You can also provide both targets and properties when creating the observer, 139 | which will also call `observe()` for you: 140 | 141 | ```js 142 | import StyleObserver from "style-observer"; 143 | 144 | const observer = new StyleObserver(callback, { 145 | targets: document.querySelectorAll(".my-element"), 146 | properties: ["color", "--my-custom-property"], 147 | }); 148 | ``` 149 | 150 | Both targets and properties can be either a single value or an iterable. 151 | 152 | Note that the observer will not fire immediately for the initial state of the elements (i.e. it behaves like `MutationObserver`, not like `ResizeObserver`). 153 | 154 | ### Records 155 | 156 | Just like other observers, changes that happen too close together (set the `throttle` option to configure) will only invoke the callback once, 157 | with an array of records, one for each change. 158 | 159 | Each record is an object with the following properties: 160 | 161 | - `target`: The element that changed 162 | - `property`: The property that changed 163 | - `value`: The new value of the property 164 | - `oldValue`: The previous value of the property 165 | 166 | ## Future Work 167 | 168 | - Observe pseudo-elements 169 | - `immediate` convenience option that fires the callback immediately for every observed element 170 | 171 | ## Limitations & Caveats 172 | 173 | - You cannot observe changes on elements **not connected to a document**. However, once the elements become connected again, the observer will pick up any changes that happened while they were disconnected. 174 | - You cannot observe changes to `transition` and `animation` properties (and their constituent properties). 175 | - You cannot observe changes **caused by CSS animations** (follow [#87](https://github.com/LeaVerou/style-observer/issues/87) for updates). 176 | - Changes caused due to a slotted element being moved to a different slot will not be picked up. 177 | 178 | ### Changing `transition` properties after observing 179 | 180 | If you change the `transition`/`transition-*` properties dynamically on elements you are observing after you start observing them, 181 | the easiest way to ensure the observer continues working as expected is to call `observer.updateTransition(targets)` to regenerate the `transition` property the observer uses to detect changes. 182 | 183 | If running JS is not an option, you can also do it manually: 184 | 185 | 1. Add `, var(--style-observer-transition, --style-observer-noop)` at the end of your `transition` property. 186 | E.g. if instead of `transition: 1s background` you'd set `transition: 1s background, var(--style-observer-transition, --style-observer-noop)`. 187 | 2. Make sure to also set `transition-behavior: allow-discrete;`. 188 | 189 | ## Prior Art 190 | 191 | The quest for a JS style observer has been long and torturous. 192 | 193 | - Early attempts used polling. Notable examples were [`ComputedStyleObserver` by Keith Clark](https://github.com/keithclark/ComputedStyleObserver) 194 | and [`StyleObserver` by PixelsCommander](https://github.com/PixelsCommander/StyleObserver) 195 | - [Jane Ori](https://propjockey.io) was the first to do better than polling, her [css-var-listener](https://github.com/propjockey/css-var-listener) using a combination of observers and events. 196 | - [css-variable-observer](https://github.com/fluorumlabs/css-variable-observer) by [Artem Godin](https://github.com/fluorumlabs) pioneered using transition events to observe property changes, and used an ingenious hack based on `font-variation-settings` to observe CSS property changes. 197 | - Four years later, [Bramus Van Damme](https://github.com/bramus) pioneered a way to do it "properly" in [style-observer](https://github.com/bramus/style-observer), 198 | thanks to [`transition-behavior: allow-discrete`](https://caniuse.com/mdn-css_properties_transition-behavior) becoming Baseline and even [blogged about all the bugs he encountered along the way](https://www.bram.us/2024/08/31/introducing-bramus-style-observer-a-mutationobserver-for-css/). 199 | 200 | While `StyleObserver` builds on this body of work, it is not a fork of any of them. 201 | It was written from scratch with the explicit goal of extending browser support and robustness. 202 | [Read the blog post](https://lea.verou.me/2025/style-observer/) for more details. 203 | 204 |
205 |
206 | 212 | -------------------------------------------------------------------------------- /_build/eleventy.js: -------------------------------------------------------------------------------- 1 | import markdownItAnchor from "markdown-it-anchor"; 2 | 3 | export default config => { 4 | let data = { 5 | layout: "page.njk", 6 | permalink: "{{ 'index' if page.filePathStem == '/README' else page.filePathStem }}.html", 7 | }; 8 | 9 | for (let p in data) { 10 | config.addGlobalData(p, data[p]); 11 | } 12 | 13 | config.amendLibrary("md", md => { 14 | md.options.typographer = true; 15 | md.use(markdownItAnchor, { 16 | permalink: markdownItAnchor.permalink.headerLink(), 17 | level: 2, 18 | }); 19 | }); 20 | 21 | return { 22 | markdownTemplateEngine: "njk", 23 | templateFormats: ["md", "njk"], 24 | dir: { 25 | includes: "_build/includes", 26 | output: "." 27 | }, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /_build/includes/page.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Style Observer 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {{ content | safe }} 21 | 22 | 23 | -------------------------------------------------------------------------------- /_headers: -------------------------------------------------------------------------------- 1 | /* 2 | Access-Control-Allow-Origin: * 3 | -------------------------------------------------------------------------------- /assets/default.css: -------------------------------------------------------------------------------- 1 | /* Web Awesome default theme tokens */ 2 | :where(:root), 3 | :host, 4 | :where([class^='wa-theme-'], [class*=' wa-theme-']), 5 | .wa-palette-default { 6 | --wa-color-red-95: #fff0ef /* oklch(96.667% 0.01632 22.08) */; 7 | --wa-color-red-90: #ffdedc /* oklch(92.735% 0.03679 21.966) */; 8 | --wa-color-red-80: #ffb8b6 /* oklch(84.803% 0.08289 20.771) */; 9 | --wa-color-red-70: #fd8f90 /* oklch(76.801% 0.13322 20.052) */; 10 | --wa-color-red-60: #f3676c /* oklch(68.914% 0.17256 20.646) */; 11 | --wa-color-red-50: #dc3146 /* oklch(58.857% 0.20512 20.223) */; 12 | --wa-color-red-40: #b30532 /* oklch(48.737% 0.19311 18.413) */; 13 | --wa-color-red-30: #8a132c /* oklch(41.17% 0.1512 16.771) */; 14 | --wa-color-red-20: #631323 /* oklch(33.297% 0.11208 14.847) */; 15 | --wa-color-red-10: #3e0913 /* oklch(24.329% 0.08074 15.207) */; 16 | --wa-color-red-05: #2a040b /* oklch(19.016% 0.06394 13.71) */; 17 | --wa-color-red: var(--wa-color-red-50); 18 | --wa-color-red-key: 50; 19 | 20 | --wa-color-yellow-95: #fef3cd /* oklch(96.322% 0.05069 93.748) */; 21 | --wa-color-yellow-90: #ffe495 /* oklch(92.377% 0.10246 91.296) */; 22 | --wa-color-yellow-80: #fac22b /* oklch(84.185% 0.16263 85.991) */; 23 | --wa-color-yellow-70: #ef9d00 /* oklch(75.949% 0.16251 72.13) */; 24 | --wa-color-yellow-60: #da7e00 /* oklch(67.883% 0.15587 62.246) */; 25 | --wa-color-yellow-50: #b45f04 /* oklch(57.449% 0.13836 56.585) */; 26 | --wa-color-yellow-40: #8c4602 /* oklch(47.319% 0.11666 54.663) */; 27 | --wa-color-yellow-30: #6f3601 /* oklch(40.012% 0.09892 54.555) */; 28 | --wa-color-yellow-20: #532600 /* oklch(32.518% 0.08157 53.927) */; 29 | --wa-color-yellow-10: #331600 /* oklch(23.846% 0.05834 56.02) */; 30 | --wa-color-yellow-05: #220c00 /* oklch(18.585% 0.04625 54.588) */; 31 | --wa-color-yellow: var(--wa-color-yellow-80); 32 | --wa-color-yellow-key: 80; 33 | 34 | --wa-color-green-95: #e3f9e3 /* oklch(96.006% 0.03715 145.28) */; 35 | --wa-color-green-90: #c2f2c1 /* oklch(91.494% 0.08233 144.35) */; 36 | --wa-color-green-80: #93da98 /* oklch(82.445% 0.11601 146.11) */; 37 | --wa-color-green-70: #5dc36f /* oklch(73.554% 0.15308 147.59) */; 38 | --wa-color-green-60: #00ac49 /* oklch(64.982% 0.18414 148.83) */; 39 | --wa-color-green-50: #00883c /* oklch(54.765% 0.15165 149.77) */; 40 | --wa-color-green-40: #036730 /* oklch(45.004% 0.11963 151.06) */; 41 | --wa-color-green-30: #0a5027 /* oklch(37.988% 0.09487 151.62) */; 42 | --wa-color-green-20: #0a3a1d /* oklch(30.876% 0.07202 152.23) */; 43 | --wa-color-green-10: #052310 /* oklch(22.767% 0.05128 152.45) */; 44 | --wa-color-green-05: #031608 /* oklch(17.84% 0.03957 151.36) */; 45 | --wa-color-green: var(--wa-color-green-60); 46 | --wa-color-green-key: 60; 47 | 48 | --wa-color-cyan-95: #e3f6fb /* oklch(96.063% 0.02111 215.26) */; 49 | --wa-color-cyan-90: #c5ecf7 /* oklch(91.881% 0.04314 216.7) */; 50 | --wa-color-cyan-80: #7fd6ec /* oklch(82.906% 0.08934 215.86) */; 51 | --wa-color-cyan-70: #2fbedc /* oklch(74.18% 0.12169 215.86) */; 52 | --wa-color-cyan-60: #00a3c0 /* oklch(65.939% 0.11738 216.42) */; 53 | --wa-color-cyan-50: #078098 /* oklch(55.379% 0.09774 217.32) */; 54 | --wa-color-cyan-40: #026274 /* oklch(45.735% 0.08074 216.18) */; 55 | --wa-color-cyan-30: #014c5b /* oklch(38.419% 0.06817 216.88) */; 56 | --wa-color-cyan-20: #003844 /* oklch(31.427% 0.05624 217.32) */; 57 | --wa-color-cyan-10: #002129 /* oklch(22.851% 0.04085 217.17) */; 58 | --wa-color-cyan-05: #00151b /* oklch(18.055% 0.03231 217.31) */; 59 | --wa-color-cyan: var(--wa-color-cyan-70); 60 | --wa-color-cyan-key: 70; 61 | 62 | --wa-color-blue-95: #e8f3ff /* oklch(95.944% 0.01996 250.38) */; 63 | --wa-color-blue-90: #d1e8ff /* oklch(92.121% 0.03985 248.26) */; 64 | --wa-color-blue-80: #9fceff /* oklch(83.572% 0.08502 249.92) */; 65 | --wa-color-blue-70: #6eb3ff /* oklch(75.256% 0.1308 252.03) */; 66 | --wa-color-blue-60: #3e96ff /* oklch(67.196% 0.17661 254.97) */; 67 | --wa-color-blue-50: #0071ec /* oklch(56.972% 0.20461 257.29) */; 68 | --wa-color-blue-40: #0053c0 /* oklch(47.175% 0.1846 259.19) */; 69 | --wa-color-blue-30: #003f9c /* oklch(39.805% 0.16217 259.98) */; 70 | --wa-color-blue-20: #002d77 /* oklch(32.436% 0.1349 260.35) */; 71 | --wa-color-blue-10: #001a4e /* oklch(23.965% 0.10161 260.68) */; 72 | --wa-color-blue-05: #000f35 /* oklch(18.565% 0.07904 260.75) */; 73 | --wa-color-blue: var(--wa-color-blue-50); 74 | --wa-color-blue-key: 50; 75 | 76 | --wa-color-indigo-95: #f0f2ff /* oklch(96.341% 0.0175 279.06) */; 77 | --wa-color-indigo-90: #dfe5ff /* oklch(92.527% 0.0359 275.35) */; 78 | --wa-color-indigo-80: #bcc7ff /* oklch(84.053% 0.07938 275.91) */; 79 | --wa-color-indigo-70: #9da9ff /* oklch(75.941% 0.12411 276.95) */; 80 | --wa-color-indigo-60: #808aff /* oklch(67.977% 0.17065 277.16) */; 81 | --wa-color-indigo-50: #6163f2 /* oklch(57.967% 0.20943 277.04) */; 82 | --wa-color-indigo-40: #4945cb /* oklch(48.145% 0.20042 277.08) */; 83 | --wa-color-indigo-30: #3933a7 /* oklch(40.844% 0.17864 277.26) */; 84 | --wa-color-indigo-20: #292381 /* oklch(33.362% 0.15096 277.21) */; 85 | --wa-color-indigo-10: #181255 /* oklch(24.534% 0.11483 277.73) */; 86 | --wa-color-indigo-05: #0d0a3a /* oklch(19.092% 0.08825 276.76) */; 87 | --wa-color-indigo: var(--wa-color-indigo-50); 88 | --wa-color-indigo-key: 50; 89 | 90 | --wa-color-purple-95: #f7f0ff /* oklch(96.49% 0.02119 306.84) */; 91 | --wa-color-purple-90: #eedfff /* oklch(92.531% 0.04569 306.6) */; 92 | --wa-color-purple-80: #ddbdff /* oklch(84.781% 0.09615 306.52) */; 93 | --wa-color-purple-70: #ca99ff /* oklch(76.728% 0.14961 305.27) */; 94 | --wa-color-purple-60: #b678f5 /* oklch(68.906% 0.1844 304.96) */; 95 | --wa-color-purple-50: #9951db /* oklch(58.603% 0.20465 304.87) */; 96 | --wa-color-purple-40: #7936b3 /* oklch(48.641% 0.18949 304.79) */; 97 | --wa-color-purple-30: #612692 /* oklch(41.23% 0.16836 304.92) */; 98 | --wa-color-purple-20: #491870 /* oklch(33.663% 0.14258 305.12) */; 99 | --wa-color-purple-10: #2d0b48 /* oklch(24.637% 0.10612 304.95) */; 100 | --wa-color-purple-05: #1e0532 /* oklch(19.393% 0.08461 305.26) */; 101 | --wa-color-purple: var(--wa-color-purple-50); 102 | --wa-color-purple-key: 50; 103 | 104 | --wa-color-pink-95: #feeff9 /* oklch(96.676% 0.02074 337.69) */; 105 | --wa-color-pink-90: #feddf0 /* oklch(93.026% 0.04388 342.45) */; 106 | --wa-color-pink-80: #fcb5d8 /* oklch(84.928% 0.09304 348.21) */; 107 | --wa-color-pink-70: #f78dbf /* oklch(77.058% 0.14016 351.19) */; 108 | --wa-color-pink-60: #e66ba3 /* oklch(69.067% 0.16347 353.69) */; 109 | --wa-color-pink-50: #c84382 /* oklch(58.707% 0.17826 354.82) */; 110 | --wa-color-pink-40: #9e2a6c /* oklch(48.603% 0.16439 350.08) */; 111 | --wa-color-pink-30: #7d1e58 /* oklch(41.017% 0.14211 347.77) */; 112 | --wa-color-pink-20: #5e1342 /* oklch(33.442% 0.11808 347.01) */; 113 | --wa-color-pink-10: #3c0828 /* oklch(24.601% 0.08768 347.8) */; 114 | --wa-color-pink-05: #28041a /* oklch(19.199% 0.06799 346.97) */; 115 | --wa-color-pink: var(--wa-color-pink-50); 116 | --wa-color-pink-key: 50; 117 | 118 | --wa-color-gray-95: #f1f2f3 /* oklch(96.067% 0.00172 247.84) */; 119 | --wa-color-gray-90: #e4e5e9 /* oklch(92.228% 0.0055 274.96) */; 120 | --wa-color-gray-80: #c7c9d0 /* oklch(83.641% 0.00994 273.33) */; 121 | --wa-color-gray-70: #abaeb9 /* oklch(75.183% 0.01604 273.78) */; 122 | --wa-color-gray-60: #9194a2 /* oklch(66.863% 0.02088 276.18) */; 123 | --wa-color-gray-50: #717584 /* oklch(56.418% 0.02359 273.77) */; 124 | --wa-color-gray-40: #545868 /* oklch(46.281% 0.02644 274.26) */; 125 | --wa-color-gray-30: #424554 /* oklch(39.355% 0.02564 276.27) */; 126 | --wa-color-gray-20: #2f323f /* oklch(31.97% 0.02354 274.82) */; 127 | --wa-color-gray-10: #1b1d26 /* oklch(23.277% 0.01762 275.14) */; 128 | --wa-color-gray-05: #101219 /* oklch(18.342% 0.01472 272.42) */; 129 | --wa-color-gray: var(--wa-color-gray-40); 130 | --wa-color-gray-key: 40; 131 | } 132 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 |
19 |
20 | 21 | 22 | 23 | 24 |
25 | -------------------------------------------------------------------------------- /assets/prism.css: -------------------------------------------------------------------------------- 1 | pre { 2 | background-color: var(--wa-color-gray-20); 3 | color: white; 4 | 5 | /* Ensures a discernible background color in dark mode 6 | * Useful for themes that use gray-20 as --wa-color-surface-default */ 7 | .wa-dark & { 8 | background-color: var(--wa-color-surface-lowered); 9 | } 10 | } 11 | 12 | .token.comment, 13 | .token.prolog, 14 | .token.doctype, 15 | .token.cdata, 16 | .token.operator, 17 | .token.punctuation { 18 | color: var(--wa-color-gray-80); 19 | } 20 | 21 | .token.namespace { 22 | opacity: 0.7; 23 | } 24 | 25 | .token.property, 26 | .token.tag, 27 | .token.url { 28 | color: var(--wa-color-indigo-80); 29 | } 30 | 31 | .token.keyword { 32 | font-weight: bold; 33 | color: var(--wa-color-indigo-70); 34 | } 35 | 36 | .token.symbol, 37 | .token.deleted, 38 | .token.important { 39 | color: var(--wa-color-red-80); 40 | } 41 | 42 | .token.boolean, 43 | .token.constant, 44 | .token.selector, 45 | .token.attr-name, 46 | .token.string, 47 | .token.char, 48 | .token.builtin, 49 | .token.inserted { 50 | color: var(--wa-color-green-80); 51 | } 52 | 53 | .token.atrule, 54 | .token.attr-value, 55 | .token.number, 56 | .token.variable, 57 | .token.function, 58 | .token.class-name, 59 | .token.regex { 60 | color: var(--wa-color-blue-80); 61 | } 62 | 63 | .token.important, 64 | .token.bold { 65 | font-weight: bold; 66 | } 67 | 68 | .token.italic { 69 | font-style: italic; 70 | } 71 | -------------------------------------------------------------------------------- /assets/prism.js: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.29.0 2 | https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript */ 3 | var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=g.reach);A+=w.value.length,w=w.next){var E=w.value;if(n.length>e.length)return;if(!(E instanceof i)){var P,L=1;if(y){if(!(P=l(b,A,e,m))||P.index>=e.length)break;var S=P.index,O=P.index+P[0].length,j=A;for(j+=w.value.length;S>=j;)j+=(w=w.next).value.length;if(A=j-=w.value.length,w.value instanceof i)continue;for(var C=w;C!==n.tail&&(jg.reach&&(g.reach=W);var z=w.prev;if(_&&(z=u(n,z,_),A+=_.length),c(n,z,L),w=u(n,z,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),L>1){var I={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,I),g&&I.reach>g.reach&&(g.reach=I.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a"+i.content+""},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); 4 | Prism.languages.markup={comment:{pattern://,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern://i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",(function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))})),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^$)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^$/i;var t={"included-cdata":{pattern://i,inside:s}};t["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var n={};n[a]={pattern:RegExp("(<__[^>]*>)(?:))*\\]\\]>|(?!)".replace(/__/g,(function(){return a})),"i"),lookbehind:!0,greedy:!0,inside:t},Prism.languages.insertBefore("markup","cdata",n)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(a,e){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp("(^|[\"'\\s])(?:"+a+")\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))","i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[e,"language-"+e],inside:Prism.languages[e]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml; 5 | !function(s){var e=/(?:"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n])*')/;s.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:RegExp("@[\\w-](?:[^;{\\s\"']|\\s+(?!\\s)|"+e.source+")*?(?:;|(?=\\s*\\{))"),inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\(\s*(?![\s)]))(?:[^()\s]|\s+(?![\s)])|\((?:[^()]|\([^()]*\))*\))+(?=\s*\))/,lookbehind:!0,alias:"selector"},keyword:{pattern:/(^|[^\w-])(?:and|not|only|or)(?![\w-])/,lookbehind:!0}}},url:{pattern:RegExp("\\burl\\((?:"+e.source+"|(?:[^\\\\\r\n()\"']|\\\\[^])*)\\)","i"),greedy:!0,inside:{function:/^url/i,punctuation:/^\(|\)$/,string:{pattern:RegExp("^"+e.source+"$"),alias:"url"}}},selector:{pattern:RegExp("(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|"+e.source+")*(?=\\s*\\{)"),lookbehind:!0},string:{pattern:e,greedy:!0},property:{pattern:/(^|[^-\w\xA0-\uFFFF])(?!\s)[-_a-z\xA0-\uFFFF](?:(?!\s)[-\w\xA0-\uFFFF])*(?=\s*:)/i,lookbehind:!0},important:/!important\b/i,function:{pattern:/(^|[^-a-z0-9])[-a-z0-9]+(?=\()/i,lookbehind:!0},punctuation:/[(){};:,]/},s.languages.css.atrule.inside.rest=s.languages.css;var t=s.languages.markup;t&&(t.tag.addInlined("style","css"),t.tag.addAttribute("style","css"))}(Prism); 6 | Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/}; 7 | Prism.languages.javascript=Prism.languages.extend("clike",{"class-name":[Prism.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|(?:get|set)(?=\s*(?:[#\[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],function:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,number:{pattern:RegExp("(^|[^\\w$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w$])"),lookbehind:!0},operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?|[~:]/}),Prism.languages.javascript["class-name"][0].pattern=/(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/,Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:RegExp("((?:^|[^$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)/(?:(?:\\[(?:[^\\]\\\\\r\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|/\\*(?:[^*]|\\*(?!/))*\\*/)*(?:$|[\r\n,.;:})\\]]|//))"),lookbehind:!0,greedy:!0,inside:{"regex-source":{pattern:/^(\/)[\s\S]+(?=\/[a-z]*$)/,lookbehind:!0,alias:"language-regex",inside:Prism.languages.regex},"regex-delimiter":/^\/|\/$/,"regex-flags":/^[a-z]+$/}},"function-variable":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/,lookbehind:!0,inside:Prism.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),Prism.languages.insertBefore("javascript","string",{hashbang:{pattern:/^#!.*/,greedy:!0,alias:"comment"},"template-string":{pattern:/`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}},"string-property":{pattern:/((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m,lookbehind:!0,greedy:!0,alias:"property"}}),Prism.languages.insertBefore("javascript","operator",{"literal-property":{pattern:/((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m,lookbehind:!0,alias:"property"}}),Prism.languages.markup&&(Prism.languages.markup.tag.addInlined("script","javascript"),Prism.languages.markup.tag.addAttribute("on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel)","javascript")),Prism.languages.js=Prism.languages.javascript; 8 | -------------------------------------------------------------------------------- /assets/social.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaVerou/style-observer/97f88badb98dc0937ff6704c12f18ef132904d61/assets/social.gif -------------------------------------------------------------------------------- /assets/social.njk: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /assets/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LeaVerou/style-observer/97f88badb98dc0937ff6704c12f18ef132904d61/assets/social.png -------------------------------------------------------------------------------- /assets/style.css: -------------------------------------------------------------------------------- 1 | @import url("https://kit.fontawesome.com/5956551eb3.css"); 2 | @import url("https://early.webawesome.com/webawesome@3.0.0-alpha.10/dist/styles/themes/default.css"); 3 | @import url("https://early.webawesome.com/webawesome@3.0.0-alpha.10/dist/styles/webawesome.css"); 4 | @import url("default.css"); 5 | @import url("prism.css"); 6 | 7 | :root { 8 | --max-page-width: min(120ch, 100% - 2em); 9 | --max-content-width: min(80ch, 100% - 2em); 10 | --body-padding: 1em; 11 | --rainbow: conic-gradient( 12 | in oklch, 13 | var(--wa-color-red-60), 14 | var(--wa-color-yellow-80), 15 | var(--wa-color-green-70), 16 | var(--wa-color-cyan-70), 17 | var(--wa-color-blue-60), 18 | var(--wa-color-indigo-60), 19 | var(--wa-color-purple-60), 20 | var(--wa-color-pink-60), 21 | var(--wa-color-red-60) 22 | ); 23 | --header-height: 5.5rem; 24 | } 25 | 26 | body { 27 | display: flex; 28 | flex-flow: column; 29 | } 30 | 31 | header, 32 | footer { 33 | box-sizing: content-box; 34 | padding-inline: clamp(1em, 50vw - var(--max-page-width) / 2, 50vw); 35 | max-width: var(--max-page-width); 36 | } 37 | 38 | .page { 39 | display: flex; 40 | max-width: var(--max-page-width); 41 | margin-inline: auto; 42 | 43 | @media (width <= 625px) { 44 | flex-direction: column; 45 | } 46 | } 47 | 48 | main { 49 | max-width: var(--max-content-width); 50 | padding: var(--wa-space-xl); 51 | margin-inline-end: auto; 52 | } 53 | 54 | :is(nav, aside) a, 55 | :is(h2, h3, h4) > a:only-child { 56 | text-decoration: none; 57 | color: inherit; 58 | } 59 | 60 | :is(h1, h2, h3, h4) { 61 | scroll-margin-block-start: var(--header-height); 62 | } 63 | 64 | h1 { 65 | position: relative; 66 | display: flex; 67 | align-items: center; 68 | font-size: var(--wa-font-size-2xl); 69 | font-weight: 900; 70 | line-height: 1; 71 | letter-spacing: -0.02em; 72 | 73 | &[data-version]::after { 74 | content: "v" attr(data-version); 75 | display: inline-block; 76 | margin-inline-start: var(--wa-space-2xs); 77 | font-size: var(--wa-font-size-s); 78 | font-weight: var(--wa-font-weight-semibold); 79 | color: var(--wa-color-on-quiet); 80 | } 81 | 82 | .logo { 83 | width: 1.6em; 84 | margin-right: var(--wa-space-s); 85 | 86 | @media (width > 1360px) { 87 | position: absolute; 88 | top: 50%; 89 | transform: translateY(-50%); 90 | right: 100%; 91 | } 92 | } 93 | 94 | span { 95 | color: var(--wa-color-pink); 96 | font-weight: 400; 97 | } 98 | } 99 | 100 | header { 101 | position: sticky; 102 | top: 0; 103 | z-index: 1; 104 | display: flex; 105 | align-items: center; 106 | border-bottom: var(--wa-border-style) var(--wa-panel-border-width) 107 | var(--wa-color-surface-border); 108 | padding-block: var(--wa-space-s); 109 | background: var(--wa-color-surface-default); 110 | 111 | nav { 112 | display: flex; 113 | gap: var(--wa-space-m); 114 | align-items: center; 115 | 116 | a { 117 | font-weight: var(--wa-font-weight-bold); 118 | color: var(--wa-color-on-quiet); 119 | font-size: var(--wa-font-size-m); 120 | } 121 | 122 | a:has(> .fa-github) { 123 | margin-inline-start: var(--wa-space-m); 124 | font-size: var(--wa-font-size-xl); 125 | } 126 | } 127 | } 128 | 129 | i { 130 | &.fa-chrome { 131 | color: var(--wa-color-green); 132 | } 133 | 134 | &.fa-firefox { 135 | color: color-mix(in oklch, var(--wa-color-red) 30%, var(--wa-color-yellow)); 136 | } 137 | 138 | &.fa-safari { 139 | color: var(--wa-color-blue); 140 | } 141 | } 142 | 143 | nav { 144 | a:not(:hover) > i { 145 | color: var(--wa-color-on-quiet); 146 | } 147 | } 148 | 149 | aside { 150 | border-inline-end: var(--wa-border-style) var(--wa-panel-border-width) 151 | var(--wa-color-surface-border); 152 | padding-block: var(--wa-space-l); 153 | padding-inline: var(--wa-space-l); 154 | 155 | ul { 156 | margin: 0; 157 | position: sticky; 158 | top: var(--header-height); 159 | list-style: none; 160 | line-height: var(--wa-line-height-expanded); 161 | } 162 | 163 | @media (width <= 625px) { 164 | border-inline-end: none; 165 | 166 | ul { 167 | position: static; 168 | } 169 | } 170 | 171 | @media (width > 625px) { 172 | padding-inline-start: 0; 173 | } 174 | 175 | a { 176 | color: inherit; 177 | text-decoration: none; 178 | font-size: var(--wa-font-size-s); 179 | font-weight: var(--wa-font-weight-semibold); 180 | white-space: nowrap; 181 | } 182 | } 183 | 184 | .readme-only { 185 | display: none; 186 | } 187 | 188 | main { 189 | ul:first-of-type { 190 | list-style: "✅ "; 191 | padding-inline-start: 0; 192 | 193 | span:first-of-type { 194 | display: none; 195 | } 196 | 197 | li::marker { 198 | font: var(--fa-font-solid); 199 | color: var(--wa-color-green); 200 | } 201 | } 202 | 203 | p:has(+ p > .compat) { 204 | margin-block-end: var(--wa-space-s); 205 | } 206 | } 207 | 208 | footer { 209 | border-top: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-surface-border); 210 | } 211 | 212 | .scrollable, 213 | pre:has(code) { 214 | max-width: 100%; 215 | overflow-x: auto; 216 | } 217 | 218 | .blurb { 219 | font-size: var(--wa-font-size-l); 220 | font-weight: var(--wa-font-weight-semibold); 221 | line-height: var(--wa-line-height-condensed); 222 | } 223 | 224 | :nth-child(1) { 225 | --index: 1; 226 | } 227 | :nth-child(2) { 228 | --index: 2; 229 | } 230 | :nth-child(3) { 231 | --index: 3; 232 | } 233 | 234 | .social { 235 | max-width: 80ch; 236 | max-height: 60ch; 237 | margin: auto; 238 | 239 | h1 { 240 | font-size: var(--wa-font-size-4xl); 241 | translate: -0.1em 0; 242 | } 243 | 244 | .logo { 245 | margin-right: var(--wa-space-xl); 246 | } 247 | 248 | .blurb { 249 | margin: 0; 250 | } 251 | 252 | .caption { 253 | font-weight: var(--wa-font-weight-bold); 254 | color: var(--wa-color-gray-60); 255 | margin: 0; 256 | margin-top: var(--wa-space-xs); 257 | 258 | > span { 259 | transition: 0.1s calc(1s * var(--index)) ease-in-out; 260 | @starting-style { 261 | opacity: 0; 262 | } 263 | } 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./src/style-observer.js"; 2 | export { default as StyleObserver } from "./src/style-observer.js"; 3 | export { default as ElementStyleObserver } from "./src/element-style-observer.js"; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "style-observer", 3 | "description": "Observe CSS property changes on any elements", 4 | "version": "0.1.0", 5 | "main": "index.js", 6 | "exports": { 7 | ".": { 8 | "import": "./index.js", 9 | "require": "./index.js", 10 | "types": "./types/index.d.ts", 11 | "default": "./index.js" 12 | } 13 | }, 14 | "scripts": { 15 | "test": "open tests/index.html", 16 | "release": "release-it", 17 | "build:apidocs": "npx typedoc", 18 | "build:html": "npx @11ty/eleventy --config=_build/eleventy.js --quiet", 19 | "build:types": "tsc --allowJs --emitDeclarationOnly --outDir types --declaration index.js", 20 | "build": "npm run build:html & npm run build:apidocs", 21 | "watch:apidocs": "npx typedoc --watch --preserveWatchOutput", 22 | "watch:html": "npx @11ty/eleventy --config=_build/eleventy.js --serve --quiet", 23 | "watch": "npm run watch:html & npm run watch:apidocs", 24 | "prepublishOnly": "npm run build:types" 25 | }, 26 | "keywords": [], 27 | "contributors": [ 28 | "Lea Verou", 29 | "Dmitry Sharabin" 30 | ], 31 | "homepage": "https://observe.style", 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/LeaVerou/style-observer.git" 35 | }, 36 | "bugs": { 37 | "url": "https://github.com/LeaVerou/style-observer/issues" 38 | }, 39 | "funding": [ 40 | { 41 | "type": "individual", 42 | "url": "https://github.com/sponsors/LeaVerou" 43 | }, 44 | { 45 | "type": "opencollective", 46 | "url": "https://opencollective.com/leaverou" 47 | } 48 | ], 49 | "license": "MIT", 50 | "type": "module", 51 | "devDependencies": { 52 | "@11ty/eleventy": "^3.0.0", 53 | "htest.dev": "^0.0.17", 54 | "markdown-it-anchor": "^9.2.0", 55 | "prettier-plugin-brace-style": "^0.7.2", 56 | "prettier-plugin-merge": "^0.7.3", 57 | "prettier-plugin-space-before-function-paren": "^0.0.8", 58 | "release-it": "^18.0.0", 59 | "typedoc": "^0.28.2", 60 | "typedoc-plugin-rename-defaults": "^0.7.3", 61 | "typescript": "^5.8.3" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/element-style-observer.js: -------------------------------------------------------------------------------- 1 | import bugs from "./util/detect-bugs.js"; 2 | import gentleRegisterProperty from "./util/gentle-register-property.js"; 3 | import MultiWeakMap from "./util/MultiWeakMap.js"; 4 | import { toArray, wait, getTimesFor } from "./util.js"; 5 | import RenderedObserver from "./rendered-observer.js"; 6 | 7 | const allowDiscrete = globalThis.CSS?.supports?.("transition-behavior", "allow-discrete") 8 | ? " allow-discrete" 9 | : ""; 10 | 11 | if (globalThis.document) { 12 | gentleRegisterProperty("--style-observer-transition", { inherits: false }); 13 | bugs.detectAll(); 14 | } 15 | 16 | /** 17 | * @typedef { object } StyleObserverOptionsObject 18 | * @property { string[] } properties - The properties to observe. 19 | */ 20 | /** 21 | * @typedef { StyleObserverOptionsObject | string | string[] } StyleObserverOptions 22 | */ 23 | 24 | /** 25 | * @callback StyleObserverCallback 26 | * @param {Record[]} records 27 | * @returns {void} 28 | */ 29 | 30 | /** 31 | * @typedef { Object } Record 32 | * @property {Element} target - The element that changed. 33 | * @property {string} property - The property that changed. 34 | * @property {string} value - The new value of the property. 35 | * @property {string} oldValue - The old value of the property. 36 | */ 37 | 38 | export default class ElementStyleObserver { 39 | /** 40 | * Observed properties to their old values. 41 | * @type {Map} 42 | */ 43 | properties; 44 | 45 | /** 46 | * Get the names of all properties currently being observed. 47 | * @type { string[] } 48 | */ 49 | get propertyNames () { 50 | return [...this.properties.keys()]; 51 | } 52 | 53 | /** 54 | * The element being observed. 55 | * @type {Element} 56 | */ 57 | target; 58 | 59 | /** 60 | * The callback to call when the element's style changes. 61 | * @type {StyleObserverCallback} 62 | */ 63 | callback; 64 | 65 | /** 66 | * The observer options. 67 | * @type {StyleObserverOptions} 68 | */ 69 | options; 70 | 71 | /** 72 | * Whether the observer has been initialized. 73 | * @type {boolean} 74 | */ 75 | #initialized = false; 76 | 77 | /** 78 | * @param {Element} target 79 | * @param {StyleObserverCallback} callback 80 | * @param {StyleObserverOptions} [options] 81 | */ 82 | constructor (target, callback, options = {}) { 83 | this.constructor.all.add(target, this); 84 | this.properties = new Map(); 85 | this.target = target; 86 | this.callback = callback; 87 | this.options = { properties: [], ...options }; 88 | let properties = toArray(options.properties); 89 | 90 | this.renderedObserver = new RenderedObserver(records => { 91 | if (this.propertyNames.length > 0) { 92 | this.handleEvent(); 93 | } 94 | }); 95 | 96 | if (properties.length > 0) { 97 | this.observe(properties); 98 | } 99 | } 100 | 101 | /** 102 | * Called the first time observe() is called to initialize the target. 103 | */ 104 | #init () { 105 | if (this.#initialized) { 106 | return; 107 | } 108 | 109 | let firstTime = this.constructor.all.get(this.target).size === 1; 110 | this.updateTransition({ firstTime }); 111 | 112 | this.#initialized = true; 113 | } 114 | 115 | resolveOptions (options) { 116 | return Object.assign(resolveOptions(options), this.options); 117 | } 118 | 119 | /** 120 | * Handle a potential property change 121 | * @private 122 | * @param {TransitionEvent} [event] 123 | */ 124 | async handleEvent (event) { 125 | if (event && !this.properties.has(event.propertyName)) { 126 | return; 127 | } 128 | 129 | if ( 130 | (bugs.TRANSITIONRUN_EVENT_LOOP && event?.type === "transitionrun") || 131 | this.options.throttle > 0 132 | ) { 133 | let eventName = bugs.TRANSITIONRUN_EVENT_LOOP ? "transitionrun" : "transitionstart"; 134 | let delay = Math.max(this.options.throttle, 50); 135 | 136 | if (bugs.TRANSITIONRUN_EVENT_LOOP) { 137 | // Safari < 18.2 fires `transitionrun` events too often, so we need to debounce. 138 | // Wait at least the amount of time needed for the transition to run + 1 frame (~16ms) 139 | let times = getTimesFor( 140 | event.propertyName, 141 | getComputedStyle(this.target).transition, 142 | ); 143 | delay = Math.max(delay, times.duration + times.delay + 16); 144 | } 145 | 146 | this.target.removeEventListener(eventName, this); 147 | await wait(delay); 148 | this.target.addEventListener(eventName, this); 149 | } 150 | 151 | let cs = getComputedStyle(this.target); 152 | let records = []; 153 | 154 | // Other properties may have changed in the meantime 155 | for (let property of this.propertyNames) { 156 | let value = cs.getPropertyValue(property); 157 | let oldValue = this.properties.get(property); 158 | 159 | if (value !== oldValue) { 160 | records.push({ target: this.target, property, value, oldValue }); 161 | this.properties.set(property, value); 162 | } 163 | } 164 | 165 | if (records.length > 0) { 166 | this.callback(records); 167 | } 168 | } 169 | 170 | /** 171 | * Observe the target for changes to one or more CSS properties. 172 | * @param {string | string[]} properties 173 | * @return {void} 174 | */ 175 | observe (properties) { 176 | properties = toArray(properties); 177 | 178 | // Drop properties already being observed 179 | properties = properties.filter(property => !this.properties.has(property)); 180 | 181 | if (properties.length === 0) { 182 | // Nothing new to observe 183 | return; 184 | } 185 | 186 | this.#init(); 187 | 188 | let cs = getComputedStyle(this.target); 189 | 190 | for (let property of properties) { 191 | if (bugs.UNREGISTERED_TRANSITION && !this.constructor.properties.has(property)) { 192 | // Init property 193 | gentleRegisterProperty(property, undefined, this.target.ownerDocument.defaultView); 194 | this.constructor.properties.add(property); 195 | } 196 | 197 | let value = cs.getPropertyValue(property); 198 | this.properties.set(property, value); 199 | } 200 | 201 | if (bugs.TRANSITIONRUN_EVENT_LOOP) { 202 | this.target.addEventListener("transitionrun", this); 203 | } 204 | 205 | this.target.addEventListener("transitionstart", this); 206 | this.target.addEventListener("transitionend", this); 207 | this.updateTransitionProperties(); 208 | 209 | this.renderedObserver.observe(this.target); 210 | } 211 | 212 | /** 213 | * Update the `--style-observer-transition` property to include all observed properties. 214 | */ 215 | updateTransitionProperties () { 216 | // Clear our own transition 217 | this.setProperty("--style-observer-transition", ""); 218 | 219 | let transitionProperties = new Set( 220 | getComputedStyle(this.target).transitionProperty.split(", "), 221 | ); 222 | let properties = []; 223 | 224 | for (let observer of this.constructor.all.get(this.target)) { 225 | properties.push(...observer.propertyNames); 226 | } 227 | 228 | properties = [...new Set(properties)]; // Dedupe 229 | 230 | // Only add properties not already present 231 | let transition = properties 232 | .filter(property => !transitionProperties.has(property)) 233 | .map(property => `${property} 1ms step-start${allowDiscrete}`) 234 | .join(", "); 235 | 236 | this.setProperty("--style-observer-transition", transition); 237 | } 238 | 239 | /** 240 | * @type { string | undefined } 241 | */ 242 | #inlineTransition; 243 | 244 | /** 245 | * Update the target's transition property or refresh it if it was overwritten. 246 | * @param {object} options 247 | * @param {boolean} [options.firstTime] - Whether this is the first time the transition is being set. 248 | */ 249 | updateTransition ({ firstTime } = {}) { 250 | const sot = "var(--style-observer-transition, --style-observer-noop)"; 251 | const inlineTransition = this.getProperty("transition"); 252 | let transition; 253 | 254 | // NOTE This code assumes that if there is an inline style, it takes precedence over other styles 255 | // This is not always true (think of !important), but will do for now. 256 | if (firstTime ? inlineTransition : !inlineTransition.includes(sot)) { 257 | // Either we are starting with an inline style being there, or our inline style was overwritten 258 | transition = this.#inlineTransition = inlineTransition; 259 | } 260 | 261 | if (transition === undefined && (firstTime || !this.#inlineTransition)) { 262 | // Just update based on most current computed style 263 | if (inlineTransition.includes(sot)) { 264 | this.setProperty("transition", ""); 265 | } 266 | 267 | transition = getComputedStyle(this.target).transition; 268 | } 269 | 270 | if (transition === "all") { 271 | transition = ""; 272 | } 273 | else { 274 | // Don't disable transitions on properties we are observing. See https://github.com/LeaVerou/style-observer/issues/107 275 | transition = transition.replace(/^none\b/, ""); 276 | } 277 | 278 | // Note that in Safari < 18.2 this fires no `transitionrun` or `transitionstart` events: 279 | // transition: all, var(--style-observer-transition, all); 280 | // so we can't just concatenate with whatever the existing value is 281 | const prefix = transition ? transition + ", " : ""; 282 | this.setProperty("transition", prefix + sot); 283 | 284 | this.updateTransitionProperties(); 285 | } 286 | 287 | /** 288 | * Whether the target has an open shadow root (and the modern adoptedStyleSheets API is supported). 289 | * @type { boolean } 290 | * @private 291 | */ 292 | get _isHost () { 293 | return ( 294 | this.target.shadowRoot && !Object.isFrozen(this.target.shadowRoot.adoptedStyleSheets) 295 | ); 296 | } 297 | 298 | /** 299 | * Shadow style sheet. Only used if _isHost is true. 300 | * @type { CSSStyleSheet | undefined } 301 | * @private 302 | */ 303 | _shadowSheet; 304 | 305 | /** 306 | * Any styles we've set on the target, for any reason. 307 | * @type { Record } 308 | * @private 309 | */ 310 | _styles = {}; 311 | 312 | /** 313 | * Set a CSS property on the target. 314 | * @param {string} property 315 | * @param {string} value 316 | * @param {string} [priority] 317 | * @return {void} 318 | */ 319 | setProperty (property, value, priority) { 320 | let inlineStyle = this.target.style; 321 | let style = inlineStyle; 322 | if (this._isHost) { 323 | // This has an open shadow root. 324 | // We can use an adopted shadow style to avoid manipulating its style attribute 325 | if (!this._shadowSheet) { 326 | this._shadowSheet = new CSSStyleSheet(); 327 | this._shadowSheet.insertRule(`:host { }`); 328 | this.target.shadowRoot.adoptedStyleSheets.push(this._shadowSheet); 329 | 330 | if (Object.keys(this._styles).length > 0) { 331 | // It was previously not a host, so we need to port the properties over 332 | for (let property in this._styles) { 333 | let value = this._styles[property]; 334 | this.setProperty(property, value); 335 | 336 | // Remove from inline style if it hasn't changed externally 337 | if (inlineStyle.getPropertyValue(property) === value) { 338 | inlineStyle.removeProperty(property); 339 | } 340 | } 341 | } 342 | } 343 | 344 | style = this._shadowSheet.cssRules[0].style; 345 | } 346 | 347 | style.setProperty(property, value, priority); 348 | // Store reserialized value for later comparison 349 | this._styles[property] = this.getProperty(property); 350 | } 351 | 352 | /** 353 | * Get a CSS property from the target. 354 | * @param {string} property 355 | * @return {string} 356 | */ 357 | getProperty (property) { 358 | let style = this._shadowSheet?.cssRules[0]?.style ?? this.target.style; 359 | return style.getPropertyValue(property); 360 | } 361 | 362 | /** 363 | * Stop observing a target for changes to one or more CSS properties. 364 | * @param { string | string[] } [properties] Properties to stop observing. Defaults to all observed properties. 365 | * @return {void} 366 | */ 367 | unobserve (properties) { 368 | properties = toArray(properties); 369 | 370 | // Drop properties not being observed anyway 371 | properties = properties.filter(property => this.properties.has(property)); 372 | 373 | for (let property of properties) { 374 | this.properties.delete(property); 375 | } 376 | 377 | if (this.properties.size === 0) { 378 | // No longer observing any properties 379 | this.target.removeEventListener("transitionrun", this); 380 | this.target.removeEventListener("transitionstart", this); 381 | this.target.removeEventListener("transitionend", this); 382 | this.renderedObserver.unobserve(this.target); 383 | } 384 | 385 | this.updateTransitionProperties(); 386 | } 387 | 388 | /** All properties ever observed by this class. */ 389 | static properties = new Set(); 390 | 391 | /** 392 | * All instances ever observed by this class. 393 | */ 394 | static all = new MultiWeakMap(); 395 | } 396 | 397 | /** 398 | * Resolve the observer options. 399 | * @param {StyleObserverOptions} options 400 | * @returns {StyleObserverOptionsObject} 401 | */ 402 | export function resolveOptions (options) { 403 | if (!options) { 404 | return {}; 405 | } 406 | 407 | if (typeof options === "string" || Array.isArray(options)) { 408 | options = { properties: toArray(options) }; 409 | } 410 | else if (typeof options === "object") { 411 | options = { properties: [], ...options }; 412 | } 413 | 414 | return options; 415 | } 416 | -------------------------------------------------------------------------------- /src/rendered-observer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Monitor the presence of an element in the document. 3 | * This observer fires the callback in situations like: 4 | * - The element is added to the DOM 5 | * - The element gets slotted or its slot starts existing (but not when moved to another slot) 6 | * - The element becomes visible from display: none 7 | */ 8 | 9 | export default class RenderedObserver { 10 | /** 11 | * All currently observed targets 12 | * @type {WeakSet} 13 | */ 14 | #targets = new Set(); 15 | 16 | /** 17 | * Documents to IntersectionObserver instances 18 | * @type {WeakMap} 19 | */ 20 | #intersectionObservers = new WeakMap(); 21 | 22 | constructor (callback) { 23 | this.callback = callback; 24 | } 25 | 26 | /** 27 | * Begin observing the presence of an element. 28 | * @param {Element} element - The element to observe. 29 | */ 30 | observe (element) { 31 | if (this.#targets.has(element)) { 32 | // Already observing this element 33 | return; 34 | } 35 | 36 | let doc = element.ownerDocument; 37 | let io = this.#intersectionObservers.get(doc); 38 | 39 | if (!io) { 40 | io = new IntersectionObserver( 41 | entries => { 42 | this.callback(entries.map(({ target }) => ({ target }))); 43 | }, 44 | { root: doc.documentElement }, 45 | ); 46 | 47 | this.#intersectionObservers.set(doc, io); 48 | } 49 | 50 | this.#targets.add(element); 51 | io.observe(element); 52 | } 53 | 54 | /** 55 | * Stop observing the presence of an element. 56 | * @param {Element} [element] - The element to stop observing. If not provided, all targets will be unobserved. 57 | */ 58 | unobserve (element) { 59 | if (!element) { 60 | // Unobserve all targets 61 | for (const target of this.#targets) { 62 | this.unobserve(target); 63 | } 64 | return; 65 | } 66 | 67 | let doc = element.ownerDocument; 68 | let io = this.#intersectionObservers.get(doc); 69 | 70 | io?.unobserve(element); 71 | this.#targets.delete(element); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/style-observer.js: -------------------------------------------------------------------------------- 1 | import ElementStyleObserver, { resolveOptions } from "./element-style-observer.js"; 2 | import { toArray } from "./util.js"; 3 | 4 | /** 5 | * @typedef {import("./element-style-observer.js").StyleObserverCallback} StyleObserverCallback 6 | */ 7 | 8 | /** 9 | * @typedef { Object } StyleObserverOptions 10 | * @property {string | string[]} [properties] - The properties to observe. 11 | * @property {Element | Element[]} [targets] - The elements to observe. 12 | */ 13 | 14 | export default class StyleObserver { 15 | /** 16 | * @type { WeakMap } 17 | */ 18 | elementObservers = new WeakMap(); 19 | 20 | /** 21 | * @param {StyleObserverCallback} callback 22 | * @param {StyleObserverOptions | string | string[]} [options] 23 | */ 24 | constructor (callback, options) { 25 | this.callback = callback; 26 | 27 | options = resolveOptions(options); 28 | options.targets ??= []; 29 | 30 | if (options.target) { 31 | options.targets.push(options.target); 32 | } 33 | 34 | this.options = options; 35 | 36 | if (this.options.targets.length > 0 && this.options.properties.length > 0) { 37 | this.observe(this.options.targets, this.options.properties); 38 | } 39 | } 40 | 41 | /** 42 | * @type {StyleObserverCallback} 43 | */ 44 | changed (records) { 45 | // TODO throttle & combine records 46 | this.callback(records); 47 | } 48 | 49 | /** 50 | * Observe one or more targets for changes to one or more CSS properties. 51 | * 52 | * @overload 53 | * @param {Element | Element[]} targets 54 | * @param {string | string[]} properties 55 | * @returns {void} 56 | * 57 | * @overload 58 | * @param {string | string[]} properties 59 | * @param {Element | Element[]} targets 60 | * @returns {void} 61 | * 62 | * @overload 63 | * @param {...(string | Element | (string | Element)[]) } propertiesOrTargets 64 | * @returns {void} 65 | */ 66 | observe (...args) { 67 | let { targets, properties } = resolveArgs(...args); 68 | 69 | if (targets.length === 0) { 70 | // Default to constructor-specified targets 71 | targets = this.options.targets; 72 | } 73 | 74 | if (targets.length === 0) { 75 | return; 76 | } 77 | 78 | for (let target of targets) { 79 | let observer = this.elementObservers.get(target); 80 | 81 | if (!observer) { 82 | observer = new ElementStyleObserver( 83 | target, 84 | records => this.changed(records), 85 | this.options, 86 | ); 87 | this.elementObservers.set(target, observer); 88 | } 89 | 90 | observer.observe(properties); 91 | } 92 | } 93 | 94 | /** 95 | * Stop observing one or more targets for changes to one or more CSS properties. 96 | * 97 | * @overload 98 | * @param {Element | Element[]} targets 99 | * @param {string | string[]} properties 100 | * @returns {void} 101 | * 102 | * @overload 103 | * @param {string | string[]} properties 104 | * @param {Element | Element[]} targets 105 | * @returns {void} 106 | * 107 | * @overload 108 | * @param {...(string | Element | (string | Element)[]) } propertiesOrTargets 109 | * @returns {void} 110 | */ 111 | unobserve (...args) { 112 | let { targets, properties } = resolveArgs(...args); 113 | 114 | if (targets.length === 0) { 115 | // Default to constructor-specified targets 116 | targets = this.options.targets; 117 | } 118 | 119 | if (targets.length === 0) { 120 | return; 121 | } 122 | 123 | if (properties.length === 0) { 124 | // Default to constructor-specified properties 125 | properties = this.options.properties; 126 | } 127 | 128 | for (let target of targets) { 129 | let observer = this.elementObservers.get(target); 130 | 131 | if (observer) { 132 | observer.unobserve(properties); 133 | } 134 | } 135 | } 136 | 137 | /** 138 | * Update the transition for one or more targets. 139 | * @param {Element | Element[]} targets 140 | * @returns {void} 141 | */ 142 | updateTransition (targets) { 143 | for (let target of toArray(targets)) { 144 | let observer = this.elementObservers.get(target); 145 | 146 | if (observer) { 147 | observer.updateTransition(); 148 | } 149 | } 150 | } 151 | } 152 | 153 | /** 154 | * Resolve the targets and properties from the arguments. 155 | * @param {Element | Element[] | string | string[]} targets 156 | * @param {Element | Element[] | string | string[]} properties 157 | * @returns {{ targets: Element[], properties: string[] }} 158 | */ 159 | function resolveArgs (targets, properties) { 160 | let args = [...toArray(targets), ...toArray(properties)]; 161 | targets = []; 162 | properties = []; 163 | 164 | for (let arg of args) { 165 | let arr = typeof arg === "string" || arg instanceof String ? properties : targets; 166 | arr.push(arg); 167 | } 168 | 169 | return { targets, properties }; 170 | } 171 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert a value to an array. `undefined` and `null` values are converted to an empty array. 3 | * @param {*} value - The value to convert. 4 | * @returns {any[]} The converted array. 5 | */ 6 | export function toArray (value) { 7 | if (value === undefined || value === null) { 8 | return []; 9 | } 10 | 11 | if (Array.isArray(value)) { 12 | return value; 13 | } 14 | 15 | // Don't convert "foo" into ["f", "o", "o"] 16 | if (typeof value !== "string" && typeof value[Symbol.iterator] === "function") { 17 | return Array.from(value); 18 | } 19 | 20 | return [value]; 21 | } 22 | 23 | /** 24 | * Wait for a given number of milliseconds or a `requestAnimationFrame`. 25 | * @param {number} ms - The number of milliseconds to wait. 26 | * @returns {Promise} 27 | */ 28 | export function wait (ms) { 29 | if (ms) { 30 | return new Promise(resolve => setTimeout(resolve, ms)); 31 | } 32 | 33 | return new Promise(resolve => requestAnimationFrame(resolve)); 34 | } 35 | 36 | let dummy; 37 | 38 | /** 39 | * Get the longhands for a given property. 40 | * @param {string} property - The property to get the longhands for. 41 | * @returns {string[]} The longhands. 42 | * @see https://lea.verou.me/blog/2020/07/introspecting-css-via-the-css-om-getting-supported-properties-shorthands-longhands/ 43 | */ 44 | export function getLonghands (property) { 45 | dummy ??= document.createElement("div"); 46 | let style = dummy.style; 47 | style[property] = "inherit"; // a value that works in every property 48 | let ret = [...style]; 49 | 50 | if (ret.length === 0) { 51 | // Fallback, in case 52 | ret = [property]; 53 | } 54 | 55 | style.cssText = ""; // clean up 56 | 57 | return ret; 58 | } 59 | 60 | /** 61 | * Parse a CSS `