├── .gitignore ├── LICENSE ├── README.md ├── media ├── diagram.png └── glow.gif ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── index.tsx └── tailwind.js ├── tsconfig.json └── tsdx.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mordechai Meisels 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 | # React glow 2 | 3 | Add a mouse-tracing glow effect to React components. 4 | 5 | ![gif of glow effect](./media/glow.gif) 6 | 7 | The glow effect will only work using the mouse as the pointer. Touch events will not trigger it. 8 | 9 | See it live on [codaworks.com](https://codaworks.com). 10 | 11 | 12 | ## Installation 13 | Install the package with npm: 14 | 15 | ```shell 16 | npm i @codaworks/react-glow 17 | ``` 18 | 19 | ## Usage 20 | Wrap any number of `` components in a `` which will be used to track the mouse location. 21 | 22 | 23 | ```jsx 24 | 25 | This won't glow 26 | 27 | 28 | This will glow purple when the mouse is passed over 29 | 30 | 31 | 32 | ``` 33 | 34 | Children of `` can style themselves how to look when glowing. You might choose to leave some children unchanged, or highlight them with the `glow:` variant style. 35 | 36 | The value of `color` will be available as a CSS variable `--glow-color`, as well as the Tailwind `glow` color. 37 | You can pass any valid CSS color, including `hsl()` values etc. 38 | Of course, you might choose to use any other color; you can leave out the `color` prop entirely. 39 | 40 | 41 | ## Tailwind 42 | Add the tailwind plugin to unlock the `glow:` variant and `glow` color 43 | 44 | `tailwind.config.js` 45 | ```js 46 | module.exports = { 47 | ... 48 | plugins: [ 49 | require('@codaworks/react-glow/tailwind') 50 | ] 51 | } 52 | ``` 53 | 54 | As with all colors in Tailwind, you may add opacity by appending a percentage after the color, such as `bg-glow/20` for 20% opacity. 55 | 56 | ## Vanilla CSS 57 | You can style the glow effect with vanilla CSS: 58 | 59 | 60 | ```jsx 61 | 62 | This won't glow 63 | 64 | 65 | This will glow pink when the mouse is passed over 66 | 67 | 68 | 69 | ``` 70 | 71 | ```css 72 | .glowable-text { 73 | color: black; 74 | } 75 | 76 | [glow] .glowable-text { 77 | color: var(--glow-color); 78 | } 79 | ``` 80 | 81 | ## How does it work? 82 | 83 | The `` component clones the children tree. The cloned tree is then stacked on top of the original tree. 84 | 85 | The overlay tree is transparent, and we only reveal parts using a CSS radial gradient mask. The mask position is updated 86 | by tracking the mouse position. 87 | When you use the `glow:` variant or `[glow]` attribute selector, it only targets the overlay. 88 | 89 | In order to not block mouse events, the overlay is set to `pointer-events: none`. 90 | 91 | We use the `` to track the mouse; the `` itself also keeps track of its position inside the ``. 92 | 93 | ### Best practices 94 | 95 | ![diagram](./media/diagram.png) 96 | 97 | The fact that we clone the children tree has some implications that is important to keep in mind: 98 | 99 | - `` children should never have side effects. Since we duplicate the children, the effects will run twice. 100 | - Keep the `` children small. Use a separate `` for each logical set of children (such as a single card). 101 | - Don't apply layout styles to `glow:`. Keep the glow styles to just visuals, such as colors, opacity, and a slight scale might also work sometimes. 102 | - Use callback refs: When you pass a ref to a glow child, the cloned version will "steal" the ref. Use a callback ref, and check if it is already set before assigning. 103 | - It might be challenging to get it to correctly work with forms, especially if you have required fields. The cloned fields will also be marked as required and failing the validation. 104 | - Use a single `` for a group of related glows. This allows you to get an overflowing glow effect when the mouse is between 2 glows. 105 | - Apply some padding between the capture and the children, to show the glow even when you leave the `` instead of abruptly cutting off the effect. 106 | 107 | 108 | ## Attribution 109 | Inspired by [this tweet](https://twitter.com/codepen/status/1696297659663888490). 110 | -------------------------------------------------------------------------------- /media/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codaworks/react-glow/e3e8cfdc9e84bfa4e096ebad18db6549fce0c0ab/media/diagram.png -------------------------------------------------------------------------------- /media/glow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codaworks/react-glow/e3e8cfdc9e84bfa4e096ebad18db6549fce0c0ab/media/glow.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codaworks/react-glow", 3 | "description": "Add a mouse-tracing glow effect to React components", 4 | "author": "Mordechai Meisels", 5 | "version": "1.0.6", 6 | "license": "MIT", 7 | "main": "dist/index.js", 8 | "module": "dist/react-glow.esm.js", 9 | "exports": { 10 | ".": "./dist/index.js", 11 | "./tailwind": "./src/tailwind.js" 12 | }, 13 | "typings": "dist/index.d.ts", 14 | "files": [ 15 | "dist", 16 | "src" 17 | ], 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/codaworks/react-glow.git" 21 | }, 22 | "keywords": [ 23 | "react", 24 | "glow" 25 | ], 26 | "bugs": { 27 | "url": "https://github.com/codaworks/react-glow/issues" 28 | }, 29 | "homepage": "https://github.com/codaworks/react-glow#readme", 30 | "engines": { 31 | "node": ">=10" 32 | }, 33 | "scripts": { 34 | "start": "tsdx watch", 35 | "build": "tsdx build", 36 | "test": "tsdx test --passWithNoTests", 37 | "lint": "tsdx lint", 38 | "prepare": "tsdx build", 39 | "size": "size-limit", 40 | "analyze": "size-limit --why" 41 | }, 42 | "devDependencies": { 43 | "@size-limit/preset-small-lib": "^9.0.0", 44 | "@types/react": "^18.2.23", 45 | "@types/react-dom": "^18.2.8", 46 | "@types/resize-observer-browser": "^0.1.8", 47 | "husky": "^8.0.3", 48 | "react": "^18.2.0", 49 | "react-dom": "^18.2.0", 50 | "rollup-plugin-banner2": "^1.2.2", 51 | "size-limit": "^9.0.0", 52 | "tailwindcss": "^3.3.3", 53 | "tsdx": "^0.14.1", 54 | "tslib": "^2.6.2", 55 | "typescript": "^3.9.10" 56 | }, 57 | "peerDependencies": { 58 | "react": ">=16", 59 | "tailwindcss": ">=3" 60 | }, 61 | "husky": { 62 | "hooks": { 63 | "pre-commit": "tsdx lint" 64 | } 65 | }, 66 | "prettier": { 67 | "printWidth": 80, 68 | "semi": true, 69 | "singleQuote": true, 70 | "trailingComma": "es5" 71 | }, 72 | "size-limit": [ 73 | { 74 | "path": "dist/react-glow.cjs.production.min.js", 75 | "limit": "10 KB" 76 | }, 77 | { 78 | "path": "dist/react-glow.esm.js", 79 | "limit": "10 KB" 80 | } 81 | ] 82 | } 83 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const resolve = require('@rollup/plugin-node-resolve') 2 | const commonjs = require('@rollup/plugin-commonjs') 3 | const packageJson = require('./package.json') 4 | 5 | module.exports = [ 6 | { 7 | input: 'src/index.js', 8 | output: [ 9 | { 10 | file: packageJson.main, 11 | format: 'cjs', 12 | sourcemap: true, 13 | }, 14 | { 15 | file: packageJson.module, 16 | format: 'esm', 17 | sourcemap: true, 18 | } 19 | ], 20 | plugins: [ 21 | resolve(), 22 | commonjs() 23 | ] 24 | } 25 | ] -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, ComponentPropsWithoutRef, useEffect, useRef } from 'react' 2 | 3 | interface GlowCaptureProps extends ComponentPropsWithoutRef<'div'> { 4 | size?: number 5 | } 6 | 7 | export const GlowCapture = (props: GlowCaptureProps) => { 8 | const { 9 | className = '', 10 | size = 400, 11 | ...rest 12 | } = props 13 | 14 | const element = useRef(null) 15 | 16 | useEffect(() => { 17 | const move = (e: PointerEvent) => { 18 | if (e.pointerType === 'mouse') { 19 | requestAnimationFrame(() => { 20 | if (!element.current) 21 | return 22 | 23 | const bounds = element.current.getBoundingClientRect() 24 | 25 | const x = e.clientX - bounds.left 26 | const y = e.clientY - bounds.top 27 | 28 | element.current.style.setProperty('--glow-x', `${x}px`) 29 | element.current.style.setProperty('--glow-y', `${y}px`) 30 | }) 31 | } 32 | } 33 | 34 | const leave = () => { 35 | element.current?.style.removeProperty('--glow-x') 36 | element.current?.style.removeProperty('--glow-y') 37 | } 38 | 39 | element.current?.addEventListener('pointermove', move, { passive: true }) 40 | element.current?.addEventListener('pointerleave', leave, { passive: true }) 41 | return () => { 42 | element.current?.removeEventListener('pointermove', move) 43 | element.current?.removeEventListener('pointerleave', leave) 44 | } 45 | }, []) 46 | 47 | return
54 | } 55 | 56 | 57 | const mask = ` 58 | radial-gradient(var(--glow-size) var(--glow-size) at calc(var(--glow-x, -99999px) - var(--glow-left, 0px)) 59 | calc(var(--glow-y, -99999px) - var(--glow-top, 0px)), #000000 1%, transparent 50%) 60 | ` 61 | 62 | interface GlowProps extends ComponentPropsWithoutRef<'div'> { 63 | color?: string, 64 | debug?: boolean 65 | } 66 | 67 | export const Glow = (props: GlowProps) => { 68 | 69 | const { 70 | className = '', 71 | style, 72 | children, 73 | color = '#f50057', 74 | debug = false, 75 | ...rest 76 | } = props 77 | 78 | 79 | const element = useRef(null) 80 | 81 | useEffect(() => { 82 | element.current?.style.setProperty('--glow-top', `${element.current?.offsetTop}px`) 83 | element.current?.style.setProperty('--glow-left', `${element.current?.offsetLeft}px`) 84 | }) 85 | 86 | useEffect(() => { 87 | const observer = new ResizeObserver(() => { 88 | requestAnimationFrame(() => { 89 | element.current?.style.setProperty('--glow-top', `${element.current?.offsetTop}px`) 90 | element.current?.style.setProperty('--glow-left', `${element.current?.offsetLeft}px`) 91 | }) 92 | }) 93 | 94 | const capture = element.current?.closest('.glow-capture') 95 | if (capture) 96 | observer.observe(capture) 97 | 98 | 99 | return () => observer.disconnect() 100 | }, []) 101 | 102 | return
103 |
109 | {children} 110 |
111 |
123 | {children} 124 |
125 |
126 | } 127 | -------------------------------------------------------------------------------- /src/tailwind.js: -------------------------------------------------------------------------------- 1 | const plugin = require('tailwindcss/plugin') 2 | 3 | module.exports = plugin(({ addVariant }) => { 4 | addVariant('glow', '.glow-capture [glow] &') 5 | }, { 6 | theme: { 7 | extend: { 8 | colors: { 9 | glow: 'color-mix(in srgb, var(--glow-color) calc( * 100%), transparent)' 10 | } 11 | } 12 | } 13 | }) 14 | 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // transpile JSX to React.createElement 25 | "jsx": "react", 26 | // interop between ESM and CJS modules. Recommended by TS 27 | "esModuleInterop": true, 28 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 29 | "skipLibCheck": true, 30 | // error out if import and file system have a casing mismatch. Recommended by TS 31 | "forceConsistentCasingInFileNames": true, 32 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 33 | "noEmit": true, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tsdx.config.js: -------------------------------------------------------------------------------- 1 | const banner = require('rollup-plugin-banner2') 2 | 3 | module.exports = { 4 | rollup(config, options) { 5 | config.plugins.push( 6 | banner(() => '"use client";\n') 7 | ) 8 | return config 9 | } 10 | }; --------------------------------------------------------------------------------