Cursor Morph
A custom cursor that changes shape based on what it hovers: a diamond over a link, a blinking bar over a text input, a circle by default. Each state is driven by a data-morph attribute read on every event; position follows with a 15% lerp.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Cursors has 26 effects, including 1 free. Explore the category →
3 usage examples



How it works
As soon as the pointer enters #zone-morph, the code adds the class cursor-active and switches #cursorMorph from display: none to display: block. It is the class cursor-active — which you must pair with cursor: none in your CSS — that hides the native cursor inside the zone. #cursorMorph is a fixed-positioned <div> that serves as the replacement cursor. On exit, the class is removed, the cursor hidden and its shape classes reset. The system cursor resumes immediately.
On every mousemove, the code reads e.target.getAttribute('data-morph') — the attribute set on the element directly under the pointer. Based on the value: the class morph-link renders #cursorMorph as 12 × 12 px with no border-radius, rotated 45° — an indigo diamond. The class morph-input applies 2 × 24 px rounded to 1 px, paired with an animation that holds opacity at 1 for the first half of the cycle, then cuts it to 0 with a hard jump — a reproduction of the native text cursor. Without a data-morph attribute, no morph class is added. Before each assignment, className is reset: no residual class survives. The shape change is instantaneous — a plain CSS class swap.
The position tracks the mouse via a requestAnimationFrame loop and a 15% lerp: on each frame, the current position advances 15% of the remaining gap. The cursor never sticks exactly to the pointer — it converges quickly with a slight, perceptible lag. Centering is recomputed on every frame from offsetWidth and offsetHeight: when the shape switches from the 12 × 12 diamond to the 2 × 24 bar, the anchor point self-corrects automatically.
Accessibility
- Hiding the system cursor is a heavy accessibility decision. Some users rely on a high-visibility OS cursor — large size, high contrast, custom shape set through system preferences.
cursor: nonewipes all of those adaptations without distinction. Limit its scope to the exact demo zone via thecursor-activeclass and never apply it to real form fields or any element where a text cursor is expected. - prefers-reduced-motion: absent from the code. Two animations run simultaneously: the continuous lerp via
requestAnimationFrameand thecursor-blinkanimation on the input state. Gate the loop initialization behindwindow.matchMedia('(prefers-reduced-motion: reduce)')and neutralize the CSS animation under the corresponding media query before deploying. - The effect is 100% mouse-driven. On touch screens, neither
mouseenternormousemovefires: the custom cursor stays hidden, the native cursor is unaffected. Verify that every element marked withdata-morphremains interactive without the effect: the attribute does not substitute for a:focusstate or keyboard behavior. - The custom cursor is a purely visual
<div>with no ARIA role and no text content. Assistive technologies ignore it. Never carry functional information through cursor shape: a blinking bar does not tell a screen reader that a field is active, and a diamond does not substitute for a:focus-visiblestate on the link itself.
Browser compatibility
Relies on addEventListener, getAttribute, classList, requestAnimationFrame and offsetWidth — APIs available everywhere for years. The real floor is ES2015 syntax (arrow functions, IIFE): IE 11 stops on a syntax error before the zone is ever entered. No CSS vendor prefixes required.
Without JavaScript, <code>#cursorMorph</code> stays at <code>display: none</code> and the system cursor displays normally — no functional content depends on the custom cursor. Key watch-out: if <code>cursor: none</code> is declared in CSS without being conditioned on the <code>cursor-active</code> class, the native cursor disappears even without the effect. Always write the rule as <code>.cursor-active { cursor: none; }</code>, never on the zone element alone.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
| Lerp factor (0.15 in the JS) | 0.15 | Controls the cursor's trail. 0.05 = arrives noticeably behind the mouse. 0.30 = almost sticks to the pointer. 1.0 = instant tracking, no smoothing. On small targets (swatches, icons), raise it to 0.20–0.25 to preserve click precision. |
| Expected data-morph values | 'button', 'link', 'input' | The code reads getAttribute('data-morph') and adds the matching class. For a new state — morph-video on a playback zone for example — add the branch in the mousemove handler and define the shape in CSS. Without the attribute, the cursor takes its default shape. |
| Link state shape (12 × 12 px, rotate 45°) | 12 px × 12 px, rotated 45° | These CSS values define the diamond. Increase the size for a larger diamond, remove the rotation for a square. offsetWidth/offsetHeight is re-read on every frame: changing dimensions in CSS requires no adjustment in the JavaScript. |
| Input state shape (2 × 24 px, border-radius 1 px) | 2 px × 24 px | These dimensions mimic the native text cursor. Increase the height for large text areas; thicken to 4–6 px for a stylized cursor. The offsetWidth/offsetHeight recalculation guarantees correct centering regardless of the chosen geometry. |
| Shared color (background: #6366f1) | #6366f1 (indigo) | The three morph classes each declare their color in their own background rule. For centralized control, replace the hard-coded values with a CSS variable --cursor-color defined at root and referenced in each morph selector. |
| cursor-blink animation (1 s, hard cut at 50%) | 1 s, opaque 0–50%, invisible 51–100% | The keyframes cut opacity to 0 at mid-cycle — no progressive fade. For a snappier blink, reduce to 0.6 s; for a fade, rewrite the keyframes with linear interpolation. Add animation: none under prefers-reduced-motion to suppress blinking without touching the shape. |
FAQ
cursor: none with the cursor-active class in your CSS — the JavaScript does not touch that property. Without that rule, the custom cursor overlays the system cursor and both are visible at the same time. The separation is intentional: the scope of cursor: none stays under your control, not hard-coded into the script.getAttribute('data-morph') returns null when the attribute is absent. The code then resets className to 'custom-cursor cursor-morph' without adding any morph class: the cursor falls back to its default shape. This reset happens on every mousemove regardless of the target — no residual class from a previous hover survives on the cursor element.<input> receives focus and the user starts typing, mousemove stops firing if the pointer does not move. The custom cursor freezes at its last computed position. Make sure cursor: none does not apply to form fields in production: users must see the native text cursor as soon as they type.