32 / 36
All 36 in 36 Tailwind CSS Button Hover Effects
- 01Tailwind Magnetic Ripple Attraction Button Hover Effect
- 02Tailwind Cyberpunk Holographic Glitch Grid Button
- 03Tailwind Frosted Glassmorphism Shimmer Glow Button
- 04Tailwind Organic Blob Morph Button Hover Effect
- 05Tailwind Dual-Line Intersection Border Draw Button
- 06Tailwind Isometric Tactile 3D Depress Button
- 07Tailwind Infinite Sliding Kinetic Marquee Button
- 08Tailwind Chroma Conic Gradient Border Spin Button
- 09Tailwind Minimalist Border & Outline Drawing Hover Effects
- 10Tailwind Modern 3D Skeuomorphic Button Press Effects
- 11Tailwind Neo-Brutalist & Cyberpunk Button Glow Effects
- 12Tailwind Sliding Background Color Wipe Button Effects
- 13Tailwind Liquid Fill & Organic Morphing Button Effects
- 14Tailwind Kinetic Text Flip & Icon Reveal Button Effects
- 15Tailwind Liquid Wave Fill Button Hover Effect
- 16Tailwind Magnetic Border Drift Button
- 17Tailwind Glitch Slice Button Hover Effect
- 18Tailwind Neon Pulse Glow Button Hover Effect
- 19Tailwind Split Reveal Curtain Button Effect
- 20Tailwind Diagonal Shutter Sweep Button Effect
- 21Tailwind Elastic Lift Spring Button Hover Effect
- 22Tailwind Center Underline Draw Link Button Effects
- 23Tailwind 3D Flip Card Button Hover Effect
- 24Tailwind Particle Burst Confetti Button Effect
- 25Tailwind Slide Doors Closing Panel Button Effect
- 26Tailwind Typewriter Retype Terminal Button Effect
- 27Tailwind Hard Shadow Shift Flat Button Effect
- 28Tailwind Material Ripple Wave Button Effect
- 29Tailwind Rainbow Gradient Border Spin Button
- 30Tailwind Morph Shape Silhouette Button Effects
- 31Tailwind Icon Slide In Directional Button Effect
- 32Tailwind Cursor Spotlight Glow Button
- 33Tailwind Stagger Fill Equaliser Button Effect
- 34Tailwind Sunburst Expand Radiant Button Effect
- 35Tailwind 3D Press Plunger Key Button Effect
- 36Tailwind Add-to-Cart Liquid Glass Ripple Button
Tailwind Cursor Spotlight Glow Button
A sophisticated dark CTA with a soft circle of light that follows the cursor across its surface — the button appears lit by a moving spotlight. Design B applies the same tracked gradient to the BORDER via a mask-composite ring, which is the part worth stealing.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<div class="tbh-32-group w-full min-h-svh flex flex-wrap">
<section class="tbh-32a flex-[1_1_340px] min-w-[300px] grid place-items-center px-6 py-14 font-tbh32-sans bg-tbh32a-stage" aria-label="Cursor spotlight button">
<button id="tbh-32a-btn" type="button" class="relative isolate overflow-hidden cursor-pointer rounded-xl border border-white/15 px-[42px] py-[18px] text-base font-bold text-tbh32a-ink bg-tbh32a-base transition-colors duration-300 hover:border-white/30 focus-visible:border-white/30 focus-visible:outline-3 focus-visible:outline-tbh32a-light focus-visible:outline-offset-4 before:content-[''] before:absolute before:inset-0 before:z-0 before:opacity-[var(--lit,0)] before:bg-[radial-gradient(160px_160px_at_var(--mx,50%)_var(--my,50%),color-mix(in_oklab,var(--color-tbh32a-light)_55%,transparent),transparent_60%)] before:transition-opacity before:duration-300 motion-reduce:before:transition-none">
<span class="relative z-[1]">Explore features</span>
</button>
</section>
<section class="tbh-32b flex-[1_1_340px] min-w-[300px] grid place-items-center px-6 py-14 font-tbh32-sans bg-tbh32b-stage" aria-label="Cursor glow border button">
<button id="tbh-32b-btn" type="button" class="relative isolate cursor-pointer rounded-xl border border-white/12 px-[42px] py-[18px] text-base font-bold text-tbh32b-ink bg-tbh32b-base focus-visible:outline-3 focus-visible:outline-tbh32b-glow focus-visible:outline-offset-4 before:content-[''] before:absolute before:-inset-px before:z-0 before:rounded-[13px] before:p-px before:opacity-[var(--lit,0)] before:bg-[radial-gradient(120px_120px_at_var(--mx,50%)_var(--my,50%),var(--color-tbh32b-glow),transparent_70%)] before:[mask:linear-gradient(#000_0_0)_content-box,linear-gradient(#000_0_0)] before:[mask-composite:exclude] before:transition-opacity before:duration-300 motion-reduce:before:transition-none">
<span class="relative z-[1]">View pricing</span>
</button>
</section>
</div><div class="tbh-32-group w-full min-h-svh flex flex-wrap">
<section class="tbh-32a flex-[1_1_340px] min-w-[300px] grid place-items-center px-6 py-14 font-tbh32-sans bg-tbh32a-stage" aria-label="Cursor spotlight button">
<button id="tbh-32a-btn" type="button" class="relative isolate overflow-hidden cursor-pointer rounded-xl border border-white/15 px-[42px] py-[18px] text-base font-bold text-tbh32a-ink bg-tbh32a-base transition-colors duration-300 hover:border-white/30 focus-visible:border-white/30 focus-visible:outline-3 focus-visible:outline-tbh32a-light focus-visible:outline-offset-4 before:content-[''] before:absolute before:inset-0 before:z-0 before:opacity-[var(--lit,0)] before:bg-[radial-gradient(160px_160px_at_var(--mx,50%)_var(--my,50%),color-mix(in_oklab,var(--color-tbh32a-light)_55%,transparent),transparent_60%)] before:transition-opacity before:duration-300 motion-reduce:before:transition-none">
<span class="relative z-[1]">Explore features</span>
</button>
</section>
<section class="tbh-32b flex-[1_1_340px] min-w-[300px] grid place-items-center px-6 py-14 font-tbh32-sans bg-tbh32b-stage" aria-label="Cursor glow border button">
<button id="tbh-32b-btn" type="button" class="relative isolate cursor-pointer rounded-xl border border-white/12 px-[42px] py-[18px] text-base font-bold text-tbh32b-ink bg-tbh32b-base focus-visible:outline-3 focus-visible:outline-tbh32b-glow focus-visible:outline-offset-4 before:content-[''] before:absolute before:-inset-px before:z-0 before:rounded-[13px] before:p-px before:opacity-[var(--lit,0)] before:bg-[radial-gradient(120px_120px_at_var(--mx,50%)_var(--my,50%),var(--color-tbh32b-glow),transparent_70%)] before:[mask:linear-gradient(#000_0_0)_content-box,linear-gradient(#000_0_0)] before:[mask-composite:exclude] before:transition-opacity before:duration-300 motion-reduce:before:transition-none">
<span class="relative z-[1]">View pricing</span>
</button>
</section>
</div>@import "tailwindcss";
@theme {
--color-tbh32a-base: #14131c;
--color-tbh32a-base: oklch(0.19 0.02 285);
--color-tbh32a-stage: #08070d;
--color-tbh32a-stage: oklch(0.11 0.01 285);
--color-tbh32a-ink: #f4f3ff;
--color-tbh32a-ink: oklch(0.96 0.01 285);
--color-tbh32a-light: #8c78ff;
--color-tbh32a-light: oklch(0.68 0.19 285);
--color-tbh32b-base: #0f1a1c;
--color-tbh32b-base: oklch(0.19 0.02 195);
--color-tbh32b-stage: #060d0e;
--color-tbh32b-stage: oklch(0.11 0.012 195);
--color-tbh32b-ink: #eafffb;
--color-tbh32b-ink: oklch(0.97 0.02 175);
--color-tbh32b-glow: #5fdde8;
--color-tbh32b-glow: oklch(0.75 0.15 190);
--font-tbh32-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* Stays CSS: the -webkit- mask pair for design B's border ring. The prefixed form needs the
'xor' keyword where the standard form takes 'exclude', so it cannot be one utility. */
.tbh-32b button::before {
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
}
.tbh-32-group {
width: 100%;
min-height: 100svh;
}
.tbh-32a,
.tbh-32b {
min-height: 100svh;
}@import "tailwindcss";
@theme {
--color-tbh32a-base: #14131c;
--color-tbh32a-base: oklch(0.19 0.02 285);
--color-tbh32a-stage: #08070d;
--color-tbh32a-stage: oklch(0.11 0.01 285);
--color-tbh32a-ink: #f4f3ff;
--color-tbh32a-ink: oklch(0.96 0.01 285);
--color-tbh32a-light: #8c78ff;
--color-tbh32a-light: oklch(0.68 0.19 285);
--color-tbh32b-base: #0f1a1c;
--color-tbh32b-base: oklch(0.19 0.02 195);
--color-tbh32b-stage: #060d0e;
--color-tbh32b-stage: oklch(0.11 0.012 195);
--color-tbh32b-ink: #eafffb;
--color-tbh32b-ink: oklch(0.97 0.02 175);
--color-tbh32b-glow: #5fdde8;
--color-tbh32b-glow: oklch(0.75 0.15 190);
--font-tbh32-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* Stays CSS: the -webkit- mask pair for design B's border ring. The prefixed form needs the
'xor' keyword where the standard form takes 'exclude', so it cannot be one utility. */
.tbh-32b button::before {
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
}
.tbh-32-group {
width: 100%;
min-height: 100svh;
}
.tbh-32a,
.tbh-32b {
min-height: 100svh;
}(() => {
const light = id => {
const btn = document.querySelector(id); if (!btn) return;
const set = (mx, my, lit) => {
btn.style.setProperty('--mx', mx);
btn.style.setProperty('--my', my);
btn.style.setProperty('--lit', lit);
};
btn.addEventListener('pointermove', e => {
const r = btn.getBoundingClientRect();
set((e.clientX - r.left) + 'px', (e.clientY - r.top) + 'px', '1');
});
btn.addEventListener('pointerleave', () => btn.style.setProperty('--lit', '0'));
btn.addEventListener('focus', () => set('50%', '50%', '1'));
btn.addEventListener('blur', () => btn.style.setProperty('--lit', '0'));
};
light('#tbh-32a-btn');
light('#tbh-32b-btn');
})();(() => {
const light = id => {
const btn = document.querySelector(id); if (!btn) return;
const set = (mx, my, lit) => {
btn.style.setProperty('--mx', mx);
btn.style.setProperty('--my', my);
btn.style.setProperty('--lit', lit);
};
btn.addEventListener('pointermove', e => {
const r = btn.getBoundingClientRect();
set((e.clientX - r.left) + 'px', (e.clientY - r.top) + 'px', '1');
});
btn.addEventListener('pointerleave', () => btn.style.setProperty('--lit', '0'));
btn.addEventListener('focus', () => set('50%', '50%', '1'));
btn.addEventListener('blur', () => btn.style.setProperty('--lit', '0'));
};
light('#tbh-32a-btn');
light('#tbh-32b-btn');
})();Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-tbh32a-base: #14131c;
--color-tbh32a-base: oklch(0.19 0.02 285);
--color-tbh32a-stage: #08070d;
--color-tbh32a-stage: oklch(0.11 0.01 285);
--color-tbh32a-ink: #f4f3ff;
--color-tbh32a-ink: oklch(0.96 0.01 285);
--color-tbh32a-light: #8c78ff;
--color-tbh32a-light: oklch(0.68 0.19 285);
--color-tbh32b-base: #0f1a1c;
--color-tbh32b-base: oklch(0.19 0.02 195);
--color-tbh32b-stage: #060d0e;
--color-tbh32b-stage: oklch(0.11 0.012 195);
--color-tbh32b-ink: #eafffb;
--color-tbh32b-ink: oklch(0.97 0.02 175);
--color-tbh32b-glow: #5fdde8;
--color-tbh32b-glow: oklch(0.75 0.15 190);
--font-tbh32-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* Stays CSS: the -webkit- mask pair for design B's border ring. The prefixed form needs the
'xor' keyword where the standard form takes 'exclude', so it cannot be one utility. */
.tbh-32b button::before {
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
}
.tbh-32-group {
width: 100%;
min-height: 100svh;
}
.tbh-32a,
.tbh-32b {
min-height: 100svh;
}@import "tailwindcss";
@theme {
--color-tbh32a-base: #14131c;
--color-tbh32a-base: oklch(0.19 0.02 285);
--color-tbh32a-stage: #08070d;
--color-tbh32a-stage: oklch(0.11 0.01 285);
--color-tbh32a-ink: #f4f3ff;
--color-tbh32a-ink: oklch(0.96 0.01 285);
--color-tbh32a-light: #8c78ff;
--color-tbh32a-light: oklch(0.68 0.19 285);
--color-tbh32b-base: #0f1a1c;
--color-tbh32b-base: oklch(0.19 0.02 195);
--color-tbh32b-stage: #060d0e;
--color-tbh32b-stage: oklch(0.11 0.012 195);
--color-tbh32b-ink: #eafffb;
--color-tbh32b-ink: oklch(0.97 0.02 175);
--color-tbh32b-glow: #5fdde8;
--color-tbh32b-glow: oklch(0.75 0.15 190);
--font-tbh32-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* Stays CSS: the -webkit- mask pair for design B's border ring. The prefixed form needs the
'xor' keyword where the standard form takes 'exclude', so it cannot be one utility. */
.tbh-32b button::before {
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
}
.tbh-32-group {
width: 100%;
min-height: 100svh;
}
.tbh-32a,
.tbh-32b {
min-height: 100svh;
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
before:opacity-[var(--lit,0)]is the port's discovery: an arbitrary utility value can be a barevar(), so a JS-written number drives the utility directly and the fade stays a CSS transition rather than becoming a second animation in script.- Both controllers collapsed into one function — the designs differ only in which gradient the markup declares, so there is nothing left for a second IIFE to say.
- UI change from the CSS original: design A's border now brightens on
focus-visible:as well as hover, and both buttons light from the centre on keyboard focus (the original wiredfocusbut left design A's border change hover-only).
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Cursor Spotlight Glow Button
Source: https://codefronts.com/motion/tailwind-css-button-hover-effects/tailwind-cursor-spotlight-glow-button/
A sophisticated dark CTA with a soft circle of light that follows the cursor across its surface — the button appears lit by a moving spotlight. Design B applies the same tracked gradient to the BORDER via a mask-composite ring, which is the part worth stealing.
## HTML
```html
<div class="tbh-32-group w-full min-h-svh flex flex-wrap">
<section class="tbh-32a flex-[1_1_340px] min-w-[300px] grid place-items-center px-6 py-14 font-tbh32-sans bg-tbh32a-stage" aria-label="Cursor spotlight button">
<button id="tbh-32a-btn" type="button" class="relative isolate overflow-hidden cursor-pointer rounded-xl border border-white/15 px-[42px] py-[18px] text-base font-bold text-tbh32a-ink bg-tbh32a-base transition-colors duration-300 hover:border-white/30 focus-visible:border-white/30 focus-visible:outline-3 focus-visible:outline-tbh32a-light focus-visible:outline-offset-4 before:content-[''] before:absolute before:inset-0 before:z-0 before:opacity-[var(--lit,0)] before:bg-[radial-gradient(160px_160px_at_var(--mx,50%)_var(--my,50%),color-mix(in_oklab,var(--color-tbh32a-light)_55%,transparent),transparent_60%)] before:transition-opacity before:duration-300 motion-reduce:before:transition-none">
<span class="relative z-[1]">Explore features</span>
</button>
</section>
<section class="tbh-32b flex-[1_1_340px] min-w-[300px] grid place-items-center px-6 py-14 font-tbh32-sans bg-tbh32b-stage" aria-label="Cursor glow border button">
<button id="tbh-32b-btn" type="button" class="relative isolate cursor-pointer rounded-xl border border-white/12 px-[42px] py-[18px] text-base font-bold text-tbh32b-ink bg-tbh32b-base focus-visible:outline-3 focus-visible:outline-tbh32b-glow focus-visible:outline-offset-4 before:content-[''] before:absolute before:-inset-px before:z-0 before:rounded-[13px] before:p-px before:opacity-[var(--lit,0)] before:bg-[radial-gradient(120px_120px_at_var(--mx,50%)_var(--my,50%),var(--color-tbh32b-glow),transparent_70%)] before:[mask:linear-gradient(#000_0_0)_content-box,linear-gradient(#000_0_0)] before:[mask-composite:exclude] before:transition-opacity before:duration-300 motion-reduce:before:transition-none">
<span class="relative z-[1]">View pricing</span>
</button>
</section>
</div>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tbh32a-base: #14131c;
--color-tbh32a-base: oklch(0.19 0.02 285);
--color-tbh32a-stage: #08070d;
--color-tbh32a-stage: oklch(0.11 0.01 285);
--color-tbh32a-ink: #f4f3ff;
--color-tbh32a-ink: oklch(0.96 0.01 285);
--color-tbh32a-light: #8c78ff;
--color-tbh32a-light: oklch(0.68 0.19 285);
--color-tbh32b-base: #0f1a1c;
--color-tbh32b-base: oklch(0.19 0.02 195);
--color-tbh32b-stage: #060d0e;
--color-tbh32b-stage: oklch(0.11 0.012 195);
--color-tbh32b-ink: #eafffb;
--color-tbh32b-ink: oklch(0.97 0.02 175);
--color-tbh32b-glow: #5fdde8;
--color-tbh32b-glow: oklch(0.75 0.15 190);
--font-tbh32-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* Stays CSS: the -webkit- mask pair for design B's border ring. The prefixed form needs the
'xor' keyword where the standard form takes 'exclude', so it cannot be one utility. */
.tbh-32b button::before {
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
}
.tbh-32-group {
width: 100%;
min-height: 100svh;
}
.tbh-32a,
.tbh-32b {
min-height: 100svh;
}
```
## JavaScript
```js
(() => {
const light = id => {
const btn = document.querySelector(id); if (!btn) return;
const set = (mx, my, lit) => {
btn.style.setProperty('--mx', mx);
btn.style.setProperty('--my', my);
btn.style.setProperty('--lit', lit);
};
btn.addEventListener('pointermove', e => {
const r = btn.getBoundingClientRect();
set((e.clientX - r.left) + 'px', (e.clientY - r.top) + 'px', '1');
});
btn.addEventListener('pointerleave', () => btn.style.setProperty('--lit', '0'));
btn.addEventListener('focus', () => set('50%', '50%', '1'));
btn.addEventListener('blur', () => btn.style.setProperty('--lit', '0'));
};
light('#tbh-32a-btn');
light('#tbh-32b-btn');
})();
```Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Cursor Spotlight Glow Button
Source: https://codefronts.com/motion/tailwind-css-button-hover-effects/tailwind-cursor-spotlight-glow-button/
A sophisticated dark CTA with a soft circle of light that follows the cursor across its surface — the button appears lit by a moving spotlight. Design B applies the same tracked gradient to the BORDER via a mask-composite ring, which is the part worth stealing.
## HTML
```html
<div class="tbh-32-group w-full min-h-svh flex flex-wrap">
<section class="tbh-32a flex-[1_1_340px] min-w-[300px] grid place-items-center px-6 py-14 font-tbh32-sans bg-tbh32a-stage" aria-label="Cursor spotlight button">
<button id="tbh-32a-btn" type="button" class="relative isolate overflow-hidden cursor-pointer rounded-xl border border-white/15 px-[42px] py-[18px] text-base font-bold text-tbh32a-ink bg-tbh32a-base transition-colors duration-300 hover:border-white/30 focus-visible:border-white/30 focus-visible:outline-3 focus-visible:outline-tbh32a-light focus-visible:outline-offset-4 before:content-[''] before:absolute before:inset-0 before:z-0 before:opacity-[var(--lit,0)] before:bg-[radial-gradient(160px_160px_at_var(--mx,50%)_var(--my,50%),color-mix(in_oklab,var(--color-tbh32a-light)_55%,transparent),transparent_60%)] before:transition-opacity before:duration-300 motion-reduce:before:transition-none">
<span class="relative z-[1]">Explore features</span>
</button>
</section>
<section class="tbh-32b flex-[1_1_340px] min-w-[300px] grid place-items-center px-6 py-14 font-tbh32-sans bg-tbh32b-stage" aria-label="Cursor glow border button">
<button id="tbh-32b-btn" type="button" class="relative isolate cursor-pointer rounded-xl border border-white/12 px-[42px] py-[18px] text-base font-bold text-tbh32b-ink bg-tbh32b-base focus-visible:outline-3 focus-visible:outline-tbh32b-glow focus-visible:outline-offset-4 before:content-[''] before:absolute before:-inset-px before:z-0 before:rounded-[13px] before:p-px before:opacity-[var(--lit,0)] before:bg-[radial-gradient(120px_120px_at_var(--mx,50%)_var(--my,50%),var(--color-tbh32b-glow),transparent_70%)] before:[mask:linear-gradient(#000_0_0)_content-box,linear-gradient(#000_0_0)] before:[mask-composite:exclude] before:transition-opacity before:duration-300 motion-reduce:before:transition-none">
<span class="relative z-[1]">View pricing</span>
</button>
</section>
</div>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tbh32a-base: #14131c;
--color-tbh32a-base: oklch(0.19 0.02 285);
--color-tbh32a-stage: #08070d;
--color-tbh32a-stage: oklch(0.11 0.01 285);
--color-tbh32a-ink: #f4f3ff;
--color-tbh32a-ink: oklch(0.96 0.01 285);
--color-tbh32a-light: #8c78ff;
--color-tbh32a-light: oklch(0.68 0.19 285);
--color-tbh32b-base: #0f1a1c;
--color-tbh32b-base: oklch(0.19 0.02 195);
--color-tbh32b-stage: #060d0e;
--color-tbh32b-stage: oklch(0.11 0.012 195);
--color-tbh32b-ink: #eafffb;
--color-tbh32b-ink: oklch(0.97 0.02 175);
--color-tbh32b-glow: #5fdde8;
--color-tbh32b-glow: oklch(0.75 0.15 190);
--font-tbh32-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* Stays CSS: the -webkit- mask pair for design B's border ring. The prefixed form needs the
'xor' keyword where the standard form takes 'exclude', so it cannot be one utility. */
.tbh-32b button::before {
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
}
.tbh-32-group {
width: 100%;
min-height: 100svh;
}
.tbh-32a,
.tbh-32b {
min-height: 100svh;
}
```
## JavaScript
```js
(() => {
const light = id => {
const btn = document.querySelector(id); if (!btn) return;
const set = (mx, my, lit) => {
btn.style.setProperty('--mx', mx);
btn.style.setProperty('--my', my);
btn.style.setProperty('--lit', lit);
};
btn.addEventListener('pointermove', e => {
const r = btn.getBoundingClientRect();
set((e.clientX - r.left) + 'px', (e.clientY - r.top) + 'px', '1');
});
btn.addEventListener('pointerleave', () => btn.style.setProperty('--lit', '0'));
btn.addEventListener('focus', () => set('50%', '50%', '1'));
btn.addEventListener('blur', () => btn.style.setProperty('--lit', '0'));
};
light('#tbh-32a-btn');
light('#tbh-32b-btn');
})();
```How this works
Three properties do everything. --mx and --my place the gradient; --lit fades it. The handler writes nothing else and touches no classes, which means the light's size, colour and falloff are all editable in the markup without reading the script.
Reading a custom property as an opacity. opacity-[var(--lit,0)] is the neat trick here: an arbitrary value can BE a variable reference, so a JS-written number drives a utility directly. The fallback matters — before the first event, 0 keeps the layer hidden.
The border version needs a mask. A gradient cannot paint a border, so design B paints a full-bleed gradient into a 1px-padded box and excludes the content area with mask-composite. What remains is a 1px ring lit only where the cursor is — three arbitrary properties, no extra elements.
Make it yours
- Light size: the
160px 160pxin the gradient (120px on design B's rim). - Falloff: the
60%/70%transparent stop — further out is softer and more diffuse. - Colour:
--color-tbh32a-light/--color-tbh32b-glow; both are read by the gradient and the focus ring. - Fade speed:
before:transition-opacity before:duration-300— the light should fade slower than it moves.
Gotchas — read before shipping
- Write ONLY custom properties in the pointermove handler. Setting classes or styles that affect layout on every move is how this effect ruins INP.
- Reset
--litto 0 onpointerleaveandblur, or the button stays lit after the cursor has gone. - The label must clear 4.5:1 against the UNLIT base, not the lit state — the spotlight only ever adds light.
- Design B's mask needs both the
-webkit-and unprefixed forms; the-webkit-pair stays in CSS because the composite keyword differs (xorvsexclude).
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Custom properties and pointer events are universal; the mask-composite ring needs Chrome 120 / Safari 15.4 / Firefox 53 and degrades to an unlit border. With JS disabled both buttons are clean dark CTAs.