├── src ├── vite-env.d.ts ├── counter.ts └── main.ts ├── .prettierrc ├── postcss.config.js ├── .eslintrc.cjs ├── .npmignore ├── .gitignore ├── .github └── workflows │ ├── lint.yml │ ├── format.yml │ └── publish.yml ├── tsconfig.json ├── vite.config.js ├── package.json ├── index.html └── README.md /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 4, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | 'postcss-nesting': { 4 | /* plugin options */ 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 4 | parser: '@typescript-eslint/parser', 5 | plugins: ['@typescript-eslint'], 6 | root: true 7 | } 8 | -------------------------------------------------------------------------------- /src/counter.ts: -------------------------------------------------------------------------------- 1 | export function setupCounter(element: HTMLButtonElement) { 2 | let counter = 0 3 | const setCounter = (count: number) => { 4 | counter = count 5 | element.innerHTML = `count is ${counter}` 6 | } 7 | element.addEventListener('click', () => setCounter(counter + 1)) 8 | setCounter(0) 9 | } 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist-ssr 12 | *.local 13 | 14 | # Editor directories and files 15 | .vscode/* 16 | !.vscode/extensions.json 17 | .idea 18 | .DS_Store 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Install modules 13 | run: npm install 14 | - name: Run ESLint 15 | run: npx eslint . --ext .js,.jsx,.ts,.tsx 16 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Formatting 2 | 3 | # This action works with pull requests and pushes 4 | on: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | prettier: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Prettify code 19 | uses: creyD/prettier_action@v4.3 20 | with: 21 | # This part is also where you can pass other options, for example: 22 | prettier_options: --write **/*.{js,md} 23 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | # Setup .npmrc file to publish to npm 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: 18 14 | registry-url: 'https://registry.npmjs.org' 15 | - run: npm ci 16 | - run: npm run build 17 | - run: npm run release 18 | env: 19 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true 21 | }, 22 | "include": ["src"] 23 | } 24 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | // vite.config.js 2 | import { resolve } from 'path' 3 | import { defineConfig } from 'vite' 4 | import { libInjectCss } from 'vite-plugin-lib-inject-css' 5 | import path from 'path' 6 | import { fileURLToPath } from 'url' 7 | 8 | const __filename = fileURLToPath(import.meta.url) 9 | 10 | export default defineConfig({ 11 | plugins: [ 12 | libInjectCss() // For a simple usage 13 | ], 14 | build: { 15 | lib: { 16 | // Could also be a dictionary or array of multiple entry points 17 | entry: resolve(path.dirname(__filename), 'src/main.ts'), 18 | name: 'nightowl', 19 | // the proper extensions will be added 20 | fileName: 'nightowl' 21 | }, 22 | rollupOption: { 23 | output: { 24 | intro: 'import "./style.css";' 25 | } 26 | } 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bufferhead/nightowl", 3 | "version": "0.0.14", 4 | "type": "module", 5 | "license": "MIT", 6 | "main": "dist/nightowl.js", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "preview": "vite preview", 11 | "release": "npm publish --access public", 12 | "lint:fix": "npx eslint . --ext .ts,.tsx --fix", 13 | "lint": "npx eslint . --ext .ts,.tsx", 14 | "format": "prettier --write ." 15 | }, 16 | "devDependencies": { 17 | "@typescript-eslint/eslint-plugin": "^6.13.1", 18 | "@typescript-eslint/parser": "^6.13.1", 19 | "eslint": "^8.55.0", 20 | "postcss": "^8.4.31", 21 | "postcss-nesting": "^12.0.1", 22 | "prettier": "3.1.0", 23 | "typescript": "^5.3.2", 24 | "vite": "^5.0.0", 25 | "vite-plugin-lib-inject-css": "^1.3.0" 26 | }, 27 | "sideEffects": [ 28 | "**/*.css" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + TS 8 | 16 | 35 | 36 | 37 |
38 |

Nightowl Demo Site

39 |
49 |

A Card with some text

50 | 65 |
66 |
67 | 68 | 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](https://github.com/bufferhead-code/nightowl/assets/6266887/6dbd652a-0307-4d2b-ac9e-26230b8b59c7) 2 | 3 | > [!NOTE] 4 | > Check out **solidtime - The modern Open Source Time-Tracker** at [solidtime.io](https://www.solidtime.io) 5 | 6 | ## Nightowl 7 | 8 | A "micro-framework" (\*hacky script) that adds dark mode to any website with a single line of code. 9 | 10 | **You can learn more about how it works and how I made it [here](http://www.youtube.com/watch?v=JONzCyVXa60)**. 11 | 12 | [![Youtube Video about how this project was made](http://img.youtube.com/vi/JONzCyVXa60/0.jpg)](http://www.youtube.com/watch?v=JONzCyVXa60 'Add Dark Mode to any Website with a single line of code') 13 | 14 | ## State of the Project 15 | 16 | This project is still in a prototyping stage, and the API is still subject to change. 17 | Please only use it with a fixed minor version. 18 | 19 | ## Known Issues 20 | 21 | - Position absolute and position fixed elements might not work as expected. 22 | - The toggle button overlay has weird paddings sometimes. 23 | 24 | ## Roadmap (Maybe) 25 | 26 | - [ ] Add more utility classes to improve contrast. 27 | - [ ] Add better support for `box-shadow`. 28 | 29 | ## Integration 30 | 31 | Integration can be achieved by one of the following methods. 32 | 33 | ### CDN 34 | 35 | Add these lines to your HTML file: 36 | 37 | ```html 38 | 42 | ``` 43 | 44 | ### npm 45 | 46 | To use nightowl with a bundler like Vite first install it with this command: 47 | 48 | ```shell 49 | npm install @bufferhead/nightowl 50 | ``` 51 | 52 | Then add these lines to your HTML file: 53 | 54 | ```html 55 | 63 | ``` 64 | 65 | ## Configuration Options 66 | 67 | ### defaultMode 68 | 69 | - **Type:** `'light' | 'dark'` 70 | - **Default:** `'light'` 71 | 72 | Sets the default mode for users that have not set a preference yet and do not have a system preference for dark mode. 73 | 74 | ### toggleButtonMode 75 | 76 | - **Type:** `'currentState' | 'newState'` 77 | - **Default:** `'currentState'` 78 | 79 | Configures what state of the toggle button should be shown to the user. 80 | 81 | - `currentState` - Shows the state that is currently applied to the website. 82 | - `newState` - Shows the state that will be applied when the user clicks the button. 83 | 84 | ## Customize Dark Mode 85 | 86 | You can exclude elements from being inverted in dark mode using the `.nightowl-daylight` CSS class. Just add it to an element and it will show the element in the same way as the light mode. 87 | 88 | ```html 89 |
90 |

I'm inverted in Dark Mode

91 |

I'm not inverted in Dark Mode

92 |
93 | ``` 94 | 95 | ## Contribution Guidelines 96 | 97 | Please open an issue and wait for one of the Maintainers to approve it until you open a merge request. 98 | 99 | ## Credits 100 | 101 | This project is heavily inspired by Aral Balkan who [wrote down this idea to implement dark mode in a few lines of CSS using CSS Filters](https://ar.al/2021/08/24/implementing-dark-mode-in-a-handful-of-lines-of-css-with-css-filters/). 102 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | const LOCAL_STORAGE_KEY = 'nightowl-color-scheme' 2 | const LIGHT = 'light' 3 | const DARK = 'dark' 4 | let store: Storage | null = null 5 | const persistPreference = true 6 | let mode = LIGHT 7 | let automaticInitialization = true 8 | let toggleButtonMode = 'currentState' 9 | interface NightowlOptions { 10 | defaultMode?: 'light' | 'dark' 11 | toggleButtonMode?: 'currentState' | 'newState' 12 | } 13 | 14 | try { 15 | store = localStorage 16 | } catch (err) { 17 | // Do nothing. The user probably blocks cookies. 18 | } 19 | 20 | function loadCss() { 21 | // fix css loading via vite 22 | // https://github.com/vitejs/vite/issues/8976 23 | const css = document.createElement('style') 24 | css.innerHTML = ` 25 | /* Prevent inconsistencies for positioning */ 26 | .nightowl-light body { 27 | filter: invert(0%); 28 | } 29 | 30 | .nightowl-dark { 31 | /* Firefox fallback. */ 32 | background-color: #111; 33 | } 34 | 35 | .nightowl-dark body { 36 | filter: invert(100%) hue-rotate(180deg); 37 | } 38 | 39 | /* Do not invert media (revert the invert). */ 40 | .nightowl-dark img, .nightowl-dark video, .nightowl-dark iframe, .nightowl-dark .nightowl-daylight { 41 | filter: invert(100%) hue-rotate(180deg); 42 | } 43 | 44 | /* Improve contrast on icons. */ 45 | .nightowl-dark .icon { 46 | filter: invert(15%) hue-rotate(180deg); 47 | } 48 | 49 | /* Re-enable code block backgrounds. */ 50 | .nightowl-dark pre { 51 | filter: invert(6%); 52 | } 53 | 54 | /* Improve contrast on list item markers. */ 55 | .nightowl-dark li::marker { 56 | color: #666; 57 | } 58 | ` 59 | document.head.appendChild(css) 60 | } 61 | 62 | export function createNightowl(options: NightowlOptions) { 63 | automaticInitialization = false 64 | if (options.defaultMode === 'dark') { 65 | mode = DARK 66 | } 67 | if (options.toggleButtonMode) { 68 | toggleButtonMode = options.toggleButtonMode 69 | } 70 | if (document.readyState === 'complete') { 71 | loadCss() 72 | initializeNightowl() 73 | initializeSwitcher() 74 | } else { 75 | window.addEventListener('load', () => { 76 | loadCss() 77 | initializeNightowl() 78 | initializeSwitcher() 79 | }) 80 | } 81 | } 82 | 83 | window.addEventListener('load', () => { 84 | if (automaticInitialization) { 85 | loadCss() 86 | initializeNightowl() 87 | initializeSwitcher() 88 | } 89 | }) 90 | 91 | function enableDarkMode() { 92 | mode = DARK 93 | const htmlElement = document.querySelector('html') 94 | if (htmlElement) { 95 | htmlElement.classList.remove('nightowl-light') 96 | htmlElement.classList.add('nightowl-dark') 97 | } 98 | } 99 | 100 | function enableLightMode() { 101 | mode = LIGHT 102 | const htmlElement = document.querySelector('html') 103 | if (htmlElement) { 104 | htmlElement.classList.remove('nightowl-dark') 105 | htmlElement.classList.add('nightowl-light') 106 | } 107 | } 108 | 109 | function toggleMode() { 110 | mode = mode === DARK ? LIGHT : DARK 111 | updateMode() 112 | } 113 | 114 | function updateMode() { 115 | if (mode === DARK) { 116 | enableDarkMode() 117 | } else { 118 | enableLightMode() 119 | } 120 | setSwitcherIcon() 121 | } 122 | 123 | function setSwitcherIcon() { 124 | const switcher = document.getElementById('nightowl-switcher-default') 125 | if (switcher) { 126 | const lightIcon = 127 | '\n' + 128 | ' \n' + 129 | '' 130 | const darkIcon = 131 | '\n' + 132 | ' \n' + 133 | '' 134 | 135 | if (toggleButtonMode === 'newState') { 136 | switcher.innerHTML = mode === DARK ? lightIcon : darkIcon 137 | } else if (toggleButtonMode === 'currentState') { 138 | switcher.innerHTML = mode === DARK ? darkIcon : lightIcon 139 | } 140 | } 141 | } 142 | 143 | function initializeSwitcher() { 144 | const switcher = document.createElement('div') 145 | console.log(window.innerWidth) 146 | switcher.id = 'nightowl-switcher-default' 147 | switcher.style.position = 'fixed' 148 | switcher.style.left = 'calc(100vw - 100px)' 149 | switcher.style.top = 'calc(10px)' 150 | switcher.style.width = '50px' 151 | switcher.style.height = '50px' 152 | switcher.style.borderRadius = '50%' 153 | switcher.style.backgroundColor = 154 | toggleButtonMode === 'newState' ? 'black' : 'white' 155 | switcher.style.display = 'flex' 156 | switcher.style.justifyContent = 'center' 157 | switcher.style.alignItems = 'center' 158 | switcher.style.cursor = 'pointer' 159 | switcher.style.zIndex = '9999' 160 | switcher.style.boxShadow = '0 0 10px rgba(0,0,0,0.2)' 161 | switcher.style.transition = 'all 0.3s ease-in-out' 162 | switcher.style.overflow = 'hidden' 163 | switcher.style.color = toggleButtonMode === 'newState' ? 'white' : 'black' 164 | 165 | switcher.addEventListener('click', () => { 166 | toggleMode() 167 | storeModeInLocalStorage() 168 | }) 169 | 170 | document.body.appendChild(switcher) 171 | setSwitcherIcon() 172 | } 173 | 174 | function initializeColorSchemeChangeListener() { 175 | // window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => { 176 | // const newColorScheme = e.matches ? "dark" : "light"; 177 | // }); 178 | } 179 | 180 | function checkForRememberedValue() { 181 | let rememberedValue = null 182 | try { 183 | if (store) { 184 | rememberedValue = store.getItem(LOCAL_STORAGE_KEY) 185 | } 186 | } catch (err) { 187 | // Do nothing. The user probably blocks cookies. 188 | } 189 | 190 | if (rememberedValue && [DARK, LIGHT].includes(rememberedValue)) { 191 | mode = rememberedValue 192 | } else if (hasNativeDarkPrefersColorScheme()) { 193 | mode = DARK 194 | } 195 | } 196 | 197 | function initializeNightowl() { 198 | initializeColorSchemeChangeListener() 199 | 200 | checkForRememberedValue() 201 | 202 | updateMode() 203 | } 204 | 205 | function storeModeInLocalStorage() { 206 | if (persistPreference && mode !== null) { 207 | try { 208 | if (store) { 209 | store.setItem(LOCAL_STORAGE_KEY, mode) 210 | } 211 | } catch (err) { 212 | // Do nothing. The user probably blocks cookies. 213 | } 214 | } 215 | } 216 | 217 | function hasNativeDarkPrefersColorScheme() { 218 | return ( 219 | window.matchMedia && 220 | (window.matchMedia('(prefers-color-scheme: dark)').matches || 221 | window.matchMedia('(prefers-color-scheme:dark)').matches) 222 | ) 223 | } 224 | --------------------------------------------------------------------------------