Carousel
A native scroll-snap carousel that works before the script loads. Then: page buttons, a rolling count, a progress bar or dots, and mouse drag with a throw. Never steals the wheel.
View codeA closer look under the pointer: a round lens follows with a little lag and shows the detail from a sharper file, or the picture itself zooms towards the pointer.
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 Zoom Lens to my product photos.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<figure class="kodza-zoom" data-zoom="2.5">
<img src="https://kodza.dev/media/kz-steps-1400.webp" data-zoom-src="https://kodza.dev/media/kz-steps-2400.webp" alt="Concrete steps climbing towards the light" width="1400" height="933" />
</figure>
<figure class="kodza-zoom kodza-zoom--inside" data-zoom="2.2">
<img src="https://kodza.dev/media/kz-float-1400.webp" data-zoom-src="https://kodza.dev/media/kz-float-2400.webp" alt="Concrete slabs floating one above the other" width="1400" height="933" />
</figure>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Zoom Lens
A closer look under the pointer: a round lens follows it with a little lag and shows the
detail, taken from a sharper file if you give one (data-zoom-src). --inside zooms the
picture itself towards the pointer, inside its frame. On a touch screen the lens shows
while the finger is down, above the finger. data-zoom sets the strength (2.5 by default).
Everything is tuned with the --kz-zoom-* variables. */
.kodza-zoom {
--kz-zoom-lens: 180px;
--kz-zoom-ring: #ffffff;
--kz-zoom-radius: 14px;
--kz-zoom-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
align-self: start;
margin: 0;
overflow: hidden;
border-radius: var(--kz-zoom-radius);
cursor: zoom-in;
touch-action: pan-y;
}
.kodza-zoom img {
display: block;
width: 100%;
height: auto;
user-select: none;
-webkit-user-drag: none;
}
/* The lens: placed by the script, it grows from the pointer when it appears */
.kodza-zoom__lens {
position: absolute;
top: 0;
left: 0;
width: var(--kz-zoom-lens);
height: var(--kz-zoom-lens);
border-radius: 50%;
background-repeat: no-repeat;
box-shadow: 0 0 0 3px var(--kz-zoom-ring), 0 18px 40px -12px rgba(0, 0, 0, 0.5);
pointer-events: none;
opacity: 0;
transform: translate(var(--kz-zoom-x, 0px), var(--kz-zoom-y, 0px)) scale(0.4);
transition: opacity 0.25s ease, transform 0.45s var(--kz-zoom-ease);
}
.kodza-zoom.is-zooming .kodza-zoom__lens {
opacity: 1;
transform: translate(var(--kz-zoom-x, 0px), var(--kz-zoom-y, 0px));
transition: opacity 0.2s ease, transform 0.5s var(--kz-zoom-ease);
}
.kodza-zoom.is-moving .kodza-zoom__lens {
transition: opacity 0.2s ease;
}
.kodza-zoom:has(.kodza-zoom__lens) {
cursor: none;
}
/* Inside: the picture zooms towards the pointer */
.kodza-zoom--inside img {
transform-origin: var(--kz-zoom-ox, 50%) var(--kz-zoom-oy, 50%);
transition: transform 0.7s var(--kz-zoom-ease);
}
.kodza-zoom--inside.is-zooming img {
transform: scale(var(--kz-zoom-scale, 2));
}
.kodza-zoom--inside:has(img) {
cursor: zoom-in;
}
@media (prefers-reduced-motion: reduce) {
.kodza-zoom__lens,
.kodza-zoom--inside img {
transition-duration: 0.01ms !important;
}
}
/* kodza-guard 668d57fc2286 — 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-zoom img:where(:not([class*="kodza-"]))):not(#_) {
transition: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-zoom:not(#_) {
border: revert;
padding: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: 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-zoom__lens:not(#_) {
box-sizing: revert;
}
.kodza-zoom img:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: 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;
}
.kodza-zoom:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-zoom:not(#_):where([hidden]) {
display: none;
}
/* 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-zoom:not(#_) {
position: relative;
border-radius: var(--kz-zoom-radius);
}
.kodza-zoom img:not(#_) {
display: block;
}
.kodza-zoom--inside img:not(#_) {
transition: transform 0.7s var(--kz-zoom-ease);
}
@media (prefers-reduced-motion: reduce) {
.kodza-zoom--inside img:not(#_) {
transition-duration: 0.01ms !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Zoom Lens
// Follows the pointer with a little lag (or the finger while it is down, above it) and
// shows the detail at data-zoom, from data-zoom-src when given (loaded on first use).
// --inside moves the picture's zoom origin instead. Measured in the frame's own pixels.
(() => {
const SELECTOR = '.kodza-zoom';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const img = root.querySelector('img');
if (!img) return;
const zoom = parseFloat(root.dataset.zoom) || 2.5;
const inside = root.classList.contains('kodza-zoom--inside');
let lens = null;
let x = 0;
let y = 0;
let tx = 0;
let ty = 0;
let frame = 0;
let lift = 0;
const sharp = () => {
const src = img.dataset.zoomSrc;
if (!src || img.dataset.zoomLoaded) return;
img.dataset.zoomLoaded = 'true';
if (inside) {
const hi = new Image();
hi.onload = () => (img.src = src);
hi.src = src;
} else lens.style.backgroundImage = `url("${src}")`;
};
if (inside) root.style.setProperty('--kz-zoom-scale', zoom);
else {
lens = Object.assign(document.createElement('span'), { className: 'kodza-zoom__lens' });
lens.setAttribute('aria-hidden', 'true');
lens.style.backgroundImage = `url("${img.currentSrc || img.src}")`;
root.append(lens);
}
const paint = () => {
// The picture, not the frame around it, sets the scale of the detail
const w = img.clientWidth;
const h = img.clientHeight;
if (inside) {
root.style.setProperty('--kz-zoom-ox', `${(x / w) * 100}%`);
root.style.setProperty('--kz-zoom-oy', `${(y / h) * 100}%`);
return;
}
const size = lens.offsetWidth;
// The lens shows what is under its centre, magnified
lens.style.backgroundSize = `${w * zoom}px ${h * zoom}px`;
lens.style.backgroundPosition = `${-(x * zoom - size / 2)}px ${-(y * zoom - size / 2)}px`;
root.style.setProperty('--kz-zoom-x', `${x - size / 2}px`);
root.style.setProperty('--kz-zoom-y', `${y - size / 2 - lift}px`);
};
const glide = () => {
if (signal.aborted) return;
const k = reduceMotion ? 1 : 0.22;
x += (tx - x) * k;
y += (ty - y) * k;
paint();
frame = Math.abs(tx - x) + Math.abs(ty - y) > 0.3 ? requestAnimationFrame(glide) : 0;
};
const aim = (event, first) => {
const box = root.getBoundingClientRect();
const k = box.width / root.offsetWidth || 1;
tx = Math.min(img.clientWidth, Math.max(0, (event.clientX - box.left) / k));
ty = Math.min(img.clientHeight, Math.max(0, (event.clientY - box.top) / k));
if (first) {
x = tx;
y = ty;
paint();
}
if (!frame) frame = requestAnimationFrame(glide);
};
root.addEventListener('pointerenter', (event) => {
if (event.pointerType !== 'mouse') return;
lift = 0;
sharp();
aim(event, true);
root.classList.add('is-zooming');
}, { signal });
root.addEventListener('pointerdown', (event) => {
if (event.pointerType === 'mouse') return;
// A finger would hide the lens: it shows a little above it
lift = inside ? 0 : 90;
sharp();
aim(event, true);
root.classList.add('is-zooming');
}, { signal });
root.addEventListener('pointermove', (event) => {
if (!root.classList.contains('is-zooming')) return;
root.classList.add('is-moving');
aim(event);
}, { signal });
const leave = () => root.classList.remove('is-zooming', 'is-moving');
root.addEventListener('pointerleave', leave, { signal });
root.addEventListener('pointerup', (event) => event.pointerType !== 'mouse' && leave(), { signal });
root.addEventListener('pointercancel', leave, { 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 — Zoom Lens, for React. From https://kodza.dev/components/zoom-lens/
// 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: zoom-lens.css and zoom-lens.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 './zoom-lens.css';
type Variant = "inside";
export type ZoomLensProps = Omit<ComponentPropsWithoutRef<'figure'>, "children" | "variant" | "vars" | "zoom"> & {
/** Modifier class(es): .kodza-zoom--inside */
variant?: Variant | Variant[];
/** data-zoom on the root (default: 2.5) */
zoom?: "2.5" | "2.2";
/** Settings, as CSS variables on the root */
vars?: {
/** default: 180px */
"--kz-zoom-lens"?: string | number;
/** default: #ffffff */
"--kz-zoom-ring"?: string | number;
/** default: 14px */
"--kz-zoom-radius"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-zoom-ease"?: string | number;
};
className?: string;
};
export function ZoomLens({ variant, zoom = "2.5", vars, className, style, ...rest }: ZoomLensProps) {
const ref = useRef<ComponentRef<'figure'>>(null);
const classes = ["kodza-zoom", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-zoom--${v}`), 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, zoom, 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('./zoom-lens.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 (
<figure key={key} {...rest} className={classes} data-zoom={zoom} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<img src="https://kodza.dev/media/kz-steps-1400.webp" data-zoom-src="https://kodza.dev/media/kz-steps-2400.webp" alt="Concrete steps climbing towards the light" width="1400" height="933" />
</figure>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.