Tailwind + JSMIT licensed

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.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

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>
@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; });
  });
})();

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<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.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@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);
  }
}
3Fonts used by this demooptional — skip if you have your own type stack
<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-dot rules still consume @theme values via animation: 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.
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
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-trail value, an @theme animation entry and one .thv29-dot rule.
  • Density: spawn every other frame for a sparser trail.
  • Swap the sparkle's clip-path polygon for any other glyph shape.

Gotchas — read before shipping

  • Dots must be positioned inside a relative zone, not the body, or scroll offsets misplace them.
  • Remove on animationend, never setTimeout — the CSS duration stays the single source of truth.
  • Unthrottled mousemove creates thousands of nodes a second; the rAF gate is not optional.
  • cursor-none hides the pointer, so the zone must not contain anything clickable.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

animationend and clip-path are universally supported; the floor is Tailwind v4's generated sheet.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Arbitraryneeds bracket syntax

Plain CSSno Tailwind surface

Search CodeFronts

Loading…