├── 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 |
63 | Fancy Button
64 |
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
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 | [](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 |
--------------------------------------------------------------------------------