Hover List Preview
Point at a row and its picture shows in one frame that follows the pointer, wiping to the next picture as you move down the list. Panel mode, keyboard and touch thumbnails included.
View codeInside its zone the pointer becomes a dot with a soft lag, and a pill that says what a click will do over each item. No GSAP.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add the Kodza Custom Cursor to my project grid.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-cursor-zone">
<a class="kodza-cursor-zone__item" href="#" data-cursor="View">
<img src="https://kodza.dev/media/kz-block-1400.webp" alt="A tall concrete slab standing alone, its long shadow stretching toward the viewer" loading="lazy" />
</a>
<a class="kodza-cursor-zone__item" href="#" data-cursor="Open">
<img src="https://kodza.dev/media/kz-columns-1400.webp" alt="A row of square concrete pillars under a flat roof, casting striped shadows" loading="lazy" />
</a>
<a class="kodza-cursor-zone__item" href="#" data-cursor="Ship it">
<img src="https://kodza.dev/media/kz-float-1400.webp" alt="A concrete block hovering above the ground, over its own shadow" loading="lazy" />
</a>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Custom Cursor
Inside its zone the pointer becomes a small dot that follows with a soft lag; over an
item it grows into a pill that says what a click will do (data-cursor="…").
Hidden on touch screens, where there is no pointer to follow.
Everything is tuned with the --kz-cursor-* variables. */
.kodza-cursor-zone {
--kz-cursor-bg: #0a0a0a;
--kz-cursor-fg: #ffffff;
--kz-cursor-dot: 10px;
--kz-cursor-ratio: 4 / 5;
--kz-cursor-radius: 4px;
--kz-cursor-placeholder: rgba(10, 10, 10, 0.06);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: clamp(14px, 2vw, 24px);
}
.kodza-cursor-zone__item {
display: block;
aspect-ratio: var(--kz-cursor-ratio);
overflow: hidden;
border-radius: var(--kz-cursor-radius);
background: var(--kz-cursor-placeholder);
}
.kodza-cursor-zone__item img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.kodza-cursor-zone__item:focus-visible {
outline: 2px solid currentColor;
outline-offset: 4px;
}
/* The pointer is replaced only where the custom cursor can follow it */
@media (hover: hover) and (pointer: fine) {
.kodza-cursor-zone.is-ready,
.kodza-cursor-zone.is-ready * {
cursor: none;
}
}
/* The cursor itself (created by the script): a dot, and a pill for the labels.
Only transform and opacity move, so it costs nothing to the page layout. */
.kodza-cursor-zone__cursor {
position: fixed;
top: 0;
left: 0;
z-index: 50;
width: 0;
height: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.25s ease;
}
.kodza-cursor-zone__cursor.is-visible {
opacity: 1;
}
.kodza-cursor-zone__dot,
.kodza-cursor-zone__pill {
position: absolute;
top: 0;
left: 0;
border-radius: 999px;
background: var(--kz-cursor-bg);
translate: -50% -50%;
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
}
.kodza-cursor-zone__dot {
width: var(--kz-cursor-dot);
height: var(--kz-cursor-dot);
}
.kodza-cursor-zone__pill {
display: grid;
place-items: center;
height: 40px;
padding: 0 16px;
color: var(--kz-cursor-fg);
font-size: 14px;
font-weight: 500;
white-space: nowrap;
opacity: 0;
transform: scale(0.2);
}
.kodza-cursor-zone__cursor.is-label .kodza-cursor-zone__dot {
opacity: 0;
transform: scale(0.4);
}
.kodza-cursor-zone__cursor.is-label .kodza-cursor-zone__pill {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.kodza-cursor-zone__cursor,
.kodza-cursor-zone__dot,
.kodza-cursor-zone__pill {
transition-duration: 0.01ms !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-cursor-zone {
--kz-cursor-bg: #ffffff;
--kz-cursor-fg: #0a0a0a;
}
*/
/* kodza-guard d13c444ef902 — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */
:where(.kodza-cursor-zone__item):not(#_) {
outline: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-cursor-zone:not(#_),
.kodza-cursor-zone__cursor:not(#_),
.kodza-cursor-zone__dot:not(#_),
.kodza-cursor-zone__pill:not(#_) {
box-sizing: revert;
}
.kodza-cursor-zone__item:not(#_) {
border: revert;
margin: revert;
padding: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-cursor-zone__item img:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-cursor-zone__item:not(#_) {
display: block;
border-radius: var(--kz-cursor-radius);
background: var(--kz-cursor-placeholder);
}
.kodza-cursor-zone__item img:not(#_) {
display: block;
}
.kodza-cursor-zone__item:focus-visible:not(#_) {
outline: 2px solid currentColor;
outline-offset: 4px;
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Custom Cursor
// One cursor per zone. It follows the pointer with a soft lag (a little easing each
// frame, only while it moves), and shows the data-cursor label of the item under it.
// Touch screens and reduced motion keep the normal pointer, or a cursor without lag.
(() => {
const SELECTOR = '.kodza-cursor-zone';
if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const FOLLOW = reduceMotion ? 1 : 0.2; // how much of the way it catches up each frame
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(zone, signal) {
const cursor = document.createElement('div');
cursor.className = 'kodza-cursor-zone__cursor';
cursor.setAttribute('aria-hidden', 'true');
const dot = document.createElement('span');
dot.className = 'kodza-cursor-zone__dot';
const label = document.createElement('span');
label.className = 'kodza-cursor-zone__pill';
cursor.append(dot, label);
zone.append(cursor);
zone.classList.add('is-ready');
const target = { x: 0, y: 0 };
const pos = { x: 0, y: 0 };
let frame = null;
const render = () => {
if (signal.aborted) return;
pos.x += (target.x - pos.x) * FOLLOW;
pos.y += (target.y - pos.y) * FOLLOW;
cursor.style.transform = `translate3d(${pos.x}px, ${pos.y}px, 0)`;
const still = Math.abs(target.x - pos.x) < 0.1 && Math.abs(target.y - pos.y) < 0.1;
frame = still ? null : requestAnimationFrame(render);
};
zone.addEventListener('pointerenter', (event) => {
// Appear right under the pointer, not sliding in from a corner
target.x = pos.x = event.clientX;
target.y = pos.y = event.clientY;
render();
cursor.classList.add('is-visible');
}, { signal });
zone.addEventListener('pointermove', (event) => {
target.x = event.clientX;
target.y = event.clientY;
const item = event.target.closest('[data-cursor]');
if (item) label.textContent = item.dataset.cursor;
cursor.classList.toggle('is-label', Boolean(item));
if (!frame) frame = requestAnimationFrame(render);
}, { signal });
zone.addEventListener('pointerleave', () => cursor.classList.remove('is-visible', 'is-label'), { signal });
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load.
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const init = (scope) => find(scope).forEach((root) => {
if (root.classList.contains('is-ready')) return; // already running, maybe from another copy of this script
const controller = new AbortController();
instances.set(root, { controller, html: root.innerHTML, attrs: [...root.attributes].map((a) => [a.name, a.value]) });
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
instance.controller.abort();
[...root.attributes].forEach((a) => root.removeAttribute(a.name));
instance.attrs.forEach(([name, value]) => root.setAttribute(name, value));
root.innerHTML = instance.html;
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Custom Cursor, for React. From https://kodza.dev/components/hover-custom-cursor/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: hover-custom-cursor.css and hover-custom-cursor.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './hover-custom-cursor.css';
export type CustomCursorProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-cursor-bg"?: string | number;
/** default: #ffffff */
"--kz-cursor-fg"?: string | number;
/** default: 10px */
"--kz-cursor-dot"?: string | number;
/** default: 4 / 5 */
"--kz-cursor-ratio"?: string | number;
/** default: 4px */
"--kz-cursor-radius"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-cursor-placeholder"?: string | number;
};
className?: string;
};
export function CustomCursor({ vars, className, style, ...rest }: CustomCursorProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-cursor-zone", className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, vars, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./hover-custom-cursor.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
return (
<div key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<a className="kodza-cursor-zone__item" href="#" data-cursor="View">
<img src="https://kodza.dev/media/kz-block-1400.webp" alt="A tall concrete slab standing alone, its long shadow stretching toward the viewer" loading="lazy" />
</a>
{' '}
<a className="kodza-cursor-zone__item" href="#" data-cursor="Open">
<img src="https://kodza.dev/media/kz-columns-1400.webp" alt="A row of square concrete pillars under a flat roof, casting striped shadows" loading="lazy" />
</a>
{' '}
<a className="kodza-cursor-zone__item" href="#" data-cursor="Ship it">
<img src="https://kodza.dev/media/kz-float-1400.webp" alt="A concrete block hovering above the ground, over its own shadow" loading="lazy" />
</a>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.