29 / 30
All 30 in 30 Tailwind CSS Hover Effects
- 01Tailwind Underline Draw Hover Effect
- 02Tailwind Text Glitch Hover Effect
- 03Tailwind Letter Spacing Expand Hover Effect
- 04Tailwind Gradient Text Reveal Hover Effect
- 05Tailwind Split Text Hover Effect
- 06Tailwind Neon Glow Text Hover Effect
- 07Tailwind Magnetic Liquid Button Hover Effect
- 08Tailwind Border Draw Hover Effect
- 09Tailwind Shimmer Shine Button Hover Effect
- 10Tailwind Fill Wipe Button Hover Effect
- 11Tailwind 3D Press Button Hover Effect
- 12Tailwind Ripple Button Hover Effect
- 13Tailwind 3D Tilt Card Hover Effect
- 14Tailwind Card Flip Hover Effect
- 15Tailwind Glassmorphism Card Hover Effect
- 16Tailwind Spotlight Card Hover Effect
- 17Tailwind Slide Reveal Card Hover Effect
- 18Tailwind Stack Lift Card Hover Effect
- 19Tailwind Zoom Pan Image Hover Effect
- 20Tailwind Color Channel Split Hover Effect
- 21Tailwind Duotone Image Hover Effect
- 22Tailwind Curtain Reveal Image Hover Effect
- 23Tailwind Ken Burns Image Hover Effect
- 24Tailwind Image Tilt Depth Hover Effect
- 25Tailwind Underline Slide Nav Hover Effect
- 26Tailwind Highlight Fill Nav Hover Effect
- 27Tailwind Strikethrough Hover Link Effect
- 28Tailwind Inline Word Swap Hover Effect
- 29Tailwind Dot Trail Cursor Hover Effect
- 30Tailwind Magnetic Cursor Pull Hover Effect
Tailwind Dot Trail Cursor Hover Effect
Four cursor trails inside bounded zones — classic fade, rainbow cycle, comet tail and sparkle burst. The controller is 30 lines; the interesting part of the port is the boundary: elements created at runtime cannot be scanned by a build, so their styling stays in CSS on purpose.
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="thv-29 w-full min-h-svh bg-thv29-bg font-thv29-sans px-8 py-10 select-none">
<p class="text-center text-[0.72rem] tracking-[0.15em] uppercase text-thv29-dim mb-6">Dot Trail Cursor — 4 Styles</p>
<div class="grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] gap-4 max-w-[740px] mx-auto">
<div data-trail="classic" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Classic Fade</span>
</div>
<div data-trail="rainbow" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Rainbow</span>
</div>
<div data-trail="comet" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Comet Tail</span>
</div>
<div data-trail="sparkle" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Sparkle</span>
</div>
</div>
</section><section class="thv-29 w-full min-h-svh bg-thv29-bg font-thv29-sans px-8 py-10 select-none">
<p class="text-center text-[0.72rem] tracking-[0.15em] uppercase text-thv29-dim mb-6">Dot Trail Cursor — 4 Styles</p>
<div class="grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] gap-4 max-w-[740px] mx-auto">
<div data-trail="classic" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Classic Fade</span>
</div>
<div data-trail="rainbow" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Rainbow</span>
</div>
<div data-trail="comet" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Comet Tail</span>
</div>
<div data-trail="sparkle" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Sparkle</span>
</div>
</div>
</section>@import "tailwindcss";
@theme {
--color-thv29-bg: #080810;
--color-thv29-bg: oklch(0.12 0.02 285);
--color-thv29-panel: #0d0d18;
--color-thv29-panel: oklch(0.15 0.02 285);
--color-thv29-edge: #1a1a2a;
--color-thv29-edge: oklch(0.22 0.02 285);
--color-thv29-dim: #8a8a99;
--color-thv29-dim: oklch(0.64 0.02 285);
--color-thv29-hint: #5e5e78;
--color-thv29-hint: oklch(0.48 0.03 285);
--font-thv29-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-thv29-classic: thv29-classic .6s ease-out forwards;
--animate-thv29-rainbow: thv29-rainbow .7s ease-out forwards;
--animate-thv29-comet: thv29-comet .5s ease-out forwards;
--animate-thv29-sparkle: thv29-sparkle .65s ease-out forwards;
}
.thv-29 {
width: 100%;
min-height: 100svh;
}
.thv-29 ::selection {
background: #ffffff;
color: #000;
}
/* Runtime-generated elements: the JS mints these nodes after the build has
scanned the document, so their styling cannot live in utilities without a
safelist. Four rules is the honest cost of the port. */
.thv29-dot {
position: absolute;
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
will-change: transform, opacity;
}
[data-trail="classic"] .thv29-dot {
width: 8px;
height: 8px;
background: #fff;
animation: var(--animate-thv29-classic);
}
[data-trail="rainbow"] .thv29-dot {
width: 10px;
height: 10px;
animation: var(--animate-thv29-rainbow);
}
[data-trail="comet"] .thv29-dot {
width: 12px;
height: 4px;
border-radius: 2px;
background: linear-gradient(90deg, rgba(100,180,255,0), rgba(100,180,255,1));
animation: var(--animate-thv29-comet);
}
[data-trail="sparkle"] .thv29-dot {
width: 10px;
height: 10px;
background: #f7d060;
clip-path: polygon(50% 0%,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%);
animation: var(--animate-thv29-sparkle);
}
@keyframes thv29-classic {
0% {
opacity: .9;
transform: translate(-50%,-50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(0);
}
}
@keyframes thv29-rainbow {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(1.1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(.1);
}
}
@keyframes thv29-comet {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(1,1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(3,.3);
}
}
@keyframes thv29-sparkle {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(0) rotate(0deg);
}
60% {
opacity: 1;
transform: translate(-50%,-50%) scale(1.3) rotate(60deg);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(.4) rotate(90deg);
}
}@import "tailwindcss";
@theme {
--color-thv29-bg: #080810;
--color-thv29-bg: oklch(0.12 0.02 285);
--color-thv29-panel: #0d0d18;
--color-thv29-panel: oklch(0.15 0.02 285);
--color-thv29-edge: #1a1a2a;
--color-thv29-edge: oklch(0.22 0.02 285);
--color-thv29-dim: #8a8a99;
--color-thv29-dim: oklch(0.64 0.02 285);
--color-thv29-hint: #5e5e78;
--color-thv29-hint: oklch(0.48 0.03 285);
--font-thv29-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-thv29-classic: thv29-classic .6s ease-out forwards;
--animate-thv29-rainbow: thv29-rainbow .7s ease-out forwards;
--animate-thv29-comet: thv29-comet .5s ease-out forwards;
--animate-thv29-sparkle: thv29-sparkle .65s ease-out forwards;
}
.thv-29 {
width: 100%;
min-height: 100svh;
}
.thv-29 ::selection {
background: #ffffff;
color: #000;
}
/* Runtime-generated elements: the JS mints these nodes after the build has
scanned the document, so their styling cannot live in utilities without a
safelist. Four rules is the honest cost of the port. */
.thv29-dot {
position: absolute;
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
will-change: transform, opacity;
}
[data-trail="classic"] .thv29-dot {
width: 8px;
height: 8px;
background: #fff;
animation: var(--animate-thv29-classic);
}
[data-trail="rainbow"] .thv29-dot {
width: 10px;
height: 10px;
animation: var(--animate-thv29-rainbow);
}
[data-trail="comet"] .thv29-dot {
width: 12px;
height: 4px;
border-radius: 2px;
background: linear-gradient(90deg, rgba(100,180,255,0), rgba(100,180,255,1));
animation: var(--animate-thv29-comet);
}
[data-trail="sparkle"] .thv29-dot {
width: 10px;
height: 10px;
background: #f7d060;
clip-path: polygon(50% 0%,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%);
animation: var(--animate-thv29-sparkle);
}
@keyframes thv29-classic {
0% {
opacity: .9;
transform: translate(-50%,-50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(0);
}
}
@keyframes thv29-rainbow {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(1.1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(.1);
}
}
@keyframes thv29-comet {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(1,1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(3,.3);
}
}
@keyframes thv29-sparkle {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(0) rotate(0deg);
}
60% {
opacity: 1;
transform: translate(-50%,-50%) scale(1.3) rotate(60deg);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(.4) rotate(90deg);
}
}(function () {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const zones = document.querySelectorAll('.thv29-zone');
const RAINBOW = ['#ff6b6b','#ffa94d','#ffd43b','#69db7c','#4dabf7','#cc5de8','#f783ac'];
let hueIndex = 0, rafId = null, pendingZone = null, pendingX = 0, pendingY = 0;
function spawnDot(zone, x, y) {
const dot = document.createElement('span');
dot.className = 'thv29-dot';
dot.style.left = x + 'px';
dot.style.top = y + 'px';
if (zone.dataset.trail === 'rainbow') {
dot.style.background = RAINBOW[hueIndex++ % RAINBOW.length];
}
zone.appendChild(dot);
dot.addEventListener('animationend', () => dot.remove(), { once: true });
}
function onMove(e) {
const zone = e.currentTarget;
const rect = zone.getBoundingClientRect();
pendingX = e.clientX - rect.left;
pendingY = e.clientY - rect.top;
pendingZone = zone;
if (!rafId) {
rafId = requestAnimationFrame(() => {
if (pendingZone) spawnDot(pendingZone, pendingX, pendingY);
rafId = null;
});
}
}
zones.forEach((zone) => {
zone.addEventListener('mousemove', onMove);
zone.addEventListener('mouseleave', () => { pendingZone = null; });
});
})();(function () {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const zones = document.querySelectorAll('.thv29-zone');
const RAINBOW = ['#ff6b6b','#ffa94d','#ffd43b','#69db7c','#4dabf7','#cc5de8','#f783ac'];
let hueIndex = 0, rafId = null, pendingZone = null, pendingX = 0, pendingY = 0;
function spawnDot(zone, x, y) {
const dot = document.createElement('span');
dot.className = 'thv29-dot';
dot.style.left = x + 'px';
dot.style.top = y + 'px';
if (zone.dataset.trail === 'rainbow') {
dot.style.background = RAINBOW[hueIndex++ % RAINBOW.length];
}
zone.appendChild(dot);
dot.addEventListener('animationend', () => dot.remove(), { once: true });
}
function onMove(e) {
const zone = e.currentTarget;
const rect = zone.getBoundingClientRect();
pendingX = e.clientX - rect.left;
pendingY = e.clientY - rect.top;
pendingZone = zone;
if (!rafId) {
rafId = requestAnimationFrame(() => {
if (pendingZone) spawnDot(pendingZone, pendingX, pendingY);
rafId = null;
});
}
}
zones.forEach((zone) => {
zone.addEventListener('mousemove', onMove);
zone.addEventListener('mouseleave', () => { pendingZone = null; });
});
})();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-thv29-bg: #080810;
--color-thv29-bg: oklch(0.12 0.02 285);
--color-thv29-panel: #0d0d18;
--color-thv29-panel: oklch(0.15 0.02 285);
--color-thv29-edge: #1a1a2a;
--color-thv29-edge: oklch(0.22 0.02 285);
--color-thv29-dim: #8a8a99;
--color-thv29-dim: oklch(0.64 0.02 285);
--color-thv29-hint: #5e5e78;
--color-thv29-hint: oklch(0.48 0.03 285);
--font-thv29-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-thv29-classic: thv29-classic .6s ease-out forwards;
--animate-thv29-rainbow: thv29-rainbow .7s ease-out forwards;
--animate-thv29-comet: thv29-comet .5s ease-out forwards;
--animate-thv29-sparkle: thv29-sparkle .65s ease-out forwards;
}
.thv-29 {
width: 100%;
min-height: 100svh;
}
.thv-29 ::selection {
background: #ffffff;
color: #000;
}
/* Runtime-generated elements: the JS mints these nodes after the build has
scanned the document, so their styling cannot live in utilities without a
safelist. Four rules is the honest cost of the port. */
.thv29-dot {
position: absolute;
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
will-change: transform, opacity;
}
[data-trail="classic"] .thv29-dot {
width: 8px;
height: 8px;
background: #fff;
animation: var(--animate-thv29-classic);
}
[data-trail="rainbow"] .thv29-dot {
width: 10px;
height: 10px;
animation: var(--animate-thv29-rainbow);
}
[data-trail="comet"] .thv29-dot {
width: 12px;
height: 4px;
border-radius: 2px;
background: linear-gradient(90deg, rgba(100,180,255,0), rgba(100,180,255,1));
animation: var(--animate-thv29-comet);
}
[data-trail="sparkle"] .thv29-dot {
width: 10px;
height: 10px;
background: #f7d060;
clip-path: polygon(50% 0%,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%);
animation: var(--animate-thv29-sparkle);
}
@keyframes thv29-classic {
0% {
opacity: .9;
transform: translate(-50%,-50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(0);
}
}
@keyframes thv29-rainbow {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(1.1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(.1);
}
}
@keyframes thv29-comet {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(1,1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(3,.3);
}
}
@keyframes thv29-sparkle {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(0) rotate(0deg);
}
60% {
opacity: 1;
transform: translate(-50%,-50%) scale(1.3) rotate(60deg);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(.4) rotate(90deg);
}
}@import "tailwindcss";
@theme {
--color-thv29-bg: #080810;
--color-thv29-bg: oklch(0.12 0.02 285);
--color-thv29-panel: #0d0d18;
--color-thv29-panel: oklch(0.15 0.02 285);
--color-thv29-edge: #1a1a2a;
--color-thv29-edge: oklch(0.22 0.02 285);
--color-thv29-dim: #8a8a99;
--color-thv29-dim: oklch(0.64 0.02 285);
--color-thv29-hint: #5e5e78;
--color-thv29-hint: oklch(0.48 0.03 285);
--font-thv29-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-thv29-classic: thv29-classic .6s ease-out forwards;
--animate-thv29-rainbow: thv29-rainbow .7s ease-out forwards;
--animate-thv29-comet: thv29-comet .5s ease-out forwards;
--animate-thv29-sparkle: thv29-sparkle .65s ease-out forwards;
}
.thv-29 {
width: 100%;
min-height: 100svh;
}
.thv-29 ::selection {
background: #ffffff;
color: #000;
}
/* Runtime-generated elements: the JS mints these nodes after the build has
scanned the document, so their styling cannot live in utilities without a
safelist. Four rules is the honest cost of the port. */
.thv29-dot {
position: absolute;
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
will-change: transform, opacity;
}
[data-trail="classic"] .thv29-dot {
width: 8px;
height: 8px;
background: #fff;
animation: var(--animate-thv29-classic);
}
[data-trail="rainbow"] .thv29-dot {
width: 10px;
height: 10px;
animation: var(--animate-thv29-rainbow);
}
[data-trail="comet"] .thv29-dot {
width: 12px;
height: 4px;
border-radius: 2px;
background: linear-gradient(90deg, rgba(100,180,255,0), rgba(100,180,255,1));
animation: var(--animate-thv29-comet);
}
[data-trail="sparkle"] .thv29-dot {
width: 10px;
height: 10px;
background: #f7d060;
clip-path: polygon(50% 0%,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%);
animation: var(--animate-thv29-sparkle);
}
@keyframes thv29-classic {
0% {
opacity: .9;
transform: translate(-50%,-50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(0);
}
}
@keyframes thv29-rainbow {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(1.1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(.1);
}
}
@keyframes thv29-comet {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(1,1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(3,.3);
}
}
@keyframes thv29-sparkle {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(0) rotate(0deg);
}
60% {
opacity: 1;
transform: translate(-50%,-50%) scale(1.3) rotate(60deg);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(.4) rotate(90deg);
}
}<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&family=JetBrains+Mono:wght@400;500;600;800&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&family=JetBrains+Mono:wght@400;500;600;800&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The port's clearest boundary. Everything the compiler can see is a utility; the runtime-generated dot stays in CSS. Reaching for a safelist here would be worse than the four rules it replaces.
- The
.thv29-dotrules still consume@themevalues viaanimation: var(--animate-thv29-classic), so the tokens remain the single source of truth for timing. - UI change from the CSS original: the controller now bails out entirely under
prefers-reduced-motion; the source merely shortened the animation to 0.01ms, which still churned DOM nodes on every frame.
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 Dot Trail Cursor Hover Effect
Source: https://codefronts.com/motion/tailwind-css-hover-effects/tailwind-dot-trail-cursor-hover-effect/
Four cursor trails inside bounded zones — classic fade, rainbow cycle, comet tail and sparkle burst. The controller is 30 lines; the interesting part of the port is the boundary: elements created at runtime cannot be scanned by a build, so their styling stays in CSS on purpose.
## HTML
```html
<section class="thv-29 w-full min-h-svh bg-thv29-bg font-thv29-sans px-8 py-10 select-none">
<p class="text-center text-[0.72rem] tracking-[0.15em] uppercase text-thv29-dim mb-6">Dot Trail Cursor — 4 Styles</p>
<div class="grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] gap-4 max-w-[740px] mx-auto">
<div data-trail="classic" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Classic Fade</span>
</div>
<div data-trail="rainbow" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Rainbow</span>
</div>
<div data-trail="comet" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Comet Tail</span>
</div>
<div data-trail="sparkle" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Sparkle</span>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-thv29-bg: #080810;
--color-thv29-bg: oklch(0.12 0.02 285);
--color-thv29-panel: #0d0d18;
--color-thv29-panel: oklch(0.15 0.02 285);
--color-thv29-edge: #1a1a2a;
--color-thv29-edge: oklch(0.22 0.02 285);
--color-thv29-dim: #8a8a99;
--color-thv29-dim: oklch(0.64 0.02 285);
--color-thv29-hint: #5e5e78;
--color-thv29-hint: oklch(0.48 0.03 285);
--font-thv29-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-thv29-classic: thv29-classic .6s ease-out forwards;
--animate-thv29-rainbow: thv29-rainbow .7s ease-out forwards;
--animate-thv29-comet: thv29-comet .5s ease-out forwards;
--animate-thv29-sparkle: thv29-sparkle .65s ease-out forwards;
}
.thv-29 {
width: 100%;
min-height: 100svh;
}
.thv-29 ::selection {
background: #ffffff;
color: #000;
}
/* Runtime-generated elements: the JS mints these nodes after the build has
scanned the document, so their styling cannot live in utilities without a
safelist. Four rules is the honest cost of the port. */
.thv29-dot {
position: absolute;
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
will-change: transform, opacity;
}
[data-trail="classic"] .thv29-dot {
width: 8px;
height: 8px;
background: #fff;
animation: var(--animate-thv29-classic);
}
[data-trail="rainbow"] .thv29-dot {
width: 10px;
height: 10px;
animation: var(--animate-thv29-rainbow);
}
[data-trail="comet"] .thv29-dot {
width: 12px;
height: 4px;
border-radius: 2px;
background: linear-gradient(90deg, rgba(100,180,255,0), rgba(100,180,255,1));
animation: var(--animate-thv29-comet);
}
[data-trail="sparkle"] .thv29-dot {
width: 10px;
height: 10px;
background: #f7d060;
clip-path: polygon(50% 0%,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%);
animation: var(--animate-thv29-sparkle);
}
@keyframes thv29-classic {
0% {
opacity: .9;
transform: translate(-50%,-50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(0);
}
}
@keyframes thv29-rainbow {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(1.1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(.1);
}
}
@keyframes thv29-comet {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(1,1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(3,.3);
}
}
@keyframes thv29-sparkle {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(0) rotate(0deg);
}
60% {
opacity: 1;
transform: translate(-50%,-50%) scale(1.3) rotate(60deg);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(.4) rotate(90deg);
}
}
```
## JavaScript
```js
(function () {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const zones = document.querySelectorAll('.thv29-zone');
const RAINBOW = ['#ff6b6b','#ffa94d','#ffd43b','#69db7c','#4dabf7','#cc5de8','#f783ac'];
let hueIndex = 0, rafId = null, pendingZone = null, pendingX = 0, pendingY = 0;
function spawnDot(zone, x, y) {
const dot = document.createElement('span');
dot.className = 'thv29-dot';
dot.style.left = x + 'px';
dot.style.top = y + 'px';
if (zone.dataset.trail === 'rainbow') {
dot.style.background = RAINBOW[hueIndex++ % RAINBOW.length];
}
zone.appendChild(dot);
dot.addEventListener('animationend', () => dot.remove(), { once: true });
}
function onMove(e) {
const zone = e.currentTarget;
const rect = zone.getBoundingClientRect();
pendingX = e.clientX - rect.left;
pendingY = e.clientY - rect.top;
pendingZone = zone;
if (!rafId) {
rafId = requestAnimationFrame(() => {
if (pendingZone) spawnDot(pendingZone, pendingX, pendingY);
rafId = null;
});
}
}
zones.forEach((zone) => {
zone.addEventListener('mousemove', onMove);
zone.addEventListener('mouseleave', () => { pendingZone = null; });
});
})();
```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 Dot Trail Cursor Hover Effect
Source: https://codefronts.com/motion/tailwind-css-hover-effects/tailwind-dot-trail-cursor-hover-effect/
Four cursor trails inside bounded zones — classic fade, rainbow cycle, comet tail and sparkle burst. The controller is 30 lines; the interesting part of the port is the boundary: elements created at runtime cannot be scanned by a build, so their styling stays in CSS on purpose.
## HTML
```html
<section class="thv-29 w-full min-h-svh bg-thv29-bg font-thv29-sans px-8 py-10 select-none">
<p class="text-center text-[0.72rem] tracking-[0.15em] uppercase text-thv29-dim mb-6">Dot Trail Cursor — 4 Styles</p>
<div class="grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] gap-4 max-w-[740px] mx-auto">
<div data-trail="classic" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Classic Fade</span>
</div>
<div data-trail="rainbow" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Rainbow</span>
</div>
<div data-trail="comet" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Comet Tail</span>
</div>
<div data-trail="sparkle" class="thv29-zone relative overflow-hidden grid place-items-center aspect-[4/3] rounded-xl border border-thv29-edge bg-thv29-panel cursor-none">
<span class="text-[0.75rem] tracking-[0.1em] uppercase text-thv29-hint pointer-events-none">Sparkle</span>
</div>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-thv29-bg: #080810;
--color-thv29-bg: oklch(0.12 0.02 285);
--color-thv29-panel: #0d0d18;
--color-thv29-panel: oklch(0.15 0.02 285);
--color-thv29-edge: #1a1a2a;
--color-thv29-edge: oklch(0.22 0.02 285);
--color-thv29-dim: #8a8a99;
--color-thv29-dim: oklch(0.64 0.02 285);
--color-thv29-hint: #5e5e78;
--color-thv29-hint: oklch(0.48 0.03 285);
--font-thv29-sans: 'Space Grotesk', system-ui, sans-serif;
--animate-thv29-classic: thv29-classic .6s ease-out forwards;
--animate-thv29-rainbow: thv29-rainbow .7s ease-out forwards;
--animate-thv29-comet: thv29-comet .5s ease-out forwards;
--animate-thv29-sparkle: thv29-sparkle .65s ease-out forwards;
}
.thv-29 {
width: 100%;
min-height: 100svh;
}
.thv-29 ::selection {
background: #ffffff;
color: #000;
}
/* Runtime-generated elements: the JS mints these nodes after the build has
scanned the document, so their styling cannot live in utilities without a
safelist. Four rules is the honest cost of the port. */
.thv29-dot {
position: absolute;
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
will-change: transform, opacity;
}
[data-trail="classic"] .thv29-dot {
width: 8px;
height: 8px;
background: #fff;
animation: var(--animate-thv29-classic);
}
[data-trail="rainbow"] .thv29-dot {
width: 10px;
height: 10px;
animation: var(--animate-thv29-rainbow);
}
[data-trail="comet"] .thv29-dot {
width: 12px;
height: 4px;
border-radius: 2px;
background: linear-gradient(90deg, rgba(100,180,255,0), rgba(100,180,255,1));
animation: var(--animate-thv29-comet);
}
[data-trail="sparkle"] .thv29-dot {
width: 10px;
height: 10px;
background: #f7d060;
clip-path: polygon(50% 0%,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%);
animation: var(--animate-thv29-sparkle);
}
@keyframes thv29-classic {
0% {
opacity: .9;
transform: translate(-50%,-50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(0);
}
}
@keyframes thv29-rainbow {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(1.1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(.1);
}
}
@keyframes thv29-comet {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(1,1);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(3,.3);
}
}
@keyframes thv29-sparkle {
0% {
opacity: 1;
transform: translate(-50%,-50%) scale(0) rotate(0deg);
}
60% {
opacity: 1;
transform: translate(-50%,-50%) scale(1.3) rotate(60deg);
}
100% {
opacity: 0;
transform: translate(-50%,-50%) scale(.4) rotate(90deg);
}
}
```
## JavaScript
```js
(function () {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const zones = document.querySelectorAll('.thv29-zone');
const RAINBOW = ['#ff6b6b','#ffa94d','#ffd43b','#69db7c','#4dabf7','#cc5de8','#f783ac'];
let hueIndex = 0, rafId = null, pendingZone = null, pendingX = 0, pendingY = 0;
function spawnDot(zone, x, y) {
const dot = document.createElement('span');
dot.className = 'thv29-dot';
dot.style.left = x + 'px';
dot.style.top = y + 'px';
if (zone.dataset.trail === 'rainbow') {
dot.style.background = RAINBOW[hueIndex++ % RAINBOW.length];
}
zone.appendChild(dot);
dot.addEventListener('animationend', () => dot.remove(), { once: true });
}
function onMove(e) {
const zone = e.currentTarget;
const rect = zone.getBoundingClientRect();
pendingX = e.clientX - rect.left;
pendingY = e.clientY - rect.top;
pendingZone = zone;
if (!rafId) {
rafId = requestAnimationFrame(() => {
if (pendingZone) spawnDot(pendingZone, pendingX, pendingY);
rafId = null;
});
}
}
zones.forEach((zone) => {
zone.addEventListener('mousemove', onMove);
zone.addEventListener('mouseleave', () => { pendingZone = null; });
});
})();
```How this works
Where the port stops. Tailwind scans source for class names. A dot that does not exist until mousemove has no class name to find, so styling it with utilities would mean maintaining a safelist. The honest answer is four small CSS rules — and stating that boundary is more useful than hiding it.
The zones are pure utilities. Everything the compiler can see — layout, radius, aspect, clipping, the hint label — is a class list.
Spawning is throttled and self-cleaning. One dot per animation frame, removed on animationend so a duration change in CSS cleans up correctly without touching the JS.
Make it yours
- Add a trail by adding a
data-trailvalue, an@themeanimation entry and one.thv29-dotrule. - Density: spawn every other frame for a sparser trail.
- Swap the sparkle's
clip-pathpolygon for any other glyph shape.
Gotchas — read before shipping
- Dots must be positioned inside a
relativezone, not the body, or scroll offsets misplace them. - Remove on
animationend, neversetTimeout— the CSS duration stays the single source of truth. - Unthrottled
mousemovecreates thousands of nodes a second; the rAF gate is not optional. cursor-nonehides the pointer, so the zone must not contain anything clickable.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
animationend and clip-path are universally supported; the floor is Tailwind v4's generated sheet.