20 / 20
All 20 in 20 Tailwind CSS Animated Buttons
- 01Tailwind Neon Glow Button with Pulsing Halo
- 02Tailwind Background Slide Hover Button (Four Directions)
- 03Tailwind Ripple Effect Button with No JavaScript
- 04Tailwind Animated Border Draw Button with Staged Delays
- 05Tailwind 3D Press Effect Button with Keycap Depth
- 06Tailwind Liquid Fill Button with Animated Wave Crest
- 07Tailwind Moving Gradient CTA Button
- 08Tailwind Shine Sweep Hover Button with Gloss Streak
- 09Tailwind Jelly Bounce Button with Elastic Squash
- 10Tailwind Text Flip Button Hover with 3D Rotation
- 11Tailwind Animated CTA Hero Button with Beacon Rings & Particle Burst
- 12Tailwind Submit Button Success State with Checkmark Draw
- 13Tailwind Animated Download Button with Progress Fill
- 14Tailwind Hamburger to Close Button with aria-expanded Variants
- 15Tailwind Add to Cart Button with Flying Product Dot
- 16Tailwind Social Share Buttons with Brand Hover & Copy Link
- 17Tailwind Animated Toggle Switch with peer-checked & has-checked
- 18Tailwind Loading Spinner Inside Button with State Machine
- 19Tailwind Next & Prev Arrow Buttons with Chasing Icons
- 20Tailwind Ghost Button with Cursor-Aware Background Reveal
Tailwind Ghost Button with Cursor-Aware Background Reveal
A ghost button whose fill grows from wherever the cursor entered and shrinks back toward wherever it left. Demo 03 showed why a pure-CSS ripple can't do this: CSS has no access to pointer coordinates. Here JS supplies two numbers and nothing else.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="tab-20 w-full min-h-svh grid place-items-center bg-tab20-bg font-tab20-sans px-6 py-10">
<div class="flex flex-col items-center gap-8">
<p class="text-[0.78rem] tracking-[0.14em] uppercase text-tab20-dim m-0">Hover from any direction</p>
<div class="flex flex-wrap justify-center gap-4">
<button type="button" data-state="idle" class="tab20-btn relative overflow-hidden min-h-11 px-9 text-[0.95rem] font-bold text-tab20-violet-ink bg-transparent border-2 border-tab20-violet rounded-[10px] cursor-pointer transition-[color,border-color] duration-[350ms] hover:text-white hover:border-transparent focus-visible:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab20-violet motion-reduce:transition-none">
<span class="tab20-fill absolute left-[var(--rx,50%)] top-[var(--ry,50%)] size-0 -translate-x-1/2 -translate-y-1/2 rounded-full bg-tab20-violet pointer-events-none transition-[width,height] duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none"></span>
<span class="relative z-10">Get Started</span>
</button>
<button type="button" data-state="idle" class="tab20-btn relative overflow-hidden min-h-11 px-9 text-[0.95rem] font-bold text-tab20-cyan-ink bg-transparent border-2 border-tab20-cyan rounded-[10px] cursor-pointer transition-[color,border-color] duration-[350ms] hover:text-white hover:border-transparent focus-visible:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab20-cyan motion-reduce:transition-none">
<span class="tab20-fill absolute left-[var(--rx,50%)] top-[var(--ry,50%)] size-0 -translate-x-1/2 -translate-y-1/2 rounded-full bg-tab20-cyan pointer-events-none transition-[width,height] duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none"></span>
<span class="relative z-10">View Docs</span>
</button>
<button type="button" data-state="idle" class="tab20-btn relative overflow-hidden min-h-11 px-9 text-[0.95rem] font-bold text-tab20-rose-ink bg-transparent border-2 border-tab20-rose rounded-[10px] cursor-pointer transition-[color,border-color] duration-[350ms] hover:text-white hover:border-transparent focus-visible:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab20-rose motion-reduce:transition-none">
<span class="tab20-fill absolute left-[var(--rx,50%)] top-[var(--ry,50%)] size-0 -translate-x-1/2 -translate-y-1/2 rounded-full bg-tab20-rose pointer-events-none transition-[width,height] duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none"></span>
<span class="relative z-10">Contact Us</span>
</button>
</div>
</div>
</section><section class="tab-20 w-full min-h-svh grid place-items-center bg-tab20-bg font-tab20-sans px-6 py-10">
<div class="flex flex-col items-center gap-8">
<p class="text-[0.78rem] tracking-[0.14em] uppercase text-tab20-dim m-0">Hover from any direction</p>
<div class="flex flex-wrap justify-center gap-4">
<button type="button" data-state="idle" class="tab20-btn relative overflow-hidden min-h-11 px-9 text-[0.95rem] font-bold text-tab20-violet-ink bg-transparent border-2 border-tab20-violet rounded-[10px] cursor-pointer transition-[color,border-color] duration-[350ms] hover:text-white hover:border-transparent focus-visible:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab20-violet motion-reduce:transition-none">
<span class="tab20-fill absolute left-[var(--rx,50%)] top-[var(--ry,50%)] size-0 -translate-x-1/2 -translate-y-1/2 rounded-full bg-tab20-violet pointer-events-none transition-[width,height] duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none"></span>
<span class="relative z-10">Get Started</span>
</button>
<button type="button" data-state="idle" class="tab20-btn relative overflow-hidden min-h-11 px-9 text-[0.95rem] font-bold text-tab20-cyan-ink bg-transparent border-2 border-tab20-cyan rounded-[10px] cursor-pointer transition-[color,border-color] duration-[350ms] hover:text-white hover:border-transparent focus-visible:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab20-cyan motion-reduce:transition-none">
<span class="tab20-fill absolute left-[var(--rx,50%)] top-[var(--ry,50%)] size-0 -translate-x-1/2 -translate-y-1/2 rounded-full bg-tab20-cyan pointer-events-none transition-[width,height] duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none"></span>
<span class="relative z-10">View Docs</span>
</button>
<button type="button" data-state="idle" class="tab20-btn relative overflow-hidden min-h-11 px-9 text-[0.95rem] font-bold text-tab20-rose-ink bg-transparent border-2 border-tab20-rose rounded-[10px] cursor-pointer transition-[color,border-color] duration-[350ms] hover:text-white hover:border-transparent focus-visible:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab20-rose motion-reduce:transition-none">
<span class="tab20-fill absolute left-[var(--rx,50%)] top-[var(--ry,50%)] size-0 -translate-x-1/2 -translate-y-1/2 rounded-full bg-tab20-rose pointer-events-none transition-[width,height] duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none"></span>
<span class="relative z-10">Contact Us</span>
</button>
</div>
</div>
</section>@import "tailwindcss";
@theme {
--color-tab20-bg: #0a0a14;
--color-tab20-bg: oklch(0.11 0.02 275);
--color-tab20-dim: #64748b;
--color-tab20-dim: oklch(0.55 0.03 255);
--color-tab20-violet: #6d28d9;
--color-tab20-violet: oklch(0.47 0.24 295);
--color-tab20-violet-ink: #c4b5fd;
--color-tab20-violet-ink: oklch(0.83 0.1 292);
--color-tab20-cyan: #0e7490;
--color-tab20-cyan: oklch(0.48 0.1 221);
--color-tab20-cyan-ink: #a5f3fc;
--color-tab20-cyan-ink: oklch(0.9 0.08 202);
--color-tab20-rose: #be123c;
--color-tab20-rose: oklch(0.49 0.2 14);
--color-tab20-rose-ink: #fda4af;
--color-tab20-rose-ink: oklch(0.8 0.12 13);
--font-tab20-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* The fill's open state: a size driven by a JS-measured custom property, which is
not expressible as a utility value because --size is written at runtime. */
.tab-20 [data-state="hover"] .tab20-fill {
width: var(--size, 400px);
height: var(--size, 400px);
}
.tab-20 {
width: 100%;
min-height: 100svh;
}
.tab-20 ::selection {
background: var(--color-tab20-violet);
color: #fff;
}
@media (prefers-reduced-motion: reduce) {
.tab-20 [data-state="hover"] .tab20-fill {
width: 100%;
height: 100%;
border-radius: 0;
}
}@import "tailwindcss";
@theme {
--color-tab20-bg: #0a0a14;
--color-tab20-bg: oklch(0.11 0.02 275);
--color-tab20-dim: #64748b;
--color-tab20-dim: oklch(0.55 0.03 255);
--color-tab20-violet: #6d28d9;
--color-tab20-violet: oklch(0.47 0.24 295);
--color-tab20-violet-ink: #c4b5fd;
--color-tab20-violet-ink: oklch(0.83 0.1 292);
--color-tab20-cyan: #0e7490;
--color-tab20-cyan: oklch(0.48 0.1 221);
--color-tab20-cyan-ink: #a5f3fc;
--color-tab20-cyan-ink: oklch(0.9 0.08 202);
--color-tab20-rose: #be123c;
--color-tab20-rose: oklch(0.49 0.2 14);
--color-tab20-rose-ink: #fda4af;
--color-tab20-rose-ink: oklch(0.8 0.12 13);
--font-tab20-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* The fill's open state: a size driven by a JS-measured custom property, which is
not expressible as a utility value because --size is written at runtime. */
.tab-20 [data-state="hover"] .tab20-fill {
width: var(--size, 400px);
height: var(--size, 400px);
}
.tab-20 {
width: 100%;
min-height: 100svh;
}
.tab-20 ::selection {
background: var(--color-tab20-violet);
color: #fff;
}
@media (prefers-reduced-motion: reduce) {
.tab-20 [data-state="hover"] .tab20-fill {
width: 100%;
height: 100%;
border-radius: 0;
}
}(function () {
var calm = window.matchMedia('(prefers-reduced-motion: reduce)');
document.querySelectorAll('.tab-20 .tab20-btn').forEach(function (btn) {
var fill = btn.querySelector('.tab20-fill');
if (!fill) return;
function measure() {
var d = Math.sqrt(Math.pow(btn.offsetWidth, 2) + Math.pow(btn.offsetHeight, 2));
btn.style.setProperty('--size', d * 3 + 'px');
}
measure();
if (window.ResizeObserver) new ResizeObserver(measure).observe(btn);
function origin(e) {
if (calm.matches) return;
fill.style.setProperty('--rx', e.offsetX + 'px');
fill.style.setProperty('--ry', e.offsetY + 'px');
}
btn.addEventListener('pointerenter', function (e) { origin(e); btn.dataset.state = 'hover'; });
btn.addEventListener('pointerleave', function (e) { origin(e); btn.dataset.state = 'idle'; });
btn.addEventListener('focus', function () { btn.dataset.state = 'hover'; });
btn.addEventListener('blur', function () { btn.dataset.state = 'idle'; });
});
})();(function () {
var calm = window.matchMedia('(prefers-reduced-motion: reduce)');
document.querySelectorAll('.tab-20 .tab20-btn').forEach(function (btn) {
var fill = btn.querySelector('.tab20-fill');
if (!fill) return;
function measure() {
var d = Math.sqrt(Math.pow(btn.offsetWidth, 2) + Math.pow(btn.offsetHeight, 2));
btn.style.setProperty('--size', d * 3 + 'px');
}
measure();
if (window.ResizeObserver) new ResizeObserver(measure).observe(btn);
function origin(e) {
if (calm.matches) return;
fill.style.setProperty('--rx', e.offsetX + 'px');
fill.style.setProperty('--ry', e.offsetY + 'px');
}
btn.addEventListener('pointerenter', function (e) { origin(e); btn.dataset.state = 'hover'; });
btn.addEventListener('pointerleave', function (e) { origin(e); btn.dataset.state = 'idle'; });
btn.addEventListener('focus', function () { btn.dataset.state = 'hover'; });
btn.addEventListener('blur', function () { btn.dataset.state = 'idle'; });
});
})();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-tab20-bg: #0a0a14;
--color-tab20-bg: oklch(0.11 0.02 275);
--color-tab20-dim: #64748b;
--color-tab20-dim: oklch(0.55 0.03 255);
--color-tab20-violet: #6d28d9;
--color-tab20-violet: oklch(0.47 0.24 295);
--color-tab20-violet-ink: #c4b5fd;
--color-tab20-violet-ink: oklch(0.83 0.1 292);
--color-tab20-cyan: #0e7490;
--color-tab20-cyan: oklch(0.48 0.1 221);
--color-tab20-cyan-ink: #a5f3fc;
--color-tab20-cyan-ink: oklch(0.9 0.08 202);
--color-tab20-rose: #be123c;
--color-tab20-rose: oklch(0.49 0.2 14);
--color-tab20-rose-ink: #fda4af;
--color-tab20-rose-ink: oklch(0.8 0.12 13);
--font-tab20-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* The fill's open state: a size driven by a JS-measured custom property, which is
not expressible as a utility value because --size is written at runtime. */
.tab-20 [data-state="hover"] .tab20-fill {
width: var(--size, 400px);
height: var(--size, 400px);
}
.tab-20 {
width: 100%;
min-height: 100svh;
}
.tab-20 ::selection {
background: var(--color-tab20-violet);
color: #fff;
}
@media (prefers-reduced-motion: reduce) {
.tab-20 [data-state="hover"] .tab20-fill {
width: 100%;
height: 100%;
border-radius: 0;
}
}@import "tailwindcss";
@theme {
--color-tab20-bg: #0a0a14;
--color-tab20-bg: oklch(0.11 0.02 275);
--color-tab20-dim: #64748b;
--color-tab20-dim: oklch(0.55 0.03 255);
--color-tab20-violet: #6d28d9;
--color-tab20-violet: oklch(0.47 0.24 295);
--color-tab20-violet-ink: #c4b5fd;
--color-tab20-violet-ink: oklch(0.83 0.1 292);
--color-tab20-cyan: #0e7490;
--color-tab20-cyan: oklch(0.48 0.1 221);
--color-tab20-cyan-ink: #a5f3fc;
--color-tab20-cyan-ink: oklch(0.9 0.08 202);
--color-tab20-rose: #be123c;
--color-tab20-rose: oklch(0.49 0.2 14);
--color-tab20-rose-ink: #fda4af;
--color-tab20-rose-ink: oklch(0.8 0.12 13);
--font-tab20-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* The fill's open state: a size driven by a JS-measured custom property, which is
not expressible as a utility value because --size is written at runtime. */
.tab-20 [data-state="hover"] .tab20-fill {
width: var(--size, 400px);
height: var(--size, 400px);
}
.tab-20 {
width: 100%;
min-height: 100svh;
}
.tab-20 ::selection {
background: var(--color-tab20-violet);
color: #fff;
}
@media (prefers-reduced-motion: reduce) {
.tab-20 [data-state="hover"] .tab20-fill {
width: 100%;
height: 100%;
border-radius: 0;
}
}<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.
- The fill's resting state is all utilities, including
left-[var(--rx,50%)]with a fallback — a runtime custom property read through an arbitrary value, which is the honest way to consume JS-written data in a class list. - Its open state stays CSS, because
--sizeis measured at runtime and there is no utility that can name it as a size. - UI change from the CSS original: keyboard focus now triggers the fill (from the centre, since there is no cursor),
pointerenterreplacesmouseenterfor pen and touch, and aResizeObserverkeeps--sizecorrect after layout changes.
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 Ghost Button with Cursor-Aware Background Reveal
Source: https://codefronts.com/motion/tailwind-css-animated-buttons/tailwind-ghost-button-background-reveal/
A ghost button whose fill grows from wherever the cursor entered and shrinks back toward wherever it left. Demo 03 showed why a pure-CSS ripple can't do this: CSS has no access to pointer coordinates. Here JS supplies two numbers and nothing else.
## HTML
```html
<section class="tab-20 w-full min-h-svh grid place-items-center bg-tab20-bg font-tab20-sans px-6 py-10">
<div class="flex flex-col items-center gap-8">
<p class="text-[0.78rem] tracking-[0.14em] uppercase text-tab20-dim m-0">Hover from any direction</p>
<div class="flex flex-wrap justify-center gap-4">
<button type="button" data-state="idle" class="tab20-btn relative overflow-hidden min-h-11 px-9 text-[0.95rem] font-bold text-tab20-violet-ink bg-transparent border-2 border-tab20-violet rounded-[10px] cursor-pointer transition-[color,border-color] duration-[350ms] hover:text-white hover:border-transparent focus-visible:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab20-violet motion-reduce:transition-none">
<span class="tab20-fill absolute left-[var(--rx,50%)] top-[var(--ry,50%)] size-0 -translate-x-1/2 -translate-y-1/2 rounded-full bg-tab20-violet pointer-events-none transition-[width,height] duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none"></span>
<span class="relative z-10">Get Started</span>
</button>
<button type="button" data-state="idle" class="tab20-btn relative overflow-hidden min-h-11 px-9 text-[0.95rem] font-bold text-tab20-cyan-ink bg-transparent border-2 border-tab20-cyan rounded-[10px] cursor-pointer transition-[color,border-color] duration-[350ms] hover:text-white hover:border-transparent focus-visible:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab20-cyan motion-reduce:transition-none">
<span class="tab20-fill absolute left-[var(--rx,50%)] top-[var(--ry,50%)] size-0 -translate-x-1/2 -translate-y-1/2 rounded-full bg-tab20-cyan pointer-events-none transition-[width,height] duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none"></span>
<span class="relative z-10">View Docs</span>
</button>
<button type="button" data-state="idle" class="tab20-btn relative overflow-hidden min-h-11 px-9 text-[0.95rem] font-bold text-tab20-rose-ink bg-transparent border-2 border-tab20-rose rounded-[10px] cursor-pointer transition-[color,border-color] duration-[350ms] hover:text-white hover:border-transparent focus-visible:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab20-rose motion-reduce:transition-none">
<span class="tab20-fill absolute left-[var(--rx,50%)] top-[var(--ry,50%)] size-0 -translate-x-1/2 -translate-y-1/2 rounded-full bg-tab20-rose pointer-events-none transition-[width,height] duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none"></span>
<span class="relative z-10">Contact Us</span>
</button>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tab20-bg: #0a0a14;
--color-tab20-bg: oklch(0.11 0.02 275);
--color-tab20-dim: #64748b;
--color-tab20-dim: oklch(0.55 0.03 255);
--color-tab20-violet: #6d28d9;
--color-tab20-violet: oklch(0.47 0.24 295);
--color-tab20-violet-ink: #c4b5fd;
--color-tab20-violet-ink: oklch(0.83 0.1 292);
--color-tab20-cyan: #0e7490;
--color-tab20-cyan: oklch(0.48 0.1 221);
--color-tab20-cyan-ink: #a5f3fc;
--color-tab20-cyan-ink: oklch(0.9 0.08 202);
--color-tab20-rose: #be123c;
--color-tab20-rose: oklch(0.49 0.2 14);
--color-tab20-rose-ink: #fda4af;
--color-tab20-rose-ink: oklch(0.8 0.12 13);
--font-tab20-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* The fill's open state: a size driven by a JS-measured custom property, which is
not expressible as a utility value because --size is written at runtime. */
.tab-20 [data-state="hover"] .tab20-fill {
width: var(--size, 400px);
height: var(--size, 400px);
}
.tab-20 {
width: 100%;
min-height: 100svh;
}
.tab-20 ::selection {
background: var(--color-tab20-violet);
color: #fff;
}
@media (prefers-reduced-motion: reduce) {
.tab-20 [data-state="hover"] .tab20-fill {
width: 100%;
height: 100%;
border-radius: 0;
}
}
```
## JavaScript
```js
(function () {
var calm = window.matchMedia('(prefers-reduced-motion: reduce)');
document.querySelectorAll('.tab-20 .tab20-btn').forEach(function (btn) {
var fill = btn.querySelector('.tab20-fill');
if (!fill) return;
function measure() {
var d = Math.sqrt(Math.pow(btn.offsetWidth, 2) + Math.pow(btn.offsetHeight, 2));
btn.style.setProperty('--size', d * 3 + 'px');
}
measure();
if (window.ResizeObserver) new ResizeObserver(measure).observe(btn);
function origin(e) {
if (calm.matches) return;
fill.style.setProperty('--rx', e.offsetX + 'px');
fill.style.setProperty('--ry', e.offsetY + 'px');
}
btn.addEventListener('pointerenter', function (e) { origin(e); btn.dataset.state = 'hover'; });
btn.addEventListener('pointerleave', function (e) { origin(e); btn.dataset.state = 'idle'; });
btn.addEventListener('focus', function () { btn.dataset.state = 'hover'; });
btn.addEventListener('blur', function () { btn.dataset.state = 'idle'; });
});
})();
```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 Ghost Button with Cursor-Aware Background Reveal
Source: https://codefronts.com/motion/tailwind-css-animated-buttons/tailwind-ghost-button-background-reveal/
A ghost button whose fill grows from wherever the cursor entered and shrinks back toward wherever it left. Demo 03 showed why a pure-CSS ripple can't do this: CSS has no access to pointer coordinates. Here JS supplies two numbers and nothing else.
## HTML
```html
<section class="tab-20 w-full min-h-svh grid place-items-center bg-tab20-bg font-tab20-sans px-6 py-10">
<div class="flex flex-col items-center gap-8">
<p class="text-[0.78rem] tracking-[0.14em] uppercase text-tab20-dim m-0">Hover from any direction</p>
<div class="flex flex-wrap justify-center gap-4">
<button type="button" data-state="idle" class="tab20-btn relative overflow-hidden min-h-11 px-9 text-[0.95rem] font-bold text-tab20-violet-ink bg-transparent border-2 border-tab20-violet rounded-[10px] cursor-pointer transition-[color,border-color] duration-[350ms] hover:text-white hover:border-transparent focus-visible:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab20-violet motion-reduce:transition-none">
<span class="tab20-fill absolute left-[var(--rx,50%)] top-[var(--ry,50%)] size-0 -translate-x-1/2 -translate-y-1/2 rounded-full bg-tab20-violet pointer-events-none transition-[width,height] duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none"></span>
<span class="relative z-10">Get Started</span>
</button>
<button type="button" data-state="idle" class="tab20-btn relative overflow-hidden min-h-11 px-9 text-[0.95rem] font-bold text-tab20-cyan-ink bg-transparent border-2 border-tab20-cyan rounded-[10px] cursor-pointer transition-[color,border-color] duration-[350ms] hover:text-white hover:border-transparent focus-visible:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab20-cyan motion-reduce:transition-none">
<span class="tab20-fill absolute left-[var(--rx,50%)] top-[var(--ry,50%)] size-0 -translate-x-1/2 -translate-y-1/2 rounded-full bg-tab20-cyan pointer-events-none transition-[width,height] duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none"></span>
<span class="relative z-10">View Docs</span>
</button>
<button type="button" data-state="idle" class="tab20-btn relative overflow-hidden min-h-11 px-9 text-[0.95rem] font-bold text-tab20-rose-ink bg-transparent border-2 border-tab20-rose rounded-[10px] cursor-pointer transition-[color,border-color] duration-[350ms] hover:text-white hover:border-transparent focus-visible:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tab20-rose motion-reduce:transition-none">
<span class="tab20-fill absolute left-[var(--rx,50%)] top-[var(--ry,50%)] size-0 -translate-x-1/2 -translate-y-1/2 rounded-full bg-tab20-rose pointer-events-none transition-[width,height] duration-500 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none"></span>
<span class="relative z-10">Contact Us</span>
</button>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-tab20-bg: #0a0a14;
--color-tab20-bg: oklch(0.11 0.02 275);
--color-tab20-dim: #64748b;
--color-tab20-dim: oklch(0.55 0.03 255);
--color-tab20-violet: #6d28d9;
--color-tab20-violet: oklch(0.47 0.24 295);
--color-tab20-violet-ink: #c4b5fd;
--color-tab20-violet-ink: oklch(0.83 0.1 292);
--color-tab20-cyan: #0e7490;
--color-tab20-cyan: oklch(0.48 0.1 221);
--color-tab20-cyan-ink: #a5f3fc;
--color-tab20-cyan-ink: oklch(0.9 0.08 202);
--color-tab20-rose: #be123c;
--color-tab20-rose: oklch(0.49 0.2 14);
--color-tab20-rose-ink: #fda4af;
--color-tab20-rose-ink: oklch(0.8 0.12 13);
--font-tab20-sans: 'Space Grotesk', system-ui, sans-serif;
}
/* The fill's open state: a size driven by a JS-measured custom property, which is
not expressible as a utility value because --size is written at runtime. */
.tab-20 [data-state="hover"] .tab20-fill {
width: var(--size, 400px);
height: var(--size, 400px);
}
.tab-20 {
width: 100%;
min-height: 100svh;
}
.tab-20 ::selection {
background: var(--color-tab20-violet);
color: #fff;
}
@media (prefers-reduced-motion: reduce) {
.tab-20 [data-state="hover"] .tab20-fill {
width: 100%;
height: 100%;
border-radius: 0;
}
}
```
## JavaScript
```js
(function () {
var calm = window.matchMedia('(prefers-reduced-motion: reduce)');
document.querySelectorAll('.tab-20 .tab20-btn').forEach(function (btn) {
var fill = btn.querySelector('.tab20-fill');
if (!fill) return;
function measure() {
var d = Math.sqrt(Math.pow(btn.offsetWidth, 2) + Math.pow(btn.offsetHeight, 2));
btn.style.setProperty('--size', d * 3 + 'px');
}
measure();
if (window.ResizeObserver) new ResizeObserver(measure).observe(btn);
function origin(e) {
if (calm.matches) return;
fill.style.setProperty('--rx', e.offsetX + 'px');
fill.style.setProperty('--ry', e.offsetY + 'px');
}
btn.addEventListener('pointerenter', function (e) { origin(e); btn.dataset.state = 'hover'; });
btn.addEventListener('pointerleave', function (e) { origin(e); btn.dataset.state = 'idle'; });
btn.addEventListener('focus', function () { btn.dataset.state = 'hover'; });
btn.addEventListener('blur', function () { btn.dataset.state = 'idle'; });
});
})();
```How this works
JS contributes coordinates, not frames. On enter, the controller writes the entry point into --rx/--ry and sets data-hover; CSS transitions the fill circle from zero to --size. On leave it rewrites the coordinates first, then clears the attribute, so the circle collapses toward the exit point — the bidirectional detail that makes the effect feel physical.
--size is measured once. Three times the diagonal guarantees coverage from any entry point, including a corner. Recomputing per hover would be waste; recomputing on resize is handled by a ResizeObserver.
Reduced motion gets a plain fill. The controller skips the coordinate writing and the CSS collapses the effect to an instant square fill — the same end state, no growth.
Make it yours
- Gradient reveal: set the fill span's background to
bg-[linear-gradient(135deg,#7c3aed,#2563eb)]. - Snappier:
duration-500→duration-300on the fill. - Partial reveal: drop the diagonal multiplier from 3 to 1.5 for a spotlight rather than a fill.
Gotchas — read before shipping
pointerenter/pointerleave(notmouseenter) so pen and touch report a sane position; the fill still needspointer-events-noneregardless.offsetX/offsetYare measured from the padding edge, so on a heavily padded button the origin sits slightly inside the true cursor point. UsegetBoundingClientRect()if that gap matters.- The button needs
relative overflow-hidden. Without the clip, a circle three diagonals wide paints over its neighbours.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
Pointer events and custom properties are universally supported; the floor is Tailwind v4's generated sheet.