├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── example ├── .npmignore ├── index.html ├── index.tsx ├── package.json ├── tsconfig.json └── yarn.lock ├── header.png ├── package.json ├── src └── index.ts ├── test └── index.test.ts ├── tsconfig.json └── yarn.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 12 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | nodeModules- 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | env: 27 | CI: true 28 | 29 | - name: Test 30 | run: yarn test --ci --coverage --maxWorkers=2 31 | env: 32 | CI: true 33 | 34 | - name: Build 35 | run: yarn build 36 | env: 37 | CI: true 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Fatih Kalifa 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | [![theme-in-css](https://user-images.githubusercontent.com/1614415/80961549-5ba25e80-8e35-11ea-8ee3-0a709439f15a.png)](.) 6 | 7 | [![npm](https://badgen.net/npm/v/theme-in-css)](https://www.npmjs.com/package/theme-in-css) 8 | [![npm-dl](https://badgen.net/npm/dt/theme-in-css)](https://www.npmjs.com/package/theme-in-css) 9 | 10 |
11 | 12 | --- 13 | 14 | - [Why?](#why) 15 | - [Usage](#usage) 16 | - [CSS Integration](#css-integration) 17 | - [.css.string: `string`](#cssstring-string) 18 | - [.css.properties: `Array<[key: string, value: string]>`](#cssproperties-arraykey-string-value-string) 19 | - [License](#license) 20 | 21 | --- 22 | 23 | ## Why? 24 | 25 | - **Strictly-typed design token, no more typo**. As you might know: CSS is forgiving, but in this case we don't want that. We want it to fail at compile time and this library gives you that 26 | - **Works in CSS and JS**. Using CSS custom properties means you can reference your design token in both CSS and JS. Separation of concern! 27 | - **UI Library agnostic**. The only purpose of this library is to provide type-safe CSS custom properties in your TS modules, nothing less, nothing more. 28 | 29 | ## Usage 30 | 31 | First create your theme object, you can group theme variables by its function (color, spacing, etc). Think of it as design token 32 | 33 | ```ts 34 | import { createTheme } from 'theme-in-css'; 35 | 36 | export const Theme = createTheme({ 37 | color: { 38 | lightPrimary: '#fff', 39 | darkPrimary: '#000', 40 | }, 41 | spacing: { 42 | xs: 2, 43 | s: 4, 44 | m: 8, 45 | l: 16, 46 | xl: 32, 47 | }, 48 | typography: { 49 | family: { 50 | serif: 'Times New Roman', 51 | sans: 'Calibri', 52 | mono: 'Menlo', 53 | }, 54 | } 55 | }); 56 | 57 | // If you hate typing you can also use a shorter property name 58 | // const t = createTheme({ c: { l1: '#fff', d1: '#000' } }); 59 | ``` 60 | 61 | You can use any any UI libraries/framework that can define style in JS/TS, for example [React](https://reactjs.org) and [Lit](https://lit.dev/). 62 | 63 | ```tsx 64 | // React 65 | import React from 'react'; 66 | import { Theme } from './theme'; 67 | 68 | export default function Component() { 69 | // use css prop via emotion/styled-components 70 | // of course inline style works as well 71 | return ( 72 |
80 |

It works

81 |
82 | ); 83 | } 84 | 85 | // Lit 86 | // You need to wrap Theme inside `unsafeCSS` 87 | import { LitElement, html, css, unsafeCSS as cv } from 'lit'; 88 | import { Theme } from './theme'; 89 | 90 | export default class Component extends LitElement { 91 | static styles = css` 92 | div { 93 | background-color: ${cv(Theme.color.darkPrimary)}; 94 | color: ${cv(Theme.color.lightPrimary)}; 95 | margin: ${cv(Theme.spacing.m)}; 96 | font-family: ${cv(Theme.typography.family.serif)}; 97 | } 98 | `; 99 | 100 | render() { 101 | return html` 102 |
103 |

It works

104 |
105 | `; 106 | } 107 | } 108 | ``` 109 | 110 | ## CSS Integration 111 | 112 | If you only create theme and use them in your app, you'll notice that your app now uses CSS variables to reference a value, but it doesn't work properly yet because you need to add the CSS into your stylesheet. 113 | 114 | ### .css.string: `string` 115 | 116 | `theme-in-css` provides `.css.string` property to dump all theme values as CSS properties. You can create 2 themes light and dark and output them in different style declaration, like this: 117 | 118 | ```ts 119 | import { Theme, DarkTheme } from './theme'; 120 | 121 | const html = ` 122 | 123 | 124 | 125 | 134 | 135 | 136 | 137 | 138 | `; 139 | ``` 140 | 141 | You can open [example](example) to see it in action. 142 | 143 | ### .css.properties: `Array<[key: string, value: string]>` 144 | 145 | You can also use `.css.properties` if you want to update the CSS custom property manually using JS. 146 | 147 | ```js 148 | const root = document.documentElement; 149 | 150 | theme.css.properties.forEach(([key, value]) => { 151 | root.style.setProperty(key, value); 152 | }); 153 | ``` 154 | 155 | If you prefer `Record` instead, you can use `Object.fromEntries` 156 | 157 | ```js 158 | const obj = Object.fromEntries(theme.css.properties); 159 | ``` 160 | 161 | ## License 162 | 163 | MIT 164 | -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | import { createTheme } from '../.'; 5 | 6 | const Theme = createTheme({ 7 | color: { 8 | lightPrimary: '#fff', 9 | darkPrimary: '#000', 10 | } 11 | }); 12 | 13 | const DarkTheme = createTheme({ 14 | color: { 15 | lightPrimary: '#000', 16 | darkPrimary: '#fff', 17 | } 18 | }) 19 | 20 | const style = document.createElement('style'); 21 | style.innerHTML = ` 22 | :root { 23 | ${Theme.css.string} 24 | } 25 | 26 | @media (prefers-color-scheme: dark) { 27 | :root { 28 | ${DarkTheme.css.string} 29 | } 30 | } 31 | ` 32 | 33 | document.head.appendChild(style) 34 | 35 | const App = () => { 36 | return ( 37 |
38 |

It Works

39 |
40 | ); 41 | }; 42 | 43 | ReactDOM.render(, document.getElementById('root')); 44 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^1.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.9.11", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^1.12.3", 22 | "typescript": "^3.4.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "baseUrl": ".", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pveyes/theme-in-css/46336bec12c0a826290a29db32fa2f1561c0bb60/header.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "theme-in-css", 3 | "author": "Fatih Kalifa", 4 | "version": "0.2.2", 5 | "license": "MIT", 6 | "main": "dist/index.js", 7 | "module": "dist/theme-in-css.esm.js", 8 | "typings": "dist/index.d.ts", 9 | "files": [ 10 | "dist", 11 | "src" 12 | ], 13 | "engines": { 14 | "node": ">=10" 15 | }, 16 | "scripts": { 17 | "start": "tsdx watch", 18 | "build": "tsdx build", 19 | "format": "prettier --write \"**/*.ts\"", 20 | "test": "tsdx test --passWithNoTests", 21 | "lint": "tsdx lint", 22 | "prepare": "tsdx build" 23 | }, 24 | "devDependencies": { 25 | "@types/react": "^17.0.11", 26 | "@types/react-dom": "^17.0.8", 27 | "husky": "^4.2.5", 28 | "react": "^17.0.2", 29 | "react-dom": "^17.0.2", 30 | "tsdx": "^0.14.1", 31 | "tslib": "^2.3.0", 32 | "typescript": "^4.3.4" 33 | }, 34 | "husky": { 35 | "hooks": { 36 | "pre-commit": "exit 0" 37 | } 38 | }, 39 | "prettier": { 40 | "printWidth": 80, 41 | "semi": true, 42 | "singleQuote": true, 43 | "trailingComma": "es5" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | type ThemeValue = string | number; 2 | 3 | interface ThemeValueMap { 4 | [key: string]: ThemeValue; 5 | }; 6 | 7 | interface ThemeConfig { 8 | [token: string]: ThemeValueMap | ThemeConfig | ThemeValue; 9 | }; 10 | 11 | interface ThemeCSSObject { 12 | css: { 13 | string: string; 14 | properties: Array<[string, string]>; 15 | }; 16 | }; 17 | 18 | type ThemeVariableValueMap = { 19 | [key in keyof Map]: Map[key] extends Record 20 | ? ThemeVariableValueMap 21 | : string; 22 | } 23 | 24 | type ThemeVariables = { 25 | [token in keyof Theme]: ThemeVariableValueMap 26 | }; 27 | 28 | type Theme = ThemeVariables & ThemeCSSObject; 29 | 30 | export function createTheme(config: Shape): Theme { 31 | const cssEntries = generateCSSEntries(config); 32 | const variables = createThemeVariables(config); 33 | 34 | const theme = { 35 | css: { 36 | string: cssEntries 37 | .flatMap(({ key, value }) => `${key}: ${value};`) 38 | .join('\n'), 39 | properties: cssEntries.map(({ key, value }) => [key, value]) as Array< 40 | [string, string] 41 | >, 42 | }, 43 | }; 44 | 45 | return Object.assign(theme, variables); 46 | } 47 | 48 | function generateCSSEntries( 49 | config: T 50 | ): Array<{ key: string; value: string }> { 51 | return Object.keys(config).flatMap(configKey => { 52 | const value = config[configKey]; 53 | if (typeof value === 'object') { 54 | return createCSSEntries(value, configKey); 55 | } 56 | 57 | return { key: toCSSProperty(configKey, ''), value: toCSSValue(value) }; 58 | }); 59 | } 60 | 61 | function createCSSEntries( 62 | map: T, 63 | prefix = '' 64 | ): Array<{ key: string; value: string }> { 65 | return Object.keys(map) 66 | .map(prop => { 67 | const value = map[prop]; 68 | if (typeof value === 'string' || typeof value === 'number') { 69 | const key = toCSSProperty(prop, prefix); 70 | return { key, value: toCSSValue(value) }; 71 | } 72 | 73 | return createCSSEntries(value, prefix + '-' + prop) as any; 74 | }) 75 | .flat(Infinity); 76 | } 77 | 78 | function createThemeVariables( 79 | config: T, 80 | prefix = '' 81 | ): ThemeVariables { 82 | const variables = Object.create(null); 83 | 84 | for (const key in config) { 85 | const v = config[key]; 86 | if (typeof v !== 'object') { 87 | variables[key] = toCSSVariable(key, prefix); 88 | } else { 89 | variables[key] = createThemeVariables( 90 | // @ts-ignore 91 | v, 92 | prefix === '' ? key : prefix + '-' + key 93 | ); 94 | } 95 | } 96 | 97 | return variables; 98 | } 99 | 100 | function toCSSVariable(key: string, prefix: string) { 101 | return `var(${toCSSProperty(key, prefix)})`; 102 | } 103 | 104 | function camelCaseToHypen(str: string) { 105 | return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); 106 | } 107 | 108 | function toCSSProperty(key: string, prefix: string) { 109 | if (prefix == '') { 110 | return `--${camelCaseToHypen(key)}`; 111 | } 112 | 113 | return `--${prefix + '-' + camelCaseToHypen(key)}`; 114 | } 115 | 116 | function toCSSValue(value: string | number) { 117 | if (typeof value === 'string') { 118 | return value; 119 | } 120 | 121 | return value + 'px'; 122 | } 123 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from '../src'; 2 | 3 | describe('it', () => { 4 | const themeConfig = { 5 | color: { 6 | lightPrimary: '#fff', 7 | darkPrimary: '#000', 8 | }, 9 | }; 10 | 11 | const theme = createTheme(themeConfig); 12 | 13 | it('returns css variable', () => { 14 | expect(theme.color.lightPrimary).toBe('var(--color-light-primary)'); 15 | expect(theme.color.darkPrimary).toBe('var(--color-dark-primary)'); 16 | }); 17 | 18 | it('outputs css string', () => { 19 | expect(theme.css.string).toBe( 20 | ` 21 | --color-light-primary: #fff; 22 | --color-dark-primary: #000; 23 | `.trim() 24 | ); 25 | }); 26 | 27 | it('outputs css properties', () => { 28 | expect(theme.css.properties).toEqual([ 29 | ['--color-light-primary', '#fff'], 30 | ['--color-dark-primary', '#000'], 31 | ]); 32 | }); 33 | 34 | it('allow single depth', () => { 35 | const theme = createTheme({ 36 | firstItem: 'lalalala', 37 | secondItem: 12345 38 | }); 39 | 40 | expect(theme.firstItem).toBe('var(--first-item)'); 41 | expect(theme.secondItem).toBe('var(--second-item)'); 42 | expect(theme.css.properties).toEqual([ 43 | ['--first-item', 'lalalala'], 44 | ['--second-item', '12345px'], 45 | ]); 46 | }) 47 | 48 | it('allow arbitrary depth', () => { 49 | const theme = createTheme({ 50 | a: { 51 | b: { 52 | c: { 53 | d: { 54 | e: 'abcde', 55 | }, 56 | }, 57 | f: { 58 | g: 'abfg', 59 | }, 60 | }, 61 | h: { 62 | i: { 63 | j: { 64 | k: 'ahijk', 65 | }, 66 | }, 67 | }, 68 | }, 69 | }); 70 | 71 | expect(theme.a.b.c.d.e).toEqual('var(--a-b-c-d-e)'); 72 | expect(theme.a.b.f.g).toEqual('var(--a-b-f-g)'); 73 | expect(theme.a.h.i.j.k).toEqual('var(--a-h-i-j-k)'); 74 | 75 | expect(theme.css.string.split('\n')).toEqual( 76 | expect.arrayContaining(['--a-b-c-d-e: abcde;']) 77 | ); 78 | expect(theme.css.string.split('\n')).toEqual( 79 | expect.arrayContaining(['--a-b-f-g: abfg;']) 80 | ); 81 | expect(theme.css.string.split('\n')).toEqual( 82 | expect.arrayContaining(['--a-h-i-j-k: ahijk;']) 83 | ); 84 | }); 85 | 86 | // TODO enable using jest 25, jsdom 16, and cssstyle v2 87 | it.skip('matches between CSS string and variable', () => { 88 | const root = document.documentElement; 89 | theme.css.properties.forEach(([key, value]) => { 90 | root.style.setProperty(key, value); 91 | }); 92 | 93 | root.style.setProperty('color', theme.color.darkPrimary); 94 | const computedStyle = window.getComputedStyle(root); 95 | 96 | expect(computedStyle.color).toBe(themeConfig.color.darkPrimary); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["dom", "esnext"], 6 | "importHelpers": true, 7 | "declaration": true, 8 | "sourceMap": true, 9 | "rootDir": "./src", 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "baseUrl": "./", 17 | "paths": { 18 | "*": ["src/*", "node_modules/*"] 19 | }, 20 | "jsx": "react", 21 | "esModuleInterop": true 22 | } 23 | } 24 | --------------------------------------------------------------------------------