Custom Cursor

Lightweight custom cursor library — hover, click, and interact with the examples below.

0. Setup

A single cursor wrapper with two items — a dot that follows instantly and a ring that follows with smooth delay.

<div class="c--cursor-a">
  <span class="c--cursor-a__item" data-lerp="1"></span>
  <span class="c--cursor-a__artwork" data-lerp="0.15"></span>
</div>
.c--cursor-a {
  position: fixed;
  top: 0;
  left: 0;
  pointer-events: none;
  z-index: 10000;

  &--is-hidden {
    .c--cursor-a__item,
    .c--cursor-a__artwork { opacity: 0; }
  }

  &--is-active {
    .c--cursor-a__item { background-color: #e74c3c; }
    .c--cursor-a__artwork { border-color: #e74c3c; }
  }

  &__item {
    position: fixed;
    top: 0;
    left: 0;
    border-radius: 50%;
    pointer-events: none;
    width: 8px;
    height: 8px;
    background: #111;
    margin-left: -4px;
    margin-top: -4px;
  }

  &__artwork {
    position: fixed;
    top: 0;
    left: 0;
    border-radius: 50%;
    pointer-events: none;
    width: 40px;
    height: 40px;
    border: 1.5px solid #111;
    margin-left: -20px;
    margin-top: -20px;
  }
}
import CustomCursor from '@andresclua/custom-cursor';

const cursor = new CustomCursor({
  element: '.c--cursor-a',
  hideTrueCursor: true,
  disableTouch: true,
  focusElements: [
    'a',
    'button',
    '.js--focus',
    { elements: '.js--grow', focusClass: 'c--cursor-a--third' },
    {
      elements: '.js--text',
      focusClass: 'c--cursor-a--fourth',
      mouseenter(cursorEl, el) { /* ... */ },
      mouseleave(cursorEl) { /* ... */ },
    },
  ],
  focusClass: 'c--cursor-a--is-active',
  hiddenClass: 'c--cursor-a--is-hidden',
  clickingClass: 'c--cursor-a--second',
  onInit: null,
  onDestroy: null,
  onMove: null,
});

// #init() and #events() run automatically (private)

1. Basic Focus (links & buttons)

Hover over links and buttons — the cursor changes color.

<a href="#" class="c--card-a">Link Card</a>
<button class="c--btn-a">Button</button>
<a href="#" class="c--card-a">Another Link</a>
/* Dot turns red, ring border turns red */
.c--cursor-a {
  &--is-active {
    .c--cursor-a__item { background-color: #e74c3c; }
    .c--cursor-a__artwork { border-color: #e74c3c; }
  }
}
// Already handled by focusElements in the constructor:
const cursor = new CustomCursor({
  element: '.c--cursor-a',
  focusElements: ['a', 'button'],
  focusClass: 'c--cursor-a--is-active',
});

2. Custom Focus Class (grow)

Hover over these cards — the cursor grows.

Grow Card A
Grow Card B
<div class="c--card-a js--grow">Grow Card A</div>
<div class="c--card-a js--grow">Grow Card B</div>
/* Dot hides, ring expands */
.c--cursor-a {
  &--third {
    .c--cursor-a__item { opacity: 0; }
    .c--cursor-a__artwork {
      width: 60px;
      height: 60px;
      margin-left: -30px;
      margin-top: -30px;
      border-color: rgba(52, 152, 219, 0.6);
      background-color: rgba(52, 152, 219, 0.1);
    }
  }
}
// Pass in the constructor focusElements array:
const cursor = new CustomCursor({
  element: '.c--cursor-a',
  focusElements: [
    { elements: '.js--grow', focusClass: 'c--cursor-a--third' },
  ],
});

3. Focus with Callbacks (text)

Hover over these cards — the cursor shows text from each card's data-cursor-text attribute.

View Project
Read Article
Play Video
Explore Gallery
<div class="c--card-a js--text" data-cursor-text="View">View Project</div>
<div class="c--card-a js--text" data-cursor-text="Read">Read Article</div>
<div class="c--card-a js--text" data-cursor-text="Play">Play Video</div>
<div class="c--card-a js--text" data-cursor-text="Explore">Explore Gallery</div>
/* Dot hides, ring becomes filled circle with text */
.c--cursor-a {
  &--fourth {
    .c--cursor-a__item { opacity: 0; }
    .c--cursor-a__artwork {
      width: 80px;
      height: 80px;
      margin-left: -40px;
      margin-top: -40px;
      border-color: transparent;
      background-color: rgba(0, 0, 0, 0.85);
    }
  }
}
// Pass in the constructor focusElements array:
const cursor = new CustomCursor({
  element: '.c--cursor-a',
  focusElements: [
    {
      elements: '.js--text',
      focusClass: 'c--cursor-a--fourth',
      mouseenter(cursorEl, el) {
        cursorEl.querySelector('.c--cursor-a__artwork').textContent =
          el.dataset.cursorText || 'View';
      },
      mouseleave(cursorEl) {
        cursorEl.querySelector('.c--cursor-a__artwork').textContent = '';
      },
    },
  ],
});

4. Default Text (persistent label)

The cursor shows a default label. Hover over cards to change the text; on leave it reverts to the default.

View Project
Read Article
Play Video
<section class="js--label-zone" data-cursor-text="Hi">
  <div class="c--card-a js--label" data-cursor-text="View">View Project</div>
  <div class="c--card-a js--label" data-cursor-text="Read">Read Article</div>
  <div class="c--card-a js--label" data-cursor-text="Play">Play Video</div>
</section>
/* Dot hides, ring becomes filled circle with persistent text */
.c--cursor-a {
  &--fifth {
    .c--cursor-a__item { opacity: 0; }
    .c--cursor-a__artwork {
      width: 80px;
      height: 80px;
      margin-left: -40px;
      margin-top: -40px;
      border-color: transparent;
      background-color: rgba(44, 62, 80, 0.9);
    }
  }
}
// Pass both zone and cards in the constructor focusElements array:
const cursor = new CustomCursor({
  element: '.c--cursor-a',
  focusElements: [
    // 1. Zone: enter → show default text, leave → clear
    {
      elements: '.js--label-zone',
      focusClass: 'c--cursor-a--fifth',
      mouseenter(cursorEl, el) {
        cursorEl.querySelector('.c--cursor-a__artwork').textContent =
          el.dataset.cursorText || 'Hi';
      },
      mouseleave(cursorEl) {
        cursorEl.querySelector('.c--cursor-a__artwork').textContent = '';
      },
    },
    // 2. Cards: enter → swap text, leave → revert to zone default
    {
      elements: '.js--label',
      focusClass: 'c--cursor-a--sixth',
      mouseenter(cursorEl, el) {
        cursorEl.querySelector('.c--cursor-a__artwork').textContent =
          el.dataset.cursorText || 'View';
      },
      mouseleave(cursorEl, el) {
        const zone = el.closest('.js--label-zone');
        cursorEl.querySelector('.c--cursor-a__artwork').textContent =
          zone ? zone.dataset.cursorText : 'Hi';
      },
    },
  ],
});

5. Disable / Enable

Toggle the custom cursor on and off.

<button class="c--btn-a" id="js--toggle">Disable Cursor</button>
const toggleBtn = document.getElementById('js--toggle');
let isDisabled = false;

toggleBtn.addEventListener('click', () => {
  isDisabled = !isDisabled;
  if (isDisabled) {
    cursor.disable();
  } else {
    cursor.enable();
  }
});

6. Update Options

Change cursor options at runtime.

<button class="c--btn-a" id="js--update">Toggle Large Cursor</button>
const updateBtn = document.getElementById('js--update');
let isLarge = false;

updateBtn.addEventListener('click', () => {
  isLarge = !isLarge;
  if (isLarge) {
    cursor.update({ focusClass: 'c--cursor-a--third' });
  } else {
    cursor.update({ focusClass: 'c--cursor-a--is-active' });
  }
});

7. Generic Focus Elements

Any element with .js--focus triggers the focused state.

Focusable Span
Focusable Div
<span class="js--focus">Focusable Span</span>
<div class="js--focus">Focusable Div</div>
// Pass '.js--focus' in the constructor focusElements array:
const cursor = new CustomCursor({
  element: '.c--cursor-a',
  focusElements: ['a', 'button', '.js--focus'],
  focusClass: 'c--cursor-a--is-active',
});

8. Dynamic Content (AJAX / Load More)

When new HTML is injected into the DOM (AJAX, sliders, infinite scroll), the cursor doesn't know about the new elements. Call cursor.update({}) to re-bind — selectors are re-evaluated and new nodes are picked up automatically.

Card 01
Card 02
<div class="example__row" id="js--grid">
  <div class="c--card-a js--dynamic" data-cursor-text="01">Card 01</div>
  <div class="c--card-a js--dynamic" data-cursor-text="02">Card 02</div>
</div>
<button class="c--btn-a" id="js--load-more">Load More</button>
/* Same text-mode styles from example 3 */
.c--cursor-a {
  &--fourth {
    .c--cursor-a__item { opacity: 0; }
    .c--cursor-a__artwork {
      width: 80px;
      height: 80px;
      margin-left: -40px;
      margin-top: -40px;
      border-color: transparent;
      background-color: rgba(0, 0, 0, 0.85);
    }
  }
}
// 1. Dynamic cards are registered via selector in the constructor:
const cursor = new CustomCursor({
  element: '.c--cursor-a',
  focusElements: [
    {
      elements: '.js--dynamic',
      focusClass: 'c--cursor-a--fourth',
      mouseenter(cursorEl, el) {
        cursorEl.querySelector('.c--cursor-a__artwork').textContent =
          el.dataset.cursorText || 'View';
      },
      mouseleave(cursorEl) {
        cursorEl.querySelector('.c--cursor-a__artwork').textContent = '';
      },
    },
  ],
});

// 2. Simulate AJAX — inject new cards and call update()
const grid = document.getElementById('js--grid');
const loadMoreBtn = document.getElementById('js--load-more');
let cardCount = 2;

loadMoreBtn.addEventListener('click', () => {
  for (let i = 0; i < 2; i++) {
    cardCount++;
    const card = document.createElement('div');
    card.className = 'c--card-a js--dynamic';
    card.dataset.cursorText = String(cardCount).padStart(2, '0');
    card.textContent = 'Card ' + String(cardCount).padStart(2, '0');
    grid.appendChild(card);
  }

  // 3. Key step: re-bind so new .js--dynamic elements are picked up
  cursor.update({});
});