├── .gitignore ├── README.md ├── index.js ├── now.json ├── package.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | public 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![][hero]][hero] 3 | 4 | [hero]: https://contrast.now.sh/cff/40f?size=256 5 | 6 | # Contrast Swatch 7 | 8 | Image microservice for color contrast information 9 | 10 | [![][a]][a] 11 | [![][b]][b] 12 | [![][c]][c] 13 | 14 | [a]: https://contrast.now.sh/bcf/409 15 | [b]: https://contrast.now.sh/98f/206 16 | [c]: https://contrast.now.sh/fff/40f 17 | 18 | ## Usage 19 | 20 | Contrast swatch images can be used in any place an image is rendered. 21 | The URL accepts a foreground and background color. 22 | 23 | [`https://contrast.now.sh/cff/40f`][example] 24 | 25 | [example]: https://contrast.now.sh/cff/40f 26 | 27 | **HTML** 28 | 29 | ```html 30 | color contrast indicator 31 | ``` 32 | 33 | **Markdown** 34 | 35 | ```md 36 | ![color contrast indicator](https://contrast.now.sh/cff/07c) 37 | ``` 38 | 39 | ## React 40 | 41 | You can wrap the image in a React component (or any templating engine) for generating documentation. 42 | 43 | ```js 44 | import React from 'react' 45 | 46 | export default ({ 47 | foreground, 48 | background, 49 | ...props 50 | }) => 51 | color contrast indicator 56 | ``` 57 | 58 | ## RGB 59 | 60 | Compare two `rgb` values, or an `rgb` and a hex value: 61 | 62 | ``` 63 | https://contrast.now.sh/rgb(204,255,255)/40f 64 | ``` 65 | 66 | [![][rgb]][rgb] 67 | 68 | [rgb]: https://contrast.now.sh/rgb(204,255,255)/40f 69 | 70 | ## Customization 71 | 72 | Use URL queries to customize the styles. 73 | 74 | ``` 75 | https://contrast.now.sh/cff/40f?width=256&height=96&fontSize=1.25 76 | ``` 77 | 78 | **Pass/Fail Label** 79 | 80 | [![][pass]][pass] 81 | [![][fail]][fail] 82 | 83 | [pass]: https://contrast.now.sh/cff/40f?width=256&height=128&label=1 84 | [fail]: https://contrast.now.sh/a6f/40f?width=256&height=128&label=1 85 | 86 | **Font Size** 87 | 88 | [![][smallfont]][smallfont] 89 | [![][largefont]][largefont] 90 | 91 | [smallfont]: https://contrast.now.sh/cff/40f?width=256&height=128&fontSize=0.5 92 | [largefont]: https://contrast.now.sh/cff/40f?width=256&height=128&fontSize=2 93 | 94 | **Size** 95 | 96 | [![][large]][large] 97 | [![][small]][small] 98 | 99 | [large]: https://contrast.now.sh/cff/40f?size=320 100 | [small]: https://contrast.now.sh/cff/40f?size=48 101 | 102 | **Width & Height** 103 | 104 | [![][wide]][wide] 105 | [![][tall]][tall] 106 | 107 | [wide]: https://contrast.now.sh/cff/40f?width=256&height=48 108 | [tall]: https://contrast.now.sh/cff/40f?width=32&height=48 109 | 110 | **Custom Text** 111 | 112 | [![][text]][text] 113 | 114 | [text]: https://contrast.now.sh/cff/40f?width=256&text=Aa 115 | 116 | **Font Weight** 117 | 118 | [![][weight]][weight] 119 | 120 | [weight]: https://contrast.now.sh/cff/40f?fontWeight=900&width=256 121 | 122 | **Radius** 123 | 124 | [![][rounded]][rounded] 125 | [![][circle]][circle] 126 | 127 | [rounded]: https://contrast.now.sh/cff/40f?radius=8 128 | [circle]: https://contrast.now.sh/cff/40f?radius=48 129 | 130 | ## Options 131 | 132 | 133 | Option | Description 134 | ---|--- 135 | `size` | Width & height in pixels 136 | `width` | Width of image in pixels 137 | `height` | Height of image in pixels (font size will scale based on height) 138 | `fontSize` | Relative font size (default 1) 139 | `fontWeight`| Font weight (default 1) 140 | `label` | Show a pass/fail label based on the [WCAG Criteria][wcag] 141 | `radius` | Border radius 142 | `baseline` | Shift text baseline down 143 | `text` | Render any custom text 144 | 145 | ## Metadata 146 | 147 | A JSON response with color contrast information can be fetched by adding the `type=json` query to the URL. 148 | 149 | ``` 150 | https://contrast.now.sh/cff/40f?type=json 151 | ``` 152 | 153 | **Note:** the returned JSON schema might change in a future version 154 | 155 | [wcag]: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html 156 | 157 | ## Related 158 | 159 | - [Colorable](https://colorable.jxnblk.com) 160 | - [Use Contrast](https://usecontrast.com/) 161 | 162 | MIT License 163 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const Color = require('color') 2 | const { createElement: h } = require('react') 3 | const { renderToStaticMarkup } = require('react-dom/server') 4 | 5 | const homepage = 'https://github.com/jxnblk/contrast-swatch' 6 | 7 | const parseURL = (req) => { 8 | const { foreground, background, ...query } = req.query 9 | 10 | if (!foreground || !background) return null 11 | 12 | return { 13 | foreground, 14 | background, 15 | query, 16 | } 17 | } 18 | 19 | const HEX = /^[A-Fa-f0-9]{3,6}$/ 20 | 21 | const getColor = (raw) => { 22 | if (HEX.test(raw)) raw = '#' + raw 23 | try { 24 | return Color(raw) 25 | } catch (e) { 26 | return null 27 | } 28 | } 29 | 30 | const getLabel = contrast => { 31 | if (contrast >= 7) return 'AAA' 32 | if (contrast >= 4.5) return 'AA' 33 | if (contrast >= 3) return 'lg' 34 | return 'Fail' 35 | } 36 | 37 | const parseColors = (data) => { 38 | const foreground = getColor(data.foreground) 39 | const background = getColor(data.background) 40 | const contrast = foreground.contrast(background) 41 | const label = getLabel(contrast) 42 | 43 | return { 44 | foreground, 45 | background, 46 | raw: data, 47 | hex: { 48 | foreground: foreground.hex(), 49 | background: background.hex(), 50 | }, 51 | rgb: { 52 | foreground: foreground.rgb().array(), 53 | background: background.rgb().array(), 54 | }, 55 | contrast, 56 | label, 57 | } 58 | } 59 | 60 | const round = n => Math.floor(100 * n) / 100 61 | 62 | const svg = req => { 63 | const data = parseURL(req) 64 | if (!data) return null 65 | const colors = parseColors(data) 66 | 67 | const opts = Object.assign({ 68 | width: 128, 69 | height: 128, 70 | font: 'system-ui,sans-serif', 71 | fontSize: 1, 72 | fontWeight: 700, 73 | baseline: 0, 74 | label: false, 75 | radius: 0, 76 | }, data.query) 77 | 78 | const width = Number(opts.size || opts.width) 79 | const height = Number(opts.size || opts.height) 80 | const xwidth = 32 * (width / height) 81 | const fontSize = Number(opts.fontSize) * 8 82 | 83 | let text = [ round(colors.contrast) ] 84 | if (Boolean(opts.label)) { 85 | text.push(colors.label) 86 | } 87 | if (opts.text) text = [opts.text] 88 | 89 | const el = h('svg', { 90 | xmlns: 'http://www.w3.org/2000/svg', 91 | width, 92 | height, 93 | viewBox: `0 0 ${xwidth} 32`, 94 | fill: colors.hex.foreground, 95 | style: { 96 | fontFamily: opts.font, 97 | fontWeight: opts.fontWeight, 98 | fontSize, 99 | }, 100 | }, 101 | h('rect', { 102 | width: xwidth, 103 | height: 32, 104 | fill: colors.hex.background, 105 | rx: opts.radius, 106 | }), 107 | h('text', { 108 | textAnchor: 'middle', 109 | x: xwidth / 2, 110 | y: 16 + Number(opts.baseline), 111 | dominantBaseline: 'middle', 112 | }, 113 | text.join(' ') 114 | ) 115 | ) 116 | 117 | const svg = renderToStaticMarkup(el) 118 | 119 | return { 120 | ...data, 121 | colors, 122 | svg, 123 | } 124 | } 125 | 126 | module.exports = async (req, res) => { 127 | const data = svg(req) 128 | 129 | if (!data) return 130 | 131 | switch (data.query.type) { 132 | case 'json': 133 | return res.send(data) 134 | } 135 | 136 | res.setHeader('Content-Type', 'image/svg+xml;charset=utf-8') 137 | res.send(data.svg) 138 | } 139 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "alias": "contrast.now.sh", 4 | "public": true, 5 | "builds": [ 6 | { 7 | "src": "index.js", 8 | "use": "@now/node" 9 | } 10 | ], 11 | "routes": [ 12 | { 13 | "src": "/", 14 | "status": 302, 15 | "headers": { 16 | "Location": "https://github.com/jxnblk/contrast-swatch" 17 | }, 18 | "continue": true 19 | }, 20 | { 21 | "src": "/(?[^/]+)/(?[^/]+)", 22 | "dest": "/index.js?foreground=$fg&background=$bg", 23 | "headers": { 24 | "Cache-Control": "max-age=604800" 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "server", 4 | "version": "0.0.1", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "now dev" 8 | }, 9 | "dependencies": { 10 | "color": "^3.1.2", 11 | "react": "^16.8.6", 12 | "react-dom": "^16.8.6" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | color-convert@^1.9.1: 6 | version "1.9.3" 7 | resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" 8 | integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== 9 | dependencies: 10 | color-name "1.1.3" 11 | 12 | color-name@1.1.3: 13 | version "1.1.3" 14 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" 15 | integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= 16 | 17 | color-name@^1.0.0: 18 | version "1.1.4" 19 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" 20 | integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== 21 | 22 | color-string@^1.5.2: 23 | version "1.5.3" 24 | resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc" 25 | integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw== 26 | dependencies: 27 | color-name "^1.0.0" 28 | simple-swizzle "^0.2.2" 29 | 30 | color@^3.1.2: 31 | version "3.1.2" 32 | resolved "https://registry.yarnpkg.com/color/-/color-3.1.2.tgz#68148e7f85d41ad7649c5fa8c8106f098d229e10" 33 | integrity sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg== 34 | dependencies: 35 | color-convert "^1.9.1" 36 | color-string "^1.5.2" 37 | 38 | is-arrayish@^0.3.1: 39 | version "0.3.2" 40 | resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" 41 | integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== 42 | 43 | "js-tokens@^3.0.0 || ^4.0.0": 44 | version "4.0.0" 45 | resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" 46 | integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 47 | 48 | loose-envify@^1.1.0, loose-envify@^1.4.0: 49 | version "1.4.0" 50 | resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" 51 | integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== 52 | dependencies: 53 | js-tokens "^3.0.0 || ^4.0.0" 54 | 55 | object-assign@^4.1.1: 56 | version "4.1.1" 57 | resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" 58 | integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= 59 | 60 | prop-types@^15.6.2: 61 | version "15.7.2" 62 | resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" 63 | integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== 64 | dependencies: 65 | loose-envify "^1.4.0" 66 | object-assign "^4.1.1" 67 | react-is "^16.8.1" 68 | 69 | react-dom@^16.8.6: 70 | version "16.8.6" 71 | resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.6.tgz#71d6303f631e8b0097f56165ef608f051ff6e10f" 72 | integrity sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA== 73 | dependencies: 74 | loose-envify "^1.1.0" 75 | object-assign "^4.1.1" 76 | prop-types "^15.6.2" 77 | scheduler "^0.13.6" 78 | 79 | react-is@^16.8.1: 80 | version "16.8.6" 81 | resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16" 82 | integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA== 83 | 84 | react@^16.8.6: 85 | version "16.8.6" 86 | resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe" 87 | integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw== 88 | dependencies: 89 | loose-envify "^1.1.0" 90 | object-assign "^4.1.1" 91 | prop-types "^15.6.2" 92 | scheduler "^0.13.6" 93 | 94 | scheduler@^0.13.6: 95 | version "0.13.6" 96 | resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.6.tgz#466a4ec332467b31a91b9bf74e5347072e4cd889" 97 | integrity sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ== 98 | dependencies: 99 | loose-envify "^1.1.0" 100 | object-assign "^4.1.1" 101 | 102 | simple-swizzle@^0.2.2: 103 | version "0.2.2" 104 | resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" 105 | integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo= 106 | dependencies: 107 | is-arrayish "^0.3.1" 108 | --------------------------------------------------------------------------------