Image Split Cards
One image laid over a row of cards. Pinned while you scroll, it splits along the gaps, then each piece turns over to show its card, one after the other. Scroll back and the picture comes back together.
View codeA picture that drifts slower than the page inside a frame that stays put. It wraps the image WordPress writes in its own frame, and the picture overhangs by its whole travel, so no gap ever shows.
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 Inner Parallax Image to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<figure class="kodza-inner-parallax-image" data-kodza-inner-parallax-image>
<img src="https://kodza.dev/media/kz-block-1400.webp" alt="A tall concrete slab standing alone, its long shadow stretching toward the viewer" width="1120" height="1400" loading="lazy">
<figcaption class="kodza-inner-parallax-image__caption">Slab 04 — Le Havre, 2026</figcaption>
</figure>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Inner Parallax Image
A picture that drifts slower than the page inside a frame that stays put. The script wraps the
picture (the img WordPress writes) in its own frame and moves it a share of the frame's height,
from the frame entering at the bottom of the screen to it leaving at the top: the picture
overhangs by its whole travel, so no gap ever shows.
Tuned with the --kz-inner-parallax-image-* variables; the smoothing is a constant at the top
of component.js. */
.kodza-inner-parallax-image {
/* How far the picture travels each way, as a share of the frame's height. 0.08 is seen without
getting in the way (0.06 is barely there); a negative value moves it with the scroll */
--kz-inner-parallax-image-drift: 0.08;
/* The frame's corners */
--kz-inner-parallax-image-radius: calc(16px * var(--kz-radius-scale, 1));
/* The caption under the frame */
--kz-inner-parallax-image-caption: color-mix(in srgb, var(--kz-color-ink, #0a0a0a) 62%, transparent);
--kz-inner-parallax-image-caption-gap: 12px;
/* A 1px line inside the frame's edge, so a dark picture keeps its corners on a dark page */
--kz-inner-parallax-image-edge: color-mix(in srgb, var(--kz-color-ink, #0a0a0a) 8%, transparent);
display: block;
margin: 0;
}
/* Laid by the script around the picture: without it, the page shows the picture as it is */
.kodza-inner-parallax-image__frame {
position: relative;
display: block;
overflow: hidden;
border-radius: var(--kz-inner-parallax-image-radius);
/* Its own layer, so the moving picture stays clipped to the round corners (Safari) */
isolation: isolate;
transform: translateZ(0);
}
.kodza-inner-parallax-image__frame::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
box-shadow: inset 0 0 0 1px var(--kz-inner-parallax-image-edge);
pointer-events: none;
}
.kodza-inner-parallax-image__frame img {
display: block;
width: 100%;
max-width: none;
height: auto;
margin: 0;
border-radius: 0;
backface-visibility: hidden;
}
/* Only while it is on screen and moving */
.kodza-inner-parallax-image.is-moving .kodza-inner-parallax-image__frame img {
will-change: transform;
}
.kodza-inner-parallax-image__caption {
margin: var(--kz-inner-parallax-image-caption-gap) 0 0;
font-family: var(--kz-font-label, var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, 400);
line-height: var(--kz-font-label-leading, 1.4);
letter-spacing: var(--kz-font-label-tracking, normal);
text-transform: var(--kz-font-label-case, none);
color: var(--kz-inner-parallax-image-caption);
}
/* kodza-guard 5d54bb21ad5b — 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, on everything the component leaves to it */
.kodza-inner-parallax-image:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
position: 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-inner-parallax-image__frame:not(#_) {
box-sizing: revert;
}
.kodza-inner-parallax-image__caption:not(#_) {
border: 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;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-inner-parallax-image__frame img:where(:not([class*="kodza-"])):not(#_) {
border: revert;
padding: 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-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-inner-parallax-image__caption:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-inner-parallax-image__caption:not(#_):where([hidden]) {
display: none;
}
.kodza-inner-parallax-image__frame:not(#_)::after {
box-sizing: 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-inner-parallax-image:not(#_) {
display: block;
}
.kodza-inner-parallax-image__frame img:not(#_) {
display: block;
max-width: none;
margin: 0;
border-radius: 0;
}
.kodza-inner-parallax-image__caption:not(#_) {
margin: var(--kz-inner-parallax-image-caption-gap) 0 0;
font-family: var(--kz-font-label, var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, 400);
line-height: var(--kz-font-label-leading, 1.4);
letter-spacing: var(--kz-font-label-tracking, normal);
text-transform: var(--kz-font-label-case, none);
color: var(--kz-inner-parallax-image-caption);
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Inner Parallax Image
// Wraps the picture in its own frame, then moves it a share of the frame's height as the frame
// crosses the screen: from the frame entering at the bottom to it leaving at the top. The picture
// is zoomed by its whole travel, so its edges never show. Reduced motion, or a site pace of 0:
// the frame is laid, nothing moves. The drift is --kz-inner-parallax-image-drift (component.css),
// the smoothing just below.
(() => {
const SELECTOR = '[data-kodza-inner-parallax-image]';
// How long the picture takes to catch up with the scroll, in ms (times the site's pace): a soft
// follow instead of a picture glued to the wheel. 0 glues it
const SMOOTHING = 110;
// The picture's own move left below this, in px, ends the loop
const SETTLE = 0.05;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// The site's pace, --kz-motion-scale (set on :root, read on the instance): every duration of the
// script times it, and 0 moves nothing, like reduced motion
const paceOf = (el) => {
const pace = parseFloat(getComputedStyle(el).getPropertyValue('--kz-motion-scale'));
return pace >= 0 ? pace : 1;
};
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
// One instance, with its own state: two copies on a page never interfere. Everything it
// starts is tied to `signal` (listeners get { signal }, observers and timers stop on its
// 'abort' event), so kodza:destroy stops it all.
function mount(root, signal) {
const img = root.querySelector('img');
if (!img) return;
// The frame goes around the picture itself (or its <picture>), inside a link if there is one,
// and leaves the caption out
const target = img.closest('picture') && root.contains(img.closest('picture')) ? img.closest('picture') : img;
const frame = document.createElement('span');
frame.className = 'kodza-inner-parallax-image__frame';
target.replaceWith(frame);
frame.append(target);
const pace = paceOf(root);
const still = reduceMotion || !pace; // the still path: no motion asked, by the visitor or the site
if (still) return;
const drift = parseFloat(getComputedStyle(root).getPropertyValue('--kz-inner-parallax-image-drift')) || 0;
if (!drift) return;
const zoom = 1 + 2 * Math.abs(drift);
const smoothing = SMOOTHING * pace;
let current = null; // the picture's offset now, in px of the frame
let raf = 0;
let last = 0;
let shown = false;
// Where the picture should be: -drift at the bottom of the screen, +drift at the top. Read in px
// of the screen, written in px of the frame (a parent zoomed by transform must not shift it)
const goal = () => {
const rect = frame.getBoundingClientRect();
const height = frame.offsetHeight;
if (!height) return 0;
const progress = clamp((innerHeight - rect.top) / (innerHeight + rect.height), 0, 1);
return (progress - 0.5) * 2 * drift * height;
};
const write = () => {
img.style.transform = `translate3d(0, ${current.toFixed(2)}px, 0) scale(${zoom})`;
};
const tick = (now) => {
raf = 0;
const to = goal();
const step = last ? Math.min(now - last, 64) : 16.7;
last = now;
if (current === null || !smoothing) current = to;
else current += (to - current) * (1 - Math.exp(-step / smoothing));
if (Math.abs(to - current) < SETTLE) current = to;
write();
if (current !== to) raf = requestAnimationFrame(tick);
else last = 0;
};
const queue = () => {
if (!shown || raf) return;
raf = requestAnimationFrame(tick);
};
// Placed before it first shows, so it never slides in from the middle
current = goal();
write();
root.classList.add('is-moving');
const io = new IntersectionObserver(([entry]) => {
shown = entry.isIntersecting;
if (shown) {
current = goal(); // back on screen after a jump (an anchor, Home): placed, not chased
write();
queue();
}
});
io.observe(frame);
// The frame's height decides the travel: a picture that loads late, a new column width
const resize = new ResizeObserver(queue);
resize.observe(frame);
window.addEventListener('scroll', queue, { passive: true, signal });
window.addEventListener('resize', queue, { signal });
signal.addEventListener('abort', () => {
io.disconnect();
resize.disconnect();
cancelAnimationFrame(raf);
});
}
// 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. Each running root
// keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
// component inside another one is saved as written, not half-started, and stopped with it. Two
// components on one root (a reveal on a stat card) both start, and stop together.
const KODZA = Symbol.for('kodza');
const RUNNING = Symbol.for(`kodza ${SELECTOR}`); // a root this component runs on, for every copy of this script
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
const put = (el, saved) => {
[...el.attributes].forEach((a) => el.removeAttribute(a.name));
saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
el.innerHTML = saved.html;
};
// The markup inside a root as written: Kodza components already running in it give back their own
const written = (root) => {
const all = [...root.querySelectorAll('*')];
if (!all.some((el) => el[KODZA])) return root.innerHTML;
const copy = root.cloneNode(true);
const twins = copy.querySelectorAll('*');
let last = null;
all.forEach((el, i) => {
if (!el[KODZA] || last?.contains(el)) return;
last = el;
put(twins[i], el[KODZA]);
});
return copy.innerHTML;
};
const init = (scope) => find(scope).forEach((root) => {
// Already running, maybe from another copy of this script. Marked is-ready with no Kodza component
// running on it, a root is a copy of a started one (a cloned slide): it is left as it is
if (root[RUNNING] || (root.classList.contains('is-ready') && !root[KODZA])) return;
// Rendered by React (data-kodza-manual): React starts it, with an init aimed at that markup once
// it has hydrated it. Any earlier start would change markup React has not taken over yet
const owner = root.closest('[data-kodza-manual]');
if (owner && !(scope && owner.contains(scope))) return;
const controller = new AbortController();
// Another Kodza component already running on this root: its markup as written is kept, and the
// two stop together
const before = root[KODZA];
const saved = { html: before ? before.html : written(root), attrs: before ? before.attrs : attrsOf(root), stop: () => destroy(root) };
if (before) before.above = saved;
instances.set(root, { controller, saved, before });
root[KODZA] = saved;
root[RUNNING] = true;
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.saved.above?.stop(); // a Kodza component started after it on the same root stops first
// The Kodza components running inside it stop first: once its markup is put back, their own
// script could not find them any more
root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
instance.controller.abort();
delete root[RUNNING];
// Started on top of another one: that one stops next, and puts the markup back
if (instance.before) {
delete instance.before.above;
root[KODZA] = instance.before;
return instance.before.stop();
}
delete root[KODZA];
put(root, instance.saved);
});
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 — Inner Parallax Image, for React. From https://kodza.dev/components/inner-parallax-image/
// 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: inner-parallax-image.css and inner-parallax-image.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.
// data-kodza-manual keeps the script from starting it on its own: the effect below does, once
// React has hydrated the markup (on a page that streams in, the script can load first).
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './inner-parallax-image.css';
export type InnerParallaxImageProps = Omit<ComponentPropsWithoutRef<'figure'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: 0.08 */
"--kz-inner-parallax-image-drift"?: string | number;
/** default: calc(16px * var(--kz-radius-scale, 1)) */
"--kz-inner-parallax-image-radius"?: string | number;
/** default: color-mix(in srgb, var(--kz-color-ink, #0a0a0a) 62%, transparent) */
"--kz-inner-parallax-image-caption"?: string | number;
/** default: 12px */
"--kz-inner-parallax-image-caption-gap"?: string | number;
/** default: color-mix(in srgb, var(--kz-color-ink, #0a0a0a) 8%, transparent) */
"--kz-inner-parallax-image-edge"?: string | number;
};
/** The text, plain: the script reads it and rebuilds the markup around it */
children?: string;
className?: string;
};
export function InnerParallaxImage({ vars, children = "Slab 04 — Le Havre, 2026", className, style, ...rest }: InnerParallaxImageProps) {
const ref = useRef<ComponentRef<'figure'>>(null);
const classes = ["kodza-inner-parallax-image", 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, children, 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('./inner-parallax-image.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 data-kodza-inner-parallax-image="" key={key} {...rest} className={classes} data-kodza-manual style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<img src="https://kodza.dev/media/kz-block-1400.webp" alt="A tall concrete slab standing alone, its long shadow stretching toward the viewer" width="1120" height="1400" loading="lazy" />
<figcaption className="kodza-inner-parallax-image__caption">{children}</figcaption>
</figure>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.