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 codeAn editorial image grid: each image grows into a fullscreen viewer in one continuous motion, with curtain transitions between images and keyboard and swipe support.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Turn my photos into a Kodza Image Gallery.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-gallery" data-kodza-gallery>
<figure class="kodza-gallery__item" style="--start: 1; --span: 7; --ratio: 3 / 2">
<button class="kodza-gallery__open" type="button" aria-label="Open image: transition: steps(9)">
<img class="kodza-gallery__img" src="https://kodza.dev/media/kz-steps-1400.webp" data-full="https://kodza.dev/media/kz-steps-2400.webp" alt="A concrete staircase that climbs nine steps and stops in mid-air" loading="lazy" />
</button>
<figcaption class="kodza-gallery__caption">
<span class="kodza-gallery__index">01</span>
<span class="kodza-gallery__title">transition: steps(9)</span>
<span class="kodza-gallery__meta">Leads nowhere</span>
</figcaption>
</figure>
<figure class="kodza-gallery__item" style="--start: 9; --span: 4; --ratio: 4 / 5; --shift: clamp(40px, 8vw, 120px)">
<button class="kodza-gallery__open" type="button" aria-label="Open image: display: block">
<img class="kodza-gallery__img" src="https://kodza.dev/media/kz-block-1400.webp" data-full="https://kodza.dev/media/kz-block-2400.webp" alt="A tall concrete slab standing alone, its long shadow stretching toward the viewer" loading="lazy" />
</button>
<figcaption class="kodza-gallery__caption">
<span class="kodza-gallery__index">02</span>
<span class="kodza-gallery__title">display: block</span>
<span class="kodza-gallery__meta">Zero dependencies</span>
</figcaption>
</figure>
<figure class="kodza-gallery__item" style="--start: 2; --span: 4; --ratio: 1 / 1">
<button class="kodza-gallery__open" type="button" aria-label="Open image: border-radius: 50%">
<img class="kodza-gallery__img" src="https://kodza.dev/media/kz-arch-1400.webp" data-full="https://kodza.dev/media/kz-arch-2400.webp" alt="A concrete wall with a round-topped opening, sunlight pouring through it onto the ground" loading="lazy" />
</button>
<figcaption class="kodza-gallery__caption">
<span class="kodza-gallery__index">03</span>
<span class="kodza-gallery__title">border-radius: 50%</span>
<span class="kodza-gallery__meta">Concrete, but soft</span>
</figcaption>
</figure>
<figure class="kodza-gallery__item" style="--start: 7; --span: 5; --ratio: 4 / 3; --shift: clamp(30px, 6vw, 90px)">
<button class="kodza-gallery__open" type="button" aria-label="Open image: z-index: 9999">
<img class="kodza-gallery__img" src="https://kodza.dev/media/kz-stack-1400.webp" data-full="https://kodza.dev/media/kz-stack-2400.webp" alt="Three concrete slabs stacked at slightly different angles" loading="lazy" />
</button>
<figcaption class="kodza-gallery__caption">
<span class="kodza-gallery__index">04</span>
<span class="kodza-gallery__title">z-index: 9999</span>
<span class="kodza-gallery__meta">Works on my machine</span>
</figcaption>
</figure>
<figure class="kodza-gallery__item" style="--start: 1; --span: 5; --ratio: 4 / 5">
<button class="kodza-gallery__open" type="button" aria-label="Open image: repeat(7, 1fr)">
<img class="kodza-gallery__img" src="https://kodza.dev/media/kz-columns-1400.webp" data-full="https://kodza.dev/media/kz-columns-2400.webp" alt="A row of square concrete pillars under a flat roof, casting striped shadows" loading="lazy" />
</button>
<figcaption class="kodza-gallery__caption">
<span class="kodza-gallery__index">05</span>
<span class="kodza-gallery__title">repeat(7, 1fr)</span>
<span class="kodza-gallery__meta">Grid, 1968 edition</span>
</figcaption>
</figure>
<figure class="kodza-gallery__item" style="--start: 7; --span: 6; --ratio: 3 / 2; --shift: clamp(40px, 10vw, 150px)">
<button class="kodza-gallery__open" type="button" aria-label="Open image: position: absolute">
<img class="kodza-gallery__img" src="https://kodza.dev/media/kz-float-1400.webp" data-full="https://kodza.dev/media/kz-float-2400.webp" alt="A concrete block hovering above the ground, over its own shadow" loading="lazy" />
</button>
<figcaption class="kodza-gallery__caption">
<span class="kodza-gallery__index">06</span>
<span class="kodza-gallery__title">position: absolute</span>
<span class="kodza-gallery__meta">Out of the flow</span>
</figcaption>
</figure>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Image Gallery
An editorial grid. Clicking an image grows it into a fullscreen viewer
in one continuous motion, and closing sends it back to its exact spot.
Everything is tuned with the --kz-gal-* variables. */
.kodza-gallery {
--kz-gal-gap: clamp(14px, 2vw, 28px);
--kz-gal-radius: 4px;
--kz-gal-ink: #0a0a0a;
--kz-gal-muted: rgba(10, 10, 10, 0.6);
--kz-gal-placeholder: rgba(10, 10, 10, 0.06);
--kz-gal-viewer-bg: #0a0a0a;
--kz-gal-viewer-fg: #ffffff;
--kz-gal-duration: 900ms;
--kz-gal-ease: cubic-bezier(0.76, 0, 0.24, 1);
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
align-items: start;
gap: calc(var(--kz-gal-gap) * 2) var(--kz-gal-gap);
color: var(--kz-gal-ink);
}
.kodza-gallery__item {
grid-column: var(--start, auto) / span var(--span, 4);
margin: var(--shift, 0) 0 0;
}
.kodza-gallery__open {
display: block;
width: 100%;
padding: 0;
border: 0;
border-radius: var(--kz-gal-radius);
background: var(--kz-gal-placeholder);
aspect-ratio: var(--ratio, 4 / 5);
overflow: hidden;
cursor: zoom-in;
}
.kodza-gallery__open:focus-visible {
outline: 2px solid var(--kz-gal-ink);
outline-offset: 4px;
}
.kodza-gallery__img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.001);
transition: transform 1.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.kodza-gallery__open:hover .kodza-gallery__img {
transform: scale(1.045);
}
.kodza-gallery__caption {
display: flex;
align-items: baseline;
gap: 12px;
margin-top: 12px;
font-size: 14px;
line-height: 1.3;
}
.kodza-gallery__index {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11px;
color: var(--kz-gal-muted);
}
.kodza-gallery__title {
font-weight: 500;
letter-spacing: -0.01em;
}
.kodza-gallery__meta {
margin-left: auto;
color: var(--kz-gal-muted);
}
/* ---------- Viewer (created by the script, shown in the top layer) ---------- */
.kodza-gallery__viewer {
position: fixed;
inset: 0;
width: 100%;
max-width: none;
height: 100%;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--kz-gal-viewer-fg);
overflow: hidden;
}
.kodza-gallery__viewer::backdrop {
background: transparent;
}
.kodza-gallery__backdrop {
position: absolute;
inset: 0;
background: var(--kz-gal-viewer-bg);
}
.kodza-gallery__frame {
position: absolute;
overflow: hidden;
border-radius: var(--kz-gal-radius);
will-change: left, top, width, height, clip-path;
}
.kodza-gallery__frame img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.kodza-gallery__bar,
.kodza-gallery__info,
.kodza-gallery__nav {
position: absolute;
z-index: 2;
display: flex;
align-items: center;
}
.kodza-gallery__bar {
top: 0;
right: 0;
left: 0;
justify-content: space-between;
padding: 20px max(20px, 4vw);
}
.kodza-gallery__count {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
}
.kodza-gallery__count b {
font-weight: 500;
color: var(--kz-gal-viewer-fg);
}
.kodza-gallery__close,
.kodza-gallery__nav button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
color: inherit;
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.25s ease;
}
.kodza-gallery__close {
height: 38px;
padding: 0 14px 0 16px;
}
.kodza-gallery__close:hover,
.kodza-gallery__nav button:hover {
background: rgba(255, 255, 255, 0.16);
}
.kodza-gallery__close:focus-visible,
.kodza-gallery__nav button:focus-visible {
outline: 2px solid var(--kz-gal-viewer-fg);
outline-offset: 3px;
}
.kodza-gallery__close svg,
.kodza-gallery__nav svg {
width: 14px;
height: 14px;
}
.kodza-gallery__info {
bottom: 0;
left: 0;
display: block;
padding: 0 max(20px, 4vw) 28px;
max-width: 60%;
}
/* Each line rises from behind a mask */
.kodza-gallery__line {
display: block;
overflow: hidden;
}
.kodza-gallery__line > span {
display: block;
}
.kodza-gallery__info-title {
margin: 0;
font-size: clamp(22px, 2.6vw, 34px);
font-weight: 600;
line-height: 1.15;
letter-spacing: -0.03em;
}
.kodza-gallery__info-meta {
margin: 6px 0 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.55);
}
.kodza-gallery__nav {
right: 0;
bottom: 0;
gap: 8px;
padding: 0 max(20px, 4vw) 28px;
}
.kodza-gallery__nav button {
width: 46px;
height: 46px;
}
@media (max-width: 720px) {
.kodza-gallery {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.kodza-gallery__item {
grid-column: span 1;
margin-top: 0;
}
.kodza-gallery__item:nth-child(3n + 1) {
grid-column: span 2;
}
.kodza-gallery__info {
max-width: none;
padding-bottom: 92px;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-gallery__img {
transition: none;
}
}
/* kodza-guard 2d2bfc1d3026 — 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-gallery__open):not(#_),
:where(.kodza-gallery__close):not(#_),
:where(.kodza-gallery__nav button:where(:not([class*="kodza-"]))):not(#_) {
outline: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-gallery:not(#_),
.kodza-gallery__index:not(#_),
.kodza-gallery__title:not(#_),
.kodza-gallery__meta:not(#_),
.kodza-gallery__backdrop:not(#_),
.kodza-gallery__stage:not(#_),
.kodza-gallery__bar:not(#_),
.kodza-gallery__count:not(#_),
.kodza-gallery__info:not(#_),
.kodza-gallery__nav:not(#_),
.kodza-gallery__info-title span:where(:not([class*="kodza-"])):not(#_),
.kodza-gallery__info-meta span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-gallery__item: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;
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-gallery__open:not(#_) {
margin: 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;
white-space: revert;
min-height: revert;
}
.kodza-gallery__img: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-gallery__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;
margin-right: revert;
margin-bottom: revert;
margin-left: 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-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-gallery__viewer:not(#_) {
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
outline-offset: revert;
appearance: 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-gallery__close:not(#_) {
margin: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-gallery__info-title: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;
color: revert;
font-family: revert;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-gallery__info-meta: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-family: 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-gallery__count b: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;
font-family: revert;
font-size: 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-gallery__close svg:where(:not([class*="kodza-"])):not(#_),
.kodza-gallery__nav svg: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;
}
.kodza-gallery__nav button:where(:not([class*="kodza-"])):not(#_) {
margin: revert;
padding: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-gallery__item:not(#_):where(:not([hidden])),
.kodza-gallery__viewer:not(#_):where(:not([hidden])),
.kodza-gallery__count b:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])),
.kodza-gallery__close svg:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])),
.kodza-gallery__nav svg:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-gallery__item:not(#_):where([hidden]),
.kodza-gallery__viewer:not(#_):where([hidden]),
.kodza-gallery__count b:where(:not([class*="kodza-"])):not(#_):where([hidden]),
.kodza-gallery__close svg:where(:not([class*="kodza-"])):not(#_):where([hidden]),
.kodza-gallery__nav svg:where(:not([class*="kodza-"])):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-gallery__item:not(#_) {
margin: var(--shift, 0) 0 0;
}
.kodza-gallery__open:not(#_) {
display: block;
width: 100%;
padding: 0;
border: 0;
border-radius: var(--kz-gal-radius);
background: var(--kz-gal-placeholder);
aspect-ratio: var(--ratio, 4 / 5);
overflow: hidden;
cursor: zoom-in;
}
.kodza-gallery__open:focus-visible:not(#_) {
outline: 2px solid var(--kz-gal-ink);
outline-offset: 4px;
}
.kodza-gallery__img:not(#_) {
display: block;
transition: transform 1.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.kodza-gallery__caption:not(#_) {
display: flex;
margin-top: 12px;
font-size: 14px;
line-height: 1.3;
}
.kodza-gallery__viewer:not(#_) {
position: fixed;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--kz-gal-viewer-fg);
}
.kodza-gallery__frame img:not(#_) {
display: block;
}
.kodza-gallery__count b:not(#_) {
font-weight: 500;
color: var(--kz-gal-viewer-fg);
}
.kodza-gallery__close:not(#_),
.kodza-gallery__nav button:not(#_) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
color: inherit;
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.25s ease;
}
.kodza-gallery__close:not(#_) {
height: 38px;
padding: 0 14px 0 16px;
}
.kodza-gallery__close:hover:not(#_),
.kodza-gallery__nav button:hover:not(#_) {
background: rgba(255, 255, 255, 0.16);
}
.kodza-gallery__close:focus-visible:not(#_),
.kodza-gallery__nav button:focus-visible:not(#_) {
outline: 2px solid var(--kz-gal-viewer-fg);
outline-offset: 3px;
}
.kodza-gallery__line:not(#_) {
display: block;
}
.kodza-gallery__info-title:not(#_) {
margin: 0;
font-size: clamp(22px, 2.6vw, 34px);
font-weight: 600;
line-height: 1.15;
letter-spacing: -0.03em;
}
.kodza-gallery__info-meta:not(#_) {
margin: 6px 0 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.55);
}
.kodza-gallery__nav button:not(#_) {
width: 46px;
height: 46px;
}
@media (max-width: 720px) {
.kodza-gallery__item:not(#_) {
margin-top: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-gallery__img:not(#_) {
transition: none;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Image Gallery
// Each [data-kodza-gallery] gets its own viewer. Open: the thumbnail grows into place.
// Navigate: a curtain wipe. Close: the image flies back to its thumbnail.
(() => {
const SELECTOR = '[data-kodza-gallery]';
// The page behind stays put while it is open: a wheel or a swipe only scrolls what can
// scroll inside, under the pointer. Listening only while open costs nothing the rest of the time.
const holdPage = (layer, signal) => {
const inside = (target, x, y, dy) => {
for (let el = target; el instanceof Element && layer.contains(el); el = el.parentElement) {
const box = el.getBoundingClientRect();
if (x < box.left || x > box.right || y < box.top || y > box.bottom) continue;
if (!/(auto|scroll)/.test(getComputedStyle(el).overflowY) || el.scrollHeight <= el.clientHeight + 1) continue;
if (dy > 0 ? el.scrollTop + el.clientHeight < el.scrollHeight - 1 : el.scrollTop > 0) return true;
}
return false;
};
let last = 0;
const wheel = (event) => !inside(event.target, event.clientX, event.clientY, event.deltaY) && event.preventDefault();
const touchstart = (event) => (last = event.touches[0].clientY);
const touchmove = (event) => {
const touch = event.touches[0];
const dy = last - touch.clientY;
last = touch.clientY;
if (!inside(event.target, touch.clientX, touch.clientY, dy)) event.preventDefault();
};
const hold = (on) => {
const method = on ? 'addEventListener' : 'removeEventListener';
document[method]('wheel', wheel, { passive: false });
document[method]('touchstart', touchstart, { passive: true });
document[method]('touchmove', touchmove, { passive: false });
};
const observer = new MutationObserver(() => hold(layer.open));
observer.observe(layer, { attributes: true, attributeFilter: ['open'] });
// Stopped while open: the page is let go
signal.addEventListener('abort', () => {
observer.disconnect();
hold(false);
});
};
const ICONS = {
close: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>',
prev: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10 3L5 8l5 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
next: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 3l5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
};
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const pad = (n) => String(n).padStart(2, '0');
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const items = [...root.querySelectorAll('.kodza-gallery__item')].map((figure) => {
const img = figure.querySelector('.kodza-gallery__img');
return {
button: figure.querySelector('.kodza-gallery__open'),
img,
full: img.dataset.full || img.currentSrc || img.src,
title: figure.querySelector('.kodza-gallery__title')?.textContent.trim() || img.alt,
meta: figure.querySelector('.kodza-gallery__meta')?.textContent.trim() || '',
};
});
if (!items.length) return;
// ---------- Viewer ----------
const viewer = document.createElement('dialog');
viewer.className = 'kodza-gallery__viewer';
holdPage(viewer, signal);
viewer.setAttribute('aria-label', 'Image viewer');
viewer.innerHTML = `
<div class="kodza-gallery__backdrop"></div>
<div class="kodza-gallery__stage"></div>
<div class="kodza-gallery__bar">
<span class="kodza-gallery__count"><b>01</b> / ${pad(items.length)}</span>
<button class="kodza-gallery__close" type="button">Close ${ICONS.close}</button>
</div>
<div class="kodza-gallery__info" aria-live="polite">
<p class="kodza-gallery__info-title kodza-gallery__line"><span></span></p>
<p class="kodza-gallery__info-meta kodza-gallery__line"><span></span></p>
</div>
<div class="kodza-gallery__nav">
<button type="button" data-dir="-1" aria-label="Previous image">${ICONS.prev}</button>
<button type="button" data-dir="1" aria-label="Next image">${ICONS.next}</button>
</div>`;
root.append(viewer);
const $ = (sel) => viewer.querySelector(sel);
const backdrop = $('.kodza-gallery__backdrop');
const stage = $('.kodza-gallery__stage');
const countEl = $('.kodza-gallery__count b');
const lines = [...viewer.querySelectorAll('.kodza-gallery__line > span')];
const chrome = [$('.kodza-gallery__bar'), $('.kodza-gallery__nav')];
const style = getComputedStyle(root);
const duration = reduceMotion ? 0 : parseFloat(style.getPropertyValue('--kz-gal-duration')) || 900;
const ease = style.getPropertyValue('--kz-gal-ease').trim() || 'cubic-bezier(0.76, 0, 0.24, 1)';
let current = 0;
let frame = null;
let busy = false;
// ---------- Geometry ----------
const ratioOf = (item) => (item.img.naturalWidth || item.img.width) / (item.img.naturalHeight || item.img.height) || 1.5;
function fitRect(item) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const padX = Math.max(20, vw * 0.06);
const top = 78;
const bottom = vw < 720 ? 168 : 132;
const maxW = vw - padX * 2;
const maxH = vh - top - bottom;
const ratio = ratioOf(item);
let width = maxW;
let height = width / ratio;
if (height > maxH) {
height = maxH;
width = height * ratio;
}
return { left: (vw - width) / 2, top: top + (maxH - height) / 2, width, height };
}
const box = (r) => ({ left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` });
function makeFrame(item) {
const el = document.createElement('div');
el.className = 'kodza-gallery__frame';
const img = document.createElement('img');
img.alt = item.img.alt;
img.src = item.img.currentSrc || item.img.src;
// The thumbnail shows instantly; the large file takes over once loaded
if (item.full && item.full !== img.src) {
const hi = new Image();
hi.onload = () => (img.src = item.full);
hi.src = item.full;
}
el.append(img);
Object.assign(el.style, box(fitRect(item)));
stage.append(el);
return el;
}
// ---------- Caption ----------
function setCaption(item, animate = true) {
const values = [item.title, item.meta];
countEl.textContent = pad(current + 1);
lines.forEach((span, i) => {
span.textContent = values[i];
if (animate && duration) {
span.animate(
[{ transform: 'translateY(105%)' }, { transform: 'translateY(0)' }],
{ duration: duration * 0.8, delay: duration * 0.35 + i * 70, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'backwards' }
);
}
});
}
// ---------- Open ----------
async function open(index) {
if (busy || viewer.open) return;
busy = true;
current = index;
const item = items[index];
frame = makeFrame(item);
// Wait until the flying image is ready to paint, so the thumbnail never blinks
await frame.firstChild.decode().catch(() => {});
if (signal.aborted) return;
const from = item.img.getBoundingClientRect();
viewer.showModal();
item.img.style.visibility = 'hidden';
setCaption(item);
const to = fitRect(item);
const flight = frame.animate([box(from), box(to)], { duration, easing: ease });
backdrop.animate([{ opacity: 0 }, { opacity: 1 }], { duration: duration * 0.7, easing: 'ease-out' });
chrome.forEach((el) =>
el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: duration * 0.6, delay: duration * 0.4, fill: 'backwards' })
);
flight.finished.then(() => (busy = false));
}
// ---------- Close ----------
function close() {
if (busy || !viewer.open) return;
busy = true;
const item = items[current];
// The page behind cannot scroll, but we can bring the thumbnail into view for the landing
const rect = item.img.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > window.innerHeight) item.button.scrollIntoView({ block: 'center' });
const to = item.img.getBoundingClientRect();
const flight = frame.animate([box(frame.getBoundingClientRect()), box(to)], { duration, easing: ease, fill: 'forwards' });
backdrop.animate([{ opacity: 1 }, { opacity: 0 }], { duration: duration * 0.8, easing: 'ease-in', fill: 'forwards' });
[...chrome, ...lines.map((l) => l.parentElement)].forEach((el) =>
el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: duration * 0.3, fill: 'forwards' })
);
flight.finished.then(() => {
if (signal.aborted) return;
item.img.style.visibility = '';
viewer.close();
stage.replaceChildren();
viewer.getAnimations({ subtree: true }).forEach((a) => a.cancel());
item.button.focus({ preventScroll: true });
frame = null;
busy = false;
});
}
// ---------- Navigate : curtain wipe ----------
async function go(dir) {
if (busy || !viewer.open || items.length < 2) return;
busy = true;
const prevItem = items[current];
current = (current + dir + items.length) % items.length;
const item = items[current];
const old = frame;
frame = makeFrame(item);
frame.style.clipPath = dir > 0 ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)';
await frame.firstChild.decode().catch(() => {});
frame.style.clipPath = '';
prevItem.img.style.visibility = '';
item.img.style.visibility = 'hidden';
// Push: the new image wipes in while the old one is wiped out by the same edge.
// Each image only moves inside its own frame, so nothing spills outside.
const speed = duration * 0.9;
const timing = { duration: speed, easing: ease };
const enter = dir > 0 ? 'inset(0 0 0 100%)' : 'inset(0 100% 0 0)';
const leave = dir > 0 ? 'inset(0 100% 0 0)' : 'inset(0 0 0 100%)';
const wipeIn = frame.animate([{ clipPath: enter }, { clipPath: 'inset(0 0 0 0)' }], timing);
frame.firstChild.animate(
[{ transform: `translateX(${dir * 12}%) scale(1.12)` }, { transform: 'none' }],
timing
);
const wipeOut = old.animate([{ clipPath: 'inset(0 0 0 0)' }, { clipPath: leave }], { ...timing, fill: 'forwards' });
old.firstChild.animate(
[{ transform: 'none', opacity: 1 }, { transform: `translateX(${-dir * 18}%)`, opacity: 0.4 }],
{ ...timing, fill: 'forwards' }
);
lines.forEach((span) =>
span.animate([{ transform: 'translateY(0)' }, { transform: 'translateY(-105%)' }], {
duration: speed * 0.35,
easing: 'ease-in',
})
);
setTimeout(() => setCaption(item), speed * 0.3);
Promise.all([wipeIn.finished, wipeOut.finished]).then(() => {
old.remove();
busy = false;
});
}
// ---------- Events ----------
items.forEach((item, i) => {
item.button.addEventListener('click', () => open(i), { signal });
// Warm the large file as soon as the visitor shows interest
const warm = () => {
if (item.full) new Image().src = item.full;
};
item.button.addEventListener('pointerenter', warm, { once: true, signal });
item.button.addEventListener('focus', warm, { once: true, signal });
});
$('.kodza-gallery__close').addEventListener('click', close, { signal });
viewer.querySelectorAll('.kodza-gallery__nav button').forEach((b) =>
b.addEventListener('click', () => go(Number(b.dataset.dir)), { signal })
);
backdrop.addEventListener('click', close, { signal });
viewer.addEventListener('cancel', (event) => {
event.preventDefault();
close();
}, { signal });
viewer.addEventListener('keydown', (event) => {
if (event.key === 'ArrowRight') go(1);
if (event.key === 'ArrowLeft') go(-1);
}, { signal });
// Swipe on touch screens
let startX = null;
viewer.addEventListener('pointerdown', (event) => (startX = event.clientX), { signal });
viewer.addEventListener('pointerup', (event) => {
if (startX === null) return;
const dx = event.clientX - startX;
startX = null;
if (Math.abs(dx) > 50) go(dx < 0 ? 1 : -1);
}, { signal });
window.addEventListener('resize', () => {
if (frame && !busy) Object.assign(frame.style, box(fitRect(items[current])));
}, { signal });
// Stopped while open: the viewer closes at once and the page scrolls again
signal.addEventListener('abort', () => {
if (!viewer.open) return;
viewer.close();
});
}
// 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 Gallery, for React. From https://kodza.dev/components/image-gallery/
// 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-gallery.css and image-gallery.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-gallery.css';
export type ImageGalleryProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: clamp(14px, 2vw, 28px) */
"--kz-gal-gap"?: string | number;
/** default: 4px */
"--kz-gal-radius"?: string | number;
/** default: #0a0a0a */
"--kz-gal-ink"?: string | number;
/** default: rgba(10, 10, 10, 0.6) */
"--kz-gal-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-gal-placeholder"?: string | number;
/** default: #0a0a0a */
"--kz-gal-viewer-bg"?: string | number;
/** default: #ffffff */
"--kz-gal-viewer-fg"?: string | number;
/** default: 900ms */
"--kz-gal-duration"?: string | number;
/** default: cubic-bezier(0.76, 0, 0.24, 1) */
"--kz-gal-ease"?: string | number;
};
className?: string;
};
export function ImageGallery({ vars, className, style, ...rest }: ImageGalleryProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-gallery", 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('./image-gallery.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 data-kodza-gallery="" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<figure className="kodza-gallery__item" style={{ "--start": "1", "--span": "7", "--ratio": "3 / 2" } as CSSProperties}>
<button className="kodza-gallery__open" type="button" aria-label="Open image: transition: steps(9)">
<img className="kodza-gallery__img" src="https://kodza.dev/media/kz-steps-1400.webp" data-full="https://kodza.dev/media/kz-steps-2400.webp" alt="A concrete staircase that climbs nine steps and stops in mid-air" loading="lazy" />
</button>
<figcaption className="kodza-gallery__caption">
<span className="kodza-gallery__index">01</span>
{' '}
<span className="kodza-gallery__title">transition: steps(9)</span>
{' '}
<span className="kodza-gallery__meta">Leads nowhere</span>
</figcaption>
</figure>
<figure className="kodza-gallery__item" style={{ "--start": "9", "--span": "4", "--ratio": "4 / 5", "--shift": "clamp(40px, 8vw, 120px)" } as CSSProperties}>
<button className="kodza-gallery__open" type="button" aria-label="Open image: display: block">
<img className="kodza-gallery__img" src="https://kodza.dev/media/kz-block-1400.webp" data-full="https://kodza.dev/media/kz-block-2400.webp" alt="A tall concrete slab standing alone, its long shadow stretching toward the viewer" loading="lazy" />
</button>
<figcaption className="kodza-gallery__caption">
<span className="kodza-gallery__index">02</span>
{' '}
<span className="kodza-gallery__title">display: block</span>
{' '}
<span className="kodza-gallery__meta">Zero dependencies</span>
</figcaption>
</figure>
<figure className="kodza-gallery__item" style={{ "--start": "2", "--span": "4", "--ratio": "1 / 1" } as CSSProperties}>
<button className="kodza-gallery__open" type="button" aria-label="Open image: border-radius: 50%">
<img className="kodza-gallery__img" src="https://kodza.dev/media/kz-arch-1400.webp" data-full="https://kodza.dev/media/kz-arch-2400.webp" alt="A concrete wall with a round-topped opening, sunlight pouring through it onto the ground" loading="lazy" />
</button>
<figcaption className="kodza-gallery__caption">
<span className="kodza-gallery__index">03</span>
{' '}
<span className="kodza-gallery__title">border-radius: 50%</span>
{' '}
<span className="kodza-gallery__meta">Concrete, but soft</span>
</figcaption>
</figure>
<figure className="kodza-gallery__item" style={{ "--start": "7", "--span": "5", "--ratio": "4 / 3", "--shift": "clamp(30px, 6vw, 90px)" } as CSSProperties}>
<button className="kodza-gallery__open" type="button" aria-label="Open image: z-index: 9999">
<img className="kodza-gallery__img" src="https://kodza.dev/media/kz-stack-1400.webp" data-full="https://kodza.dev/media/kz-stack-2400.webp" alt="Three concrete slabs stacked at slightly different angles" loading="lazy" />
</button>
<figcaption className="kodza-gallery__caption">
<span className="kodza-gallery__index">04</span>
{' '}
<span className="kodza-gallery__title">z-index: 9999</span>
{' '}
<span className="kodza-gallery__meta">Works on my machine</span>
</figcaption>
</figure>
<figure className="kodza-gallery__item" style={{ "--start": "1", "--span": "5", "--ratio": "4 / 5" } as CSSProperties}>
<button className="kodza-gallery__open" type="button" aria-label="Open image: repeat(7, 1fr)">
<img className="kodza-gallery__img" src="https://kodza.dev/media/kz-columns-1400.webp" data-full="https://kodza.dev/media/kz-columns-2400.webp" alt="A row of square concrete pillars under a flat roof, casting striped shadows" loading="lazy" />
</button>
<figcaption className="kodza-gallery__caption">
<span className="kodza-gallery__index">05</span>
{' '}
<span className="kodza-gallery__title">repeat(7, 1fr)</span>
{' '}
<span className="kodza-gallery__meta">Grid, 1968 edition</span>
</figcaption>
</figure>
<figure className="kodza-gallery__item" style={{ "--start": "7", "--span": "6", "--ratio": "3 / 2", "--shift": "clamp(40px, 10vw, 150px)" } as CSSProperties}>
<button className="kodza-gallery__open" type="button" aria-label="Open image: position: absolute">
<img className="kodza-gallery__img" src="https://kodza.dev/media/kz-float-1400.webp" data-full="https://kodza.dev/media/kz-float-2400.webp" alt="A concrete block hovering above the ground, over its own shadow" loading="lazy" />
</button>
<figcaption className="kodza-gallery__caption">
<span className="kodza-gallery__index">06</span>
{' '}
<span className="kodza-gallery__title">position: absolute</span>
{' '}
<span className="kodza-gallery__meta">Out of the flow</span>
</figcaption>
</figure>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.