├── .prettierrc ├── screenshot.png ├── src ├── index.js ├── index-styled.js ├── styles.css └── web-vitals.js ├── rollup.config.js ├── LICENSE ├── index.html ├── package.json ├── README.md ├── .gitignore └── CODE-OF-CONDUCT.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { "singleQuote": true } 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/web-vitals-element/HEAD/screenshot.png -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import WebVitals from './web-vitals.js'; 2 | 3 | customElements.define('web-vitals', WebVitals); 4 | -------------------------------------------------------------------------------- /src/index-styled.js: -------------------------------------------------------------------------------- 1 | import WebVitals from './web-vitals.js'; 2 | import styles from './styles.css'; 3 | 4 | customElements.define('web-vitals', WebVitals); 5 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import { terser } from 'rollup-plugin-terser'; 3 | import postcss from 'rollup-plugin-postcss'; 4 | 5 | const IS_PRODUCTION = process.env.NODE_ENV === 'production'; 6 | 7 | export default [ 8 | { 9 | input: 'src/index.js', 10 | output: { 11 | format: 'es', 12 | file: IS_PRODUCTION 13 | ? 'dist/web-vitals-element.min.js' 14 | : 'dist/web-vitals-element.js', 15 | }, 16 | plugins: [ 17 | nodeResolve(), 18 | IS_PRODUCTION ? terser() : null, 19 | postcss({ 20 | plugins: [], 21 | }), 22 | ], 23 | }, 24 | { 25 | input: 'src/index-styled.js', 26 | output: { 27 | format: 'es', 28 | file: IS_PRODUCTION 29 | ? 'dist/web-vitals-element.styled.min.js' 30 | : 'dist/web-vitals-element.styled.js', 31 | }, 32 | plugins: [ 33 | nodeResolve(), 34 | IS_PRODUCTION ? terser() : null, 35 | postcss({ 36 | plugins: [], 37 | }), 38 | ], 39 | }, 40 | ]; 41 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .web-vitals { 2 | max-width: 20em; 3 | border-radius: 0.5em; 4 | overflow: hidden; 5 | padding: 1em; 6 | font-family: Arial, Helvetica, sans-serif; 7 | box-shadow: 0 0.25em 0.35em rgba(23, 23, 23, 0.4); 8 | color: #444; 9 | background: #fff; 10 | } 11 | 12 | .web-vitals a { 13 | color: #444; 14 | font-weight: bold; 15 | } 16 | 17 | .web-vitals dl { 18 | margin: 0; 19 | grid-template-columns: 1fr 1fr; 20 | } 21 | 22 | .web-vitals p { 23 | margin: 0.5em 0 0; 24 | font-size: 0.75em; 25 | } 26 | 27 | .web-vitals div { 28 | display: grid; 29 | grid-template-columns: 3fr 1fr; 30 | } 31 | 32 | .web-vitals div dd::after { 33 | margin-left: 0.25em; 34 | line-height: 1; 35 | } 36 | 37 | .web-vitals div:not(.is-done) dd::after { 38 | content: '🕐'; 39 | } 40 | 41 | .web-vitals div.is-good dd::after { 42 | content: '✅'; 43 | } 44 | 45 | .web-vitals div.needs-improvement dd::after { 46 | content: '⚠️'; 47 | } 48 | 49 | .web-vitals div.is-poor dd::after { 50 | content: '❌'; 51 | } 52 | 53 | .web-vitals dd { 54 | text-align: right; 55 | margin-left: 1em; 56 | } 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stefan Judis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the Software), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | web-vitals-element 6 | 7 | 8 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |

web vitals custom element

26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-vitals-element", 3 | "version": "1.3.6", 4 | "description": "", 5 | "main": "dist/web-vitals-element.js", 6 | "exports": { 7 | ".": { 8 | "import": "./dist/web-vitals-element.min.js" 9 | }, 10 | "./styled": { 11 | "import": "./dist/web-vitals-element.styled.min.js" 12 | } 13 | }, 14 | "scripts": { 15 | "dev:server": "live-server .", 16 | "dev:bundle": "rollup -cw", 17 | "dev": "run-p dev:*", 18 | "bundle": "NODE_ENV=production rollup -c", 19 | "prepublish": "NODE_ENV=production rollup -c && rollup -c" 20 | }, 21 | "type": "module", 22 | "files": [ 23 | "dist/**" 24 | ], 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/stefanjudis/web-vitals-element.git" 28 | }, 29 | "keywords": [ 30 | "web-vitals", 31 | "performance", 32 | "metrics", 33 | "CLS", 34 | "FCP", 35 | "FID", 36 | "LCP", 37 | "TTFB" 38 | ], 39 | "author": "stefan judis ", 40 | "license": "MIT", 41 | "bugs": { 42 | "url": "https://github.com/stefanjudis/web-vitals-element/issues" 43 | }, 44 | "homepage": "https://github.com/stefanjudis/web-vitals-element#readme", 45 | "devDependencies": { 46 | "@rollup/plugin-node-resolve": "^13.0.0", 47 | "live-server": "^1.2.1", 48 | "npm-run-all": "^4.1.5", 49 | "postcss": "^8.3.5", 50 | "rollup": "^2.52.3", 51 | "rollup-plugin-postcss": "^4.0.0", 52 | "rollup-plugin-terser": "^7.0.2" 53 | }, 54 | "dependencies": { 55 | "web-vitals": "^2.0.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # web-vitals-element 2 | 3 | > Bring [web vitals](https://github.com/GoogleChrome/web-vitals) quickly into your page using custom elements 4 | 5 | ![web-vitals-element in styled and unstyled version](./screenshot.png) 6 | 7 | [See it in action on CodePen](https://codepen.io/stefanjudis/pen/wvGzvWx). 8 | 9 | ## Basic usage 10 | 11 | ```html 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 31 | ``` 32 | 33 | _The element does not render shadow DOM. You can style it like any other element in your HTML page._ 34 | 35 | After loading the element script, use the `web-vitals` element in your HTML. 36 | 37 | ```html 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | ``` 47 | 48 | Currently supported metrics: `cls`, `fcp`, `fid`, `lcp`, `ttfb`. Read more about these in [the web-vitals documentation](https://github.com/GoogleChrome/web-vitals). 49 | 50 | ## Contributing 51 | 52 | _I'd love to see more themes for the web vitals element box – the fancier the better!_ If you're interested in contributing some fancy looks, please [open an issue](https://github.com/stefanjudis/web-vitals-element/issues/new). 53 | 54 | ## Code of conduct 55 | 56 | This project underlies [a code of conduct](./CODE-OF-CONDUCT.md). 57 | 58 | ## License 59 | 60 | This project is released under [MIT license](./LICENSE). 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | 118 | dist 119 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at stefanjudis@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /src/web-vitals.js: -------------------------------------------------------------------------------- 1 | import * as webVitals from 'web-vitals'; 2 | 3 | const MS_UNIT = 'ms'; 4 | 5 | // borrowed from the vitals extension 6 | // https://github.com/GoogleChrome/web-vitals-extension/blob/master/src/browser_action/vitals.js#L20-L23 7 | const METRIC_CONFIG = new Map([ 8 | [ 9 | 'CLS', 10 | { 11 | thresholds: { 12 | good: 0.1, 13 | needsImprovement: 0.25, 14 | }, 15 | observerEntryType: 'layout-shift', 16 | explainerURL: 'https://web.dev/cls/', 17 | longName: 'Cumulative Layout Shift', 18 | roundFn: (value) => Math.floor(value * 100) / 100, 19 | }, 20 | ], 21 | [ 22 | 'FCP', 23 | { 24 | thresholds: { 25 | good: 2500, 26 | }, 27 | observerEntryType: 'paint', 28 | explainerURL: 'https://web.dev/fcp/', 29 | unit: MS_UNIT, 30 | longName: 'First Contentful Paint', 31 | }, 32 | ], 33 | [ 34 | 'FID', 35 | { 36 | thresholds: { 37 | good: 100, 38 | needsImprovement: 300, 39 | }, 40 | observerEntryType: 'first-input', 41 | explainerURL: 'https://web.dev/fid/', 42 | unit: MS_UNIT, 43 | longName: 'First Input Delay', 44 | }, 45 | ], 46 | [ 47 | 'LCP', 48 | { 49 | thresholds: { 50 | good: 2500, 51 | needsImprovement: 4000, 52 | }, 53 | observerEntryType: 'paint', 54 | explainerURL: 'https://web.dev/lcp/', 55 | unit: MS_UNIT, 56 | longName: 'Largest Contentful Paint', 57 | }, 58 | ], 59 | [ 60 | 'TTFB', 61 | { 62 | thresholds: { 63 | good: 2500, 64 | }, 65 | explainerURL: 'https://web.dev/ttfb/', 66 | unit: MS_UNIT, 67 | longName: 'Time to First Byte', 68 | }, 69 | ], 70 | ]); 71 | 72 | const GENERAL_ATTRIBUTES = ['class', 'style']; 73 | const CONFIG_ATTRIBUTES = ['show-unsupported', 'show-metric-name']; 74 | 75 | class WebVitals extends HTMLElement { 76 | constructor() { 77 | super(); 78 | 79 | this.unsupportedMetrics = []; 80 | this.metrics = new Map(); 81 | } 82 | 83 | connectedCallback() { 84 | const metricAttributes = this.getMetricAttributes(); 85 | const metricList = metricAttributes.length 86 | ? metricAttributes 87 | : [...METRIC_CONFIG.keys()]; 88 | 89 | this.metrics = this.getMetrics(metricList); 90 | 91 | this.render(); 92 | 93 | for (let metricConfig of this.metrics.values()) { 94 | const { name, getWebVitalsValue } = metricConfig; 95 | 96 | getWebVitalsValue((metric) => { 97 | this.metrics.set(name, { 98 | ...metricConfig, 99 | ...metric, 100 | }); 101 | this.render(); 102 | }, true); 103 | } 104 | } 105 | 106 | getMetricAttributes() { 107 | return this.getAttributeNames() 108 | .filter( 109 | (attr) => 110 | !GENERAL_ATTRIBUTES.includes(attr) && 111 | !CONFIG_ATTRIBUTES.includes(attr) 112 | ) 113 | .map((attr) => attr.toUpperCase()); 114 | } 115 | 116 | getMetrics(metricList) { 117 | return new Map( 118 | metricList.reduce((acc, metricName) => { 119 | // exclude metric when it's not supported by web-vitals 120 | const getWebVitalsValue = webVitals[`get${metricName}`]; 121 | if (!getWebVitalsValue) { 122 | console.error(`${metricName} is not supported by ''`); 123 | this.unsupportedMetrics.push(metricName); 124 | return acc; 125 | } 126 | 127 | // exclude metric when it's not supported 128 | const metricConfig = METRIC_CONFIG.get(metricName); 129 | const { observerEntryType } = metricConfig; 130 | if ( 131 | observerEntryType && 132 | !PerformanceObserver.supportedEntryTypes.includes(observerEntryType) 133 | ) { 134 | console.error(`${metricName} is not supported by your browser`); 135 | this.unsupportedMetrics.push(metricName); 136 | return acc; 137 | } 138 | 139 | return [ 140 | ...acc, 141 | [ 142 | metricName, 143 | { 144 | ...METRIC_CONFIG.get(metricName), 145 | getWebVitalsValue, 146 | name: metricName, 147 | }, 148 | ], 149 | ]; 150 | }, []) 151 | ); 152 | } 153 | 154 | render() { 155 | this.innerHTML = `
156 |
157 | ${[...this.metrics] 158 | .map(([key, metric]) => { 159 | const { explainerURL, longName, roundFn, thresholds, unit, value } = 160 | metric; 161 | let classes = ''; 162 | const roundValue = roundFn || Math.floor; 163 | const { good, needsImprovement } = thresholds; 164 | 165 | if (value) { 166 | classes += 'is-final '; 167 | let score = 'is-poor'; 168 | if (needsImprovement && value <= needsImprovement) { 169 | score = 'needs-improvement'; 170 | } 171 | if (value <= good) { 172 | score = 'is-good'; 173 | } 174 | classes += score; 175 | } 176 | 177 | return ` 178 |
179 |
180 | ${ 181 | this.hasAttribute('show-metric-name') 182 | ? `${longName} (${key})` 183 | : `${key}` 184 | } 185 |
186 |
${ 187 | value ? `${roundValue(value)}${unit ? unit : ''}` : '...' 188 | }
189 |
190 | `; 191 | }) 192 | .join('')} 193 |
194 | ${ 195 | this.unsupportedMetrics.length && 196 | this.hasAttribute('show-unsupported') 197 | ? `

Not supported: ${this.unsupportedMetrics.join(', ')}

` 198 | : '' 199 | } 200 |
`; 201 | } 202 | } 203 | 204 | export default WebVitals; 205 | --------------------------------------------------------------------------------