Showcase Video

2025-07-25T111321

SDC Component

cursor-animated-custom.component.yml

1
2
3
4
5
$schema: https://git.drupalcode.org/project/drupal/-/raw/10.1.x/core/modules/sdc/src/metadata.schema.json
name: cursor-opc
status: experimental
description: 'A custom cursor component that follows mouse movement with smooth animation'
props: {}

cursor-animated-custom.twig

1
2
3
<div id="cursor-custom" class="cursor-custom">
  <div class="cursor-custom__dot"></div>
</div>

_cursor-animated-custom.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/**
 * Custom cursor functionality
 */
(function () {
  'use strict';

  // Initialize once DOM is fully loaded
  document.addEventListener('DOMContentLoaded', () => {
    const cursor = document.querySelector('.cursor-custom__dot');

    if (!cursor) return;

    let mouseX = 0;
    let mouseY = 0;
    let cursorX = 0;
    let cursorY = 0;

    // Smooth animation function
    const animate = () => {
      const diffX = mouseX - cursorX;
      const diffY = mouseY - cursorY;

      cursorX += diffX * 0.1;
      cursorY += diffY * 0.1;

      // Use translate3d for better performance and combine with scale if active
      const scale = cursor.classList.contains('is-active-dot') ? 'scale(2)' : 'scale(1)';
      cursor.style.transform = `translate3d(${cursorX}px, ${cursorY}px, 0) ${scale}`;
      requestAnimationFrame(animate);
    };

    // Track mouse movement
    document.addEventListener('mousemove', (e) => {
      mouseX = e.clientX;
      mouseY = e.clientY;
    });

    // Handle cursor scale/color on clickable elements
    document.querySelectorAll('a, button, [role="button"], input[type="submit"]').forEach(el => {
      el.addEventListener('mouseenter', () => cursor.classList.add('is-active-dot'));
      el.addEventListener('mouseleave', () => cursor.classList.remove('is-active-dot'));
    });

    // Handle cursor on hover of the iframe
    document.querySelectorAll('iframe').forEach(el => {
      el.addEventListener('mouseenter', () => cursor.classList.add('is-dot-in-iframe'));
      el.addEventListener('mouseleave', () => cursor.classList.remove('is-dot-in-iframe'));
    });

    // Start animation
    animate();
  });
})();

cursor-animated-custom.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Hide the default cursor
body:has(#cursor-custom) {
    cursor: visible !important;

    // Base cursor container
    #cursor-custom {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        z-index: 9999;
        pointer-events: none;
        mix-blend-mode: difference;

        // Cursor dot inside the cursor-custom container
        &>div.cursor-custom__dot {
            position: fixed;
            top: -15px;
            left: -15px;
            width: 20px;
            height: 20px;
            background-color: white;
            border-radius: 50%;
            transform-origin: center;
            transition: transform 0.05s ease-out, background-color 0.5s ease-out;
            will-change: transform;
            &.is-active-dot {   background-color: rgb(46, 46, 46);       }
            &.is-dot-in-iframe {background-color: transparent !important;}
        }
    }
}

And then simply chuck the following in your page.html.twig or other template where relevant

1
+{% include 'radix_opc:cursor-opc' %}

Reference

https://codepen.io/seiko-yamaguchi/pen/gORrrKP