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 codeA picture that unveils itself when it comes into view: a curtain opens from below, from the side or like an iris while the picture settles from a slight zoom.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add Kodza Image Reveal to the pictures of my case study.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<figure class="kodza-image-reveal">
<div class="kodza-image-reveal__frame"><img src="https://kodza.dev/media/kz-steps-1400.webp" alt="Concrete steps climbing towards the light" width="1400" height="933" /></div>
<figcaption class="kodza-image-reveal__caption">Steps — they lead somewhere, eventually.</figcaption>
</figure>
<figure class="kodza-image-reveal" data-from="left">
<div class="kodza-image-reveal__frame"><img src="https://kodza.dev/media/kz-columns-1400.webp" alt="A row of concrete columns casting long shadows" width="1120" height="1400" /></div>
<figcaption class="kodza-image-reveal__caption">Columns — seven of them, no meetings.</figcaption>
</figure>
<figure class="kodza-image-reveal" data-from="center">
<div class="kodza-image-reveal__frame"><img src="https://kodza.dev/media/kz-arch-1400.webp" alt="A concrete wall with a round-topped opening" width="1400" height="1400" /></div>
<figcaption class="kodza-image-reveal__caption">Arch — the only door in the building.</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 — Image Reveal
A picture that unveils itself when it comes into view: a curtain opens (from below by
default, data-from="left", or "center" like an iris) while the picture settles from a
slight zoom, then its caption rises. Without the script it is simply there.
"kodza:replay" plays it again. Everything is tuned with the --kz-image-reveal-* variables. */
.kodza-image-reveal {
--kz-image-reveal-duration: 1.1s;
--kz-image-reveal-ease: cubic-bezier(0.76, 0, 0.24, 1);
--kz-image-reveal-settle: cubic-bezier(0.22, 1, 0.36, 1);
--kz-image-reveal-radius: 12px;
--kz-image-reveal-muted: rgba(10, 10, 10, 0.62);
display: grid;
gap: 12px;
margin: 0;
}
.kodza-image-reveal__frame {
aspect-ratio: 4 / 5;
overflow: hidden;
border-radius: var(--kz-image-reveal-radius);
clip-path: inset(0 round var(--kz-image-reveal-radius));
transition: clip-path var(--kz-image-reveal-duration) var(--kz-image-reveal-ease);
}
.kodza-image-reveal__frame img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform calc(var(--kz-image-reveal-duration) * 1.5) var(--kz-image-reveal-settle);
}
.kodza-image-reveal__caption {
overflow: hidden;
color: var(--kz-image-reveal-muted);
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
transition: opacity 0.6s ease calc(var(--kz-image-reveal-duration) * 0.6), transform 0.8s var(--kz-image-reveal-settle) calc(var(--kz-image-reveal-duration) * 0.6);
}
/* Waiting to be seen (only once the script is there) */
.kodza-image-reveal.is-ready:not(.is-shown) .kodza-image-reveal__frame {
clip-path: inset(100% 0 0 0 round var(--kz-image-reveal-radius));
transition: none;
}
.kodza-image-reveal.is-ready[data-from="left"]:not(.is-shown) .kodza-image-reveal__frame {
clip-path: inset(0 100% 0 0 round var(--kz-image-reveal-radius));
}
.kodza-image-reveal.is-ready[data-from="center"]:not(.is-shown) .kodza-image-reveal__frame {
clip-path: inset(46% 40% round 999px);
}
.kodza-image-reveal.is-ready:not(.is-shown) .kodza-image-reveal__frame img {
transform: scale(1.3);
transition: none;
}
.kodza-image-reveal.is-ready:not(.is-shown) .kodza-image-reveal__caption {
opacity: 0;
transform: translateY(10px);
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.kodza-image-reveal__frame,
.kodza-image-reveal__frame img,
.kodza-image-reveal__caption {
transition: none !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-image-reveal {
--kz-image-reveal-muted: rgba(242, 242, 240, 0.62);
}
*/
/* kodza-guard a3144330718e — 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-image-reveal: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-image-reveal__frame:not(#_) {
box-sizing: revert;
}
.kodza-image-reveal__caption: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;
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-image-reveal__frame 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-image-reveal__caption:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-image-reveal__caption: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-image-reveal:not(#_) {
display: grid;
}
.kodza-image-reveal__frame img:not(#_) {
display: block;
transition: transform calc(var(--kz-image-reveal-duration) * 1.5) var(--kz-image-reveal-settle);
}
.kodza-image-reveal__caption:not(#_) {
color: var(--kz-image-reveal-muted);
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
transition: opacity 0.6s ease calc(var(--kz-image-reveal-duration) * 0.6), transform 0.8s var(--kz-image-reveal-settle) calc(var(--kz-image-reveal-duration) * 0.6);
}
.kodza-image-reveal.is-ready:not(.is-shown) .kodza-image-reveal__frame img:not(#_) {
transition: none;
}
.kodza-image-reveal.is-ready:not(.is-shown) .kodza-image-reveal__caption:not(#_) {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.kodza-image-reveal__frame img:not(#_),
.kodza-image-reveal__caption:not(#_) {
transition: none !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Image Reveal
// Unveils each picture the first time a third of it is in view (and again on
// "kodza:replay"), once its image has loaded, so the curtain never opens on an empty frame.
(() => {
const SELECTOR = '.kodza-image-reveal';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const loaded = (root, signal) => {
const img = root.querySelector('img');
return !img || img.complete ? Promise.resolve() : new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true, signal });
img.addEventListener('error', resolve, { once: true, signal });
});
};
const show = (root, signal) => loaded(root, signal).then(() => requestAnimationFrame(() => {
if (signal.aborted) return;
root.classList.add('is-shown');
}));
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
root.addEventListener('kodza:replay', () => {
root.classList.remove('is-shown');
void root.offsetWidth;
show(root, signal);
}, { signal });
if (reduceMotion || !('IntersectionObserver' in window)) return root.classList.add('is-shown');
const io = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
show(root, signal);
}, { threshold: 0.33 });
io.observe(root);
signal.addEventListener('abort', () => io.disconnect());
}
// 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 — Image Reveal, for React. From https://kodza.dev/components/image-reveal/
// 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: image-reveal.css and image-reveal.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 './image-reveal.css';
export type ImageRevealProps = Omit<ComponentPropsWithoutRef<'figure'>, "children" | "vars" | "from"> & {
/** data-from on the root */
from?: "left" | "center";
/** Settings, as CSS variables on the root */
vars?: {
/** default: 1.1s */
"--kz-image-reveal-duration"?: string | number;
/** default: cubic-bezier(0.76, 0, 0.24, 1) */
"--kz-image-reveal-ease"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-image-reveal-settle"?: string | number;
/** default: 12px */
"--kz-image-reveal-radius"?: string | number;
/** default: rgba(10, 10, 10, 0.62) */
"--kz-image-reveal-muted"?: string | number;
};
/** The text, plain: the script reads it and rebuilds the markup around it */
children?: string;
className?: string;
};
export function ImageReveal({ from, vars, children = "Steps — they lead somewhere, eventually.", className, style, ...rest }: ImageRevealProps) {
const ref = useRef<ComponentRef<'figure'>>(null);
const classes = ["kodza-image-reveal", 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, from, 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('./image-reveal.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-from={from} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<div className="kodza-image-reveal__frame">
<img src="https://kodza.dev/media/kz-steps-1400.webp" alt="Concrete steps climbing towards the light" width="1400" height="933" />
</div>
<figcaption className="kodza-image-reveal__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.