Last active
June 2, 2026 04:59
-
-
Save jnicol/75aa0a1a0939974c201bba3881b9bd81 to your computer and use it in GitHub Desktop.
CSS/JS cursor effects
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <html> | |
| <body> | |
| <style> | |
| /* | |
| Cursor follow + fill effect inspired by | |
| https://juno-hamburg.com/ | |
| and | |
| https://dmmotionarts.com/button-fill-at-cursor-location-on-hover-elementor-and-js-tutorial/ | |
| There are 3 separate effects: | |
| 1. Custom cursor that follows mouse movements | |
| 2. Cursor grows on hover and displays a text label | |
| 3. Button fill effect on hover, where a circle expands from the cursor position to fill the button. | |
| Demo: https://jonathannicol.com/projects/cursor-fill/ | |
| */ | |
| /* | |
| Custom cursor | |
| --------------------------------------- | |
| */ | |
| body { | |
| background-color: white; | |
| } | |
| @media not (any-pointer: coarse) { | |
| html:not(.no-js) body * { | |
| cursor: none; | |
| } | |
| html:not(.no-js) { | |
| cursor: none; | |
| } | |
| } | |
| html:not(.no-js) .cursor.hide { | |
| visibility: hidden | |
| } | |
| .cursor { | |
| --top: -5rem; /* initially off screen */ | |
| --left: -5rem; | |
| } | |
| /* Two circles, to achieve desired mix blend mode */ | |
| .cursor-bg, | |
| .cursor-fg { | |
| position: fixed; | |
| top: var(--top); | |
| left: var(--left); | |
| width: 4rem; | |
| height: 4rem; | |
| border-radius: 50%; | |
| pointer-events: none; | |
| overflow: hidden; | |
| transform: translate3d(-50%, -50%, 0) scale(.375); /* small, so we can grow it on hover */ | |
| transform-origin: center; | |
| transition: transform .2s cubic-bezier(.23, 1, .32, 1); | |
| } | |
| /* mix-blend modes are optional. You could remove .cursor-bg entitely and give .cursor-fg a solid color if you want */ | |
| .cursor-bg { | |
| z-index: 100; | |
| background-color: white; | |
| mix-blend-mode: exclusion; | |
| } | |
| .cursor-fg { | |
| z-index: 101; | |
| background-color: #5048fe; | |
| mix-blend-mode: lighten; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| /* | |
| Grow/hover label effect | |
| --------------------------------------- | |
| */ | |
| .cursor.grow .cursor-bg, | |
| .cursor.grow .cursor-fg { | |
| transform: translate3d(-50%, -50%, 0) scale(1); | |
| } | |
| /* text inside cursor */ | |
| .cursor-text { | |
| color: white; | |
| text-transform: uppercase; | |
| display: none; | |
| } | |
| html:not(.no-js) .cursor.grow .cursor-text { | |
| display: inline-block; | |
| } | |
| /* disable mix-blend mode when button grows to show a label */ | |
| .cursor.grow .cursor-fg { | |
| background-color: #5048fe;; | |
| mix-blend-mode: initial; | |
| } | |
| /* | |
| Button fill effect | |
| --------------------------------------- | |
| */ | |
| .cursor-fill { | |
| position: relative; | |
| display: inline-block; | |
| overflow: hidden; | |
| } | |
| .cursor-fill-bg { | |
| position: absolute; | |
| width: 100%; | |
| top: 0%; | |
| left: 0%; | |
| background-color: #5048fe; | |
| z-index: 1; | |
| border-radius: 50%; | |
| transform: translate(-50%,-50%) scale(0); | |
| transition: transform 0.2s cubic-bezier(.23, 1, .32, 1); | |
| } | |
| .cursor-fill:hover .cursor-fill-bg { | |
| transform: translate(-50%,-50%) scale(2.5); | |
| } | |
| .cursor-fill-content { | |
| position: relative; | |
| z-index: 2; | |
| } | |
| /* | |
| Demo styles. Not required for effects | |
| --------------------------------------- | |
| */ | |
| body { | |
| height: 100vh; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| font-family: sans-serif; | |
| margin: 0; | |
| } | |
| .btn { | |
| padding: 0.5rem 2rem; | |
| background-color: transparent; | |
| border: 2px solid black; | |
| margin: 0 1rem; | |
| } | |
| .btn:hover { | |
| color: white; | |
| border-color: #5048fe; | |
| } | |
| .btn--round { | |
| padding: 1rem; | |
| aspect-ratio: 1/1; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .cursor-grow { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| background: #ddd; | |
| padding: 5rem; | |
| } | |
| </style> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', () => { | |
| // Cursor follow effect | |
| // -------------------- | |
| const cursor = document.querySelector('.cursor'); | |
| const cursorLabel = document.querySelector('.cursor-text'); | |
| const mouse = {x: 0, y: 0}; | |
| // Track cursor movements | |
| window.addEventListener('mousemove', (event) => { | |
| mouse.x = event.clientX; | |
| mouse.y = event.clientY; | |
| requestAnimationFrame(() => updateCursor()); | |
| }); | |
| // request animation frame recursive function to update cursor position | |
| const updateCursor = () => { | |
| cursor.style.setProperty('--left', `${mouse.x}px`); | |
| cursor.style.setProperty('--top', `${mouse.y}px`); | |
| } | |
| // Hide cursor when mouse leaves viewport | |
| document.documentElement.addEventListener("mouseleave",()=>{ | |
| cursor.classList.add("hide"); | |
| }); | |
| document.documentElement.addEventListener("mouseenter",()=>{ | |
| cursor.classList.remove("hide"); | |
| }); | |
| // Cursor grow effect | |
| // -------------------- | |
| const cursorGrows = document.querySelectorAll('.cursor-grow'); | |
| cursorGrows.forEach(cursorGrow => { | |
| cursorGrow.addEventListener('mouseenter', () => { | |
| cursor.classList.add('grow'); | |
| label = cursorGrow.dataset.cursorLabel; | |
| cursorLabel.textContent = label ? label : ''; | |
| }); | |
| cursorGrow.addEventListener('mouseleave', () => { | |
| cursor.classList.remove('grow', 'view'); | |
| cursorLabel.textContent = ''; | |
| }); | |
| }); | |
| // Fill effect | |
| // ------------------ | |
| const cursorFills = document.querySelectorAll('.cursor-fill'); | |
| cursorFills.forEach(cursorFill => { | |
| cursorFill.addEventListener('mouseenter', e => { | |
| cursor.classList.add('hide'); // hide custom cursor when hovering button | |
| updateFillPos(e, cursorFill); | |
| }); | |
| cursorFill.addEventListener('mouseleave', e => { | |
| cursor.classList.remove('hide'); // hide custom cursor when hovering button | |
| updateFillPos(e, cursorFill); | |
| }); | |
| }); | |
| function updateFillPos(e, cursorFill) { | |
| const fillBg = cursorFill.querySelector('.cursor-fill-bg'); | |
| const rect = cursorFill.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| fillBg.style.left = `${x}px`; | |
| fillBg.style.top = `${y}px`; | |
| fillBg.style.height = cursorFill.clientWidth + 'px'; | |
| } | |
| }); | |
| </script> | |
| <div class="cursor"> | |
| <div class="cursor-bg"></div> | |
| <div class="cursor-fg"><div class="cursor-text"></div></div> | |
| </div> | |
| <div class="cursor-grow" data-cursor-label="View">Hover me</div> | |
| <div class="cursor-fill btn"> | |
| <div class="cursor-fill-bg"></div> | |
| <div class="cursor-fill-content">Button</div> | |
| </div> | |
| <div class="cursor-fill btn btn--round"> | |
| <div class="cursor-fill-bg"></div> | |
| <div class="cursor-fill-content">Button</div> | |
| </div> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment