6 CSS Custom Cursor Styles05 / 06

CSS + JSMIT licensed

Dynamic Cursor Text Badge (View / Play / Read) That Follows the Mouse over a Gallery

The e-commerce and portfolio favourite: hovering a product or project card reveals a pill — 'View', 'Play', 'Read' — that glides along with the pointer, replacing the native cursor. The label is driven by a data-attribute per card, so one handler powers a whole grid, and the badge scales in with a spring while staying perfectly readable against imagery.

Published

Live Demo
Try it

The code

<section class="ccs-05">
  <div class="ccs-05__wrap">
    <div class="ccs-05__grid" data-grid>
      <div class="ccs-05__badge" data-badge aria-hidden="true">View</div>
      <a class="ccs-05__card ccs-05__card--a" href="#" data-cursor-label="View"><span>Case study</span><strong>Northwind rebrand</strong></a>
      <a class="ccs-05__card ccs-05__card--b" href="#" data-cursor-label="Play"><span>Motion reel</span><strong>Kinetic titles</strong></a>
      <a class="ccs-05__card ccs-05__card--c" href="#" data-cursor-label="Read"><span>Journal</span><strong>Designing in oklch</strong></a>
      <a class="ccs-05__card ccs-05__card--d" href="#" data-cursor-label="View"><span>Case study</span><strong>Halcyon store</strong></a>
    </div>
  </div>
</section>
.ccs-05,
.ccs-05 *,
.ccs-05 *::before,
.ccs-05 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.ccs-05 {
  --bg: oklch(0.15 0.01 265);
  --text: oklch(0.96 0.005 265);
  --accent: oklch(0.82 0.15 90);
  --muted: color-mix(in oklab,var(--text) 55%,var(--bg));
  color-scheme: dark;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 100vh;
  min-height: 100dvh;
  padding: 28px;
  font-family: system-ui,'Segoe UI',sans-serif;
  background: var(--bg);
  color: var(--text);
}

.ccs-05__wrap {
  width: min(600px,100%);
}

.ccs-05__grid {
  position: relative;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 14px;
}

.ccs-05__card {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: 3px;
  min-height: 150px;
  padding: 20px;
  border-radius: 18px;
  text-decoration: none;
  color: #fff;
  overflow: hidden;
  isolation: isolate;
}

.ccs-05__card span {
  font: 600 11px/1 ui-monospace,Menlo,monospace;
  letter-spacing: .08em;
  text-transform: uppercase;
  opacity: .85;
}

.ccs-05__card strong {
  font: 700 18px/1.15 system-ui,sans-serif;
}

.ccs-05__card--a {
  background: linear-gradient(150deg,oklch(0.55 0.18 25),oklch(0.3 0.1 25));
}

.ccs-05__card--b {
  background: linear-gradient(150deg,oklch(0.55 0.16 285),oklch(0.3 0.1 285));
}

.ccs-05__card--c {
  background: linear-gradient(150deg,oklch(0.55 0.15 155),oklch(0.3 0.09 155));
}

.ccs-05__card--d {
  background: linear-gradient(150deg,oklch(0.6 0.16 220),oklch(0.32 0.1 220));
}

.ccs-05__card:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

.ccs-05__badge {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  display: grid;
  place-items: center;
  min-width: 64px;
  height: 64px;
  padding: 0 12px;
  border-radius: 999px;
  font: 700 13px/1 system-ui,sans-serif;
  letter-spacing: .02em;
  color: oklch(0.2 0.02 90);
  background: var(--accent);
  box-shadow: 0 8px 24px oklch(0 0 0/.4);
  pointer-events: none;
  opacity: 0;
  scale: .6;
  transition: opacity .2s ease,scale .2s ease;
}

.ccs-05__grid.is-live .ccs-05__badge {
  opacity: 1;
  scale: 1;
}

@media (hover: hover) and (pointer: fine) {
  .ccs-05__grid.is-live {
    cursor: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .ccs-05__grid.is-live {
    cursor: auto;
  }

  .ccs-05__badge {
    transition: opacity .2s ease;
  }
}
(function () {
  var grid = document.querySelector('.ccs-05__grid');
  var badge = grid.querySelector('[data-badge]');
  if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
  var reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;

  var mx = 0, my = 0, bx = 0, by = 0, raf = 0, running = false;

  grid.addEventListener('pointerover', function (e) {
    var card = e.target.closest('[data-cursor-label]');
    if (!card) return;
    badge.textContent = card.dataset.cursorLabel;
    grid.classList.add('is-live');
    if (!running) { running = true; raf = requestAnimationFrame(loop); }
  });
  grid.addEventListener('pointermove', function (e) {
    var r = grid.getBoundingClientRect();
    mx = e.clientX - r.left; my = e.clientY - r.top;
    if (reduce) badge.style.transform = 'translate(' + mx + 'px,' + my + 'px) translate(-50%,-50%)';
  });
  grid.addEventListener('pointerout', function (e) {
    if (e.relatedTarget && grid.contains(e.relatedTarget)) return;
    grid.classList.remove('is-live');
    running = false; cancelAnimationFrame(raf);
  });

  function loop() {
    bx += (mx - bx) * 0.22; by += (my - by) * 0.22;
    badge.style.transform = 'translate(' + bx + 'px,' + by + 'px) translate(-50%,-50%)';
    if (running) raf = requestAnimationFrame(loop);
  }
})();
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 Custom Cursor Style 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: Dynamic Cursor Text Badge (View / Play / Read) That Follows the Mouse over a Gallery
Source: https://codefronts.com/snippets/css-custom-cursor/view-badge-cursor/

The e-commerce and portfolio favourite: hovering a product or project card reveals a pill — 'View', 'Play', 'Read' — that glides along with the pointer, replacing the native cursor. The label is driven by a data-attribute per card, so one handler powers a whole grid, and the badge scales in with a spring while staying perfectly readable against imagery.
## HTML
```html
<section class="ccs-05">
  <div class="ccs-05__wrap">
    <div class="ccs-05__grid" data-grid>
      <div class="ccs-05__badge" data-badge aria-hidden="true">View</div>
      <a class="ccs-05__card ccs-05__card--a" href="#" data-cursor-label="View"><span>Case study</span><strong>Northwind rebrand</strong></a>
      <a class="ccs-05__card ccs-05__card--b" href="#" data-cursor-label="Play"><span>Motion reel</span><strong>Kinetic titles</strong></a>
      <a class="ccs-05__card ccs-05__card--c" href="#" data-cursor-label="Read"><span>Journal</span><strong>Designing in oklch</strong></a>
      <a class="ccs-05__card ccs-05__card--d" href="#" data-cursor-label="View"><span>Case study</span><strong>Halcyon store</strong></a>
    </div>
  </div>
</section>
```
## CSS
```css
.ccs-05,
.ccs-05 *,
.ccs-05 *::before,
.ccs-05 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.ccs-05 {
  --bg: oklch(0.15 0.01 265);
  --text: oklch(0.96 0.005 265);
  --accent: oklch(0.82 0.15 90);
  --muted: color-mix(in oklab,var(--text) 55%,var(--bg));
  color-scheme: dark;
  display: grid;
  place-items: center;
  width: 100%;
  min-height: 100vh;
  min-height: 100dvh;
  padding: 28px;
  font-family: system-ui,'Segoe UI',sans-serif;
  background: var(--bg);
  color: var(--text);
}

.ccs-05__wrap {
  width: min(600px,100%);
}

.ccs-05__grid {
  position: relative;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 14px;
}

.ccs-05__card {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: 3px;
  min-height: 150px;
  padding: 20px;
  border-radius: 18px;
  text-decoration: none;
  color: #fff;
  overflow: hidden;
  isolation: isolate;
}

.ccs-05__card span {
  font: 600 11px/1 ui-monospace,Menlo,monospace;
  letter-spacing: .08em;
  text-transform: uppercase;
  opacity: .85;
}

.ccs-05__card strong {
  font: 700 18px/1.15 system-ui,sans-serif;
}

.ccs-05__card--a {
  background: linear-gradient(150deg,oklch(0.55 0.18 25),oklch(0.3 0.1 25));
}

.ccs-05__card--b {
  background: linear-gradient(150deg,oklch(0.55 0.16 285),oklch(0.3 0.1 285));
}

.ccs-05__card--c {
  background: linear-gradient(150deg,oklch(0.55 0.15 155),oklch(0.3 0.09 155));
}

.ccs-05__card--d {
  background: linear-gradient(150deg,oklch(0.6 0.16 220),oklch(0.32 0.1 220));
}

.ccs-05__card:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

.ccs-05__badge {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10;
  display: grid;
  place-items: center;
  min-width: 64px;
  height: 64px;
  padding: 0 12px;
  border-radius: 999px;
  font: 700 13px/1 system-ui,sans-serif;
  letter-spacing: .02em;
  color: oklch(0.2 0.02 90);
  background: var(--accent);
  box-shadow: 0 8px 24px oklch(0 0 0/.4);
  pointer-events: none;
  opacity: 0;
  scale: .6;
  transition: opacity .2s ease,scale .2s ease;
}

.ccs-05__grid.is-live .ccs-05__badge {
  opacity: 1;
  scale: 1;
}

@media (hover: hover) and (pointer: fine) {
  .ccs-05__grid.is-live {
    cursor: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .ccs-05__grid.is-live {
    cursor: auto;
  }

  .ccs-05__badge {
    transition: opacity .2s ease;
  }
}
```

## JavaScript
```js
(function () {
  var grid = document.querySelector('.ccs-05__grid');
  var badge = grid.querySelector('[data-badge]');
  if (!matchMedia('(hover:hover) and (pointer:fine)').matches) return;
  var reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;

  var mx = 0, my = 0, bx = 0, by = 0, raf = 0, running = false;

  grid.addEventListener('pointerover', function (e) {
    var card = e.target.closest('[data-cursor-label]');
    if (!card) return;
    badge.textContent = card.dataset.cursorLabel;
    grid.classList.add('is-live');
    if (!running) { running = true; raf = requestAnimationFrame(loop); }
  });
  grid.addEventListener('pointermove', function (e) {
    var r = grid.getBoundingClientRect();
    mx = e.clientX - r.left; my = e.clientY - r.top;
    if (reduce) badge.style.transform = 'translate(' + mx + 'px,' + my + 'px) translate(-50%,-50%)';
  });
  grid.addEventListener('pointerout', function (e) {
    if (e.relatedTarget && grid.contains(e.relatedTarget)) return;
    grid.classList.remove('is-live');
    running = false; cancelAnimationFrame(raf);
  });

  function loop() {
    bx += (mx - bx) * 0.22; by += (my - by) * 0.22;
    badge.style.transform = 'translate(' + bx + 'px,' + by + 'px) translate(-50%,-50%)';
    if (running) raf = requestAnimationFrame(loop);
  }
})();
```

How this works

One badge element lives at the grid level with pointer-events:none so it never blocks the links underneath. Each card carries its own copy in data-cursor-label ("View", "Play", "Read"). A single delegated pointerover handler reads the hovered card's label and writes it into the badge; pointermove positions the badge with transform: translate() (plus a gentle lerp so it trails elegantly); pointerout hides it.

Show/hide is a CSS transition on opacity and scale — the badge springs from 0.6 to 1 for a lively 'pop'. The native cursor is hidden only while over a labelled card (cursor:none scoped to the grid on hover), so the badge is a genuine replacement, not an addition. Because the label comes from the DOM, adding a card with a new verb needs zero JS changes. All of it sits behind the pointer-fine + reduced-motion guards, and the cards remain ordinary focusable links for keyboard users.

Techniques used: data-attribute-driven label (one handler, many cards) · event delegation (pointerover / pointerout) · transform follow with lerp trailing · opacity + scale spring reveal · pointer-events:none so the badge never blocks clicks · cursor:none scoped to hovered cards only

grid.addEventListener('pointerover', e => {
  const card = e.target.closest('[data-cursor-label]');
  if (card) badge.textContent = card.dataset.cursorLabel;  // "View" / "Play" / "Read"
});
grid.addEventListener('pointermove', e => { /* translate badge to the pointer */ });

Make it yours

  • Add an arrow or play glyph inside the pill and switch it per label with a second data-attribute.
  • Rotate the badge slightly toward the drag direction using pointer velocity for extra life.
  • Blend it with mix-blend-mode:difference so one pill color reads over any thumbnail.
  • Debounce the label write if cards are tightly packed to avoid flicker at card borders.

Gotchas — read before shipping

  • Keep pointer-events:none on the badge, or it will sit between the cursor and the card and swallow clicks.
  • Use closest('[data-cursor-label]'), not the raw e.target — the pointer is usually over an inner element (image, heading), not the card itself.
  • Cards must stay real, focusable links/buttons so keyboard and screen-reader users can reach them without the hover badge.
  • Guard for touch: on phones the badge can't follow a finger, so leave native behavior in place.

Browser support

ChromeSafariFirefoxEdge
111+16.2+113+111+

Floor set by oklch(), color-mix() as this demo is written. The core technique itself goes back further — Chrome 55+.

Event delegation, Pointer Events and dataset are universal in evergreen browsers. mix-blend-mode (optional) is broadly supported.

Techniques used in this demo

Search CodeFronts

Loading…