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 codeWrap an article and every picture in it opens large: it grows from its own place to the screen, arrows or a swipe go to the next, and it returns to its place on Escape.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Open my article images in a Kodza Lightbox.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-lightbox">
<img src="https://kodza.dev/media/kz-steps-1400.webp" data-full="https://kodza.dev/media/kz-steps-2400.webp" alt="Concrete steps climbing towards the light" width="1400" height="933" />
<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" width="1400" height="1400" />
<img src="https://kodza.dev/media/kz-columns-1400.webp" data-full="https://kodza.dev/media/kz-columns-2400.webp" alt="A row of concrete columns casting long shadows" width="1120" height="1400" />
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Lightbox
Wrap an article (or any content) in .kodza-lightbox and every picture in it opens large:
it grows from its own place to the whole screen, the others are a swipe or an arrow key
away, and it goes back to its place on Escape, a click, a scroll or a swipe down. Pictures
inside a link, or with data-no-lightbox, are left alone. data-full gives a sharper file.
It is a real dialog element: focus stays inside and comes back after.
Everything is tuned with the --kz-lightbox-* variables. */
.kodza-lightbox {
--kz-lightbox-shade: rgba(10, 10, 10, 0.9);
--kz-lightbox-fg: #ffffff;
--kz-lightbox-muted: rgba(255, 255, 255, 0.62);
--kz-lightbox-soft: rgba(255, 255, 255, 0.1);
--kz-lightbox-radius: 12px;
--kz-lightbox-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
/* The pictures in the article: a button each, the picture as it was */
.kodza-lightbox__trigger {
display: block;
width: 100%;
padding: 0;
border: 0;
border-radius: var(--kz-lightbox-radius);
background: none;
cursor: zoom-in;
transition: transform 0.4s var(--kz-lightbox-ease);
}
.kodza-lightbox__trigger img {
display: block;
width: 100%;
height: auto;
border-radius: inherit;
}
.kodza-lightbox__trigger:active {
transform: scale(0.99);
}
.kodza-lightbox__trigger:focus-visible {
outline: 2px solid currentColor;
outline-offset: 4px;
}
/* While open, the picture in the article is held by the viewer */
.kodza-lightbox__trigger.is-away img {
visibility: hidden;
}
/* The viewer: the whole screen, a shade, the picture, a bar */
.kodza-lightbox__viewer {
/* A modal dialog sits on the screen itself: 100% is the screen */
width: 100%;
max-width: none;
height: 100%;
max-height: none;
margin: 0;
padding: 0;
overflow: hidden;
border: 0;
background: none;
color: var(--kz-lightbox-fg);
}
.kodza-lightbox__viewer::backdrop {
background: none;
}
.kodza-lightbox__shade {
position: absolute;
inset: 0;
background: var(--kz-lightbox-shade);
/* The page behind is blurred out of reading, not just darkened */
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
}
.kodza-lightbox__image {
position: fixed;
max-width: none;
margin: 0;
border-radius: 6px;
transform-origin: 0 0;
cursor: zoom-out;
user-select: none;
-webkit-user-drag: none;
touch-action: none;
}
.kodza-lightbox__bar {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 24px 22px;
}
.kodza-lightbox__caption {
display: grid;
min-width: 0;
overflow: hidden;
font-size: 14px;
}
.kodza-lightbox__caption > span,
.kodza-lightbox__count > span {
grid-area: 1 / 1;
white-space: nowrap;
}
.kodza-lightbox__caption > span {
overflow: hidden;
text-overflow: ellipsis;
}
.kodza-lightbox__count {
display: inline-grid;
overflow: hidden;
color: var(--kz-lightbox-muted);
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
}
.kodza-lightbox__nav {
display: flex;
flex: none;
align-items: center;
gap: 8px;
}
.kodza-lightbox__button {
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--kz-lightbox-soft);
color: inherit;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.kodza-lightbox__button:hover {
background: rgba(255, 255, 255, 0.18);
}
.kodza-lightbox__button:active {
transform: scale(0.94);
}
.kodza-lightbox__button:focus-visible {
outline: 2px solid var(--kz-lightbox-fg);
outline-offset: 2px;
}
.kodza-lightbox__button svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.kodza-lightbox__close {
position: absolute;
top: 18px;
right: 18px;
}
.kodza-lightbox__viewer.is-single .kodza-lightbox__nav,
.kodza-lightbox__viewer.is-single .kodza-lightbox__count {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.kodza-lightbox__trigger,
.kodza-lightbox__button {
transition-duration: 0.01ms !important;
}
}
/* kodza-guard 3e080b9876c0 — 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-lightbox__trigger):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-lightbox__button):not(#_) {
outline: revert;
position: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-lightbox:not(#_),
.kodza-lightbox__shade:not(#_),
.kodza-lightbox__bar:not(#_),
.kodza-lightbox__nav:not(#_),
.kodza-lightbox__count:not(#_),
.kodza-lightbox__caption span:where(:not([class*="kodza-"])):not(#_),
.kodza-lightbox__count span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-lightbox__trigger:not(#_) {
margin: revert;
text-decoration: 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-lightbox__viewer:not(#_) {
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: 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-lightbox__caption: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-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-lightbox__button:not(#_) {
margin: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: 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-lightbox__trigger img:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: 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-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-lightbox__button 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-lightbox__viewer:not(#_):where(:not([hidden])),
.kodza-lightbox__button svg:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-lightbox__viewer:not(#_):where([hidden]),
.kodza-lightbox__button 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-lightbox__trigger:not(#_) {
display: block;
width: 100%;
padding: 0;
border: 0;
border-radius: var(--kz-lightbox-radius);
background: none;
cursor: zoom-in;
transition: transform 0.4s var(--kz-lightbox-ease);
}
.kodza-lightbox__trigger img:not(#_) {
display: block;
border-radius: inherit;
}
.kodza-lightbox__trigger:active:not(#_) {
transform: scale(0.99);
}
.kodza-lightbox__trigger:focus-visible:not(#_) {
outline: 2px solid currentColor;
outline-offset: 4px;
}
.kodza-lightbox__viewer:not(#_) {
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: none;
color: var(--kz-lightbox-fg);
}
.kodza-lightbox__caption:not(#_) {
display: grid;
font-size: 14px;
}
.kodza-lightbox__button:not(#_) {
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--kz-lightbox-soft);
color: inherit;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.kodza-lightbox__button:hover:not(#_) {
background: rgba(255, 255, 255, 0.18);
}
.kodza-lightbox__button:active:not(#_) {
transform: scale(0.94);
}
.kodza-lightbox__button:focus-visible:not(#_) {
outline: 2px solid var(--kz-lightbox-fg);
outline-offset: 2px;
}
.kodza-lightbox__close:not(#_) {
position: absolute;
top: 18px;
right: 18px;
}
@media (prefers-reduced-motion: reduce) {
.kodza-lightbox__trigger:not(#_),
.kodza-lightbox__button: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 — Lightbox
// Turns each picture of the content into a button, and opens a <dialog> where the picture
// grows from its own place to fit the screen (the sharper data-full swaps in once loaded).
// Arrows, buttons or a swipe move to the next one; Escape, a click, a scroll or a swipe down
// send it back to its place. Screen readers hear the caption and "2 of 3".
(() => {
const SELECTOR = '.kodza-lightbox';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';
const icon = (d) => `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="${d}"/></svg>`;
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const triggers = [...root.querySelectorAll('img')]
.filter((img) => !img.closest('a, button') && !img.hasAttribute('data-no-lightbox'))
.map((img) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'kodza-lightbox__trigger';
button.setAttribute('aria-label', `Enlarge: ${img.alt || 'picture'}`);
img.replaceWith(button);
button.append(img);
return button;
});
if (!triggers.length) return;
// The viewer
const viewer = document.createElement('dialog');
viewer.className = 'kodza-lightbox__viewer';
viewer.setAttribute('aria-label', 'Picture viewer');
viewer.innerHTML = `<div class="kodza-lightbox__shade"></div>
<div class="kodza-lightbox__bar">
<p class="kodza-lightbox__caption" aria-live="polite"><span></span></p>
<div class="kodza-lightbox__nav">
<span class="kodza-lightbox__count" aria-hidden="true"><span></span></span>
<button class="kodza-lightbox__button" type="button" data-step="-1" aria-label="Previous picture">${icon('M10 3.5 5.5 8l4.5 4.5')}</button>
<button class="kodza-lightbox__button" type="button" data-step="1" aria-label="Next picture">${icon('M6 3.5 10.5 8 6 12.5')}</button>
</div>
</div>
<button class="kodza-lightbox__button kodza-lightbox__close" type="button" aria-label="Close">${icon('M4 4l8 8M12 4l-8 8')}</button>`;
root.append(viewer);
viewer.classList.toggle('is-single', triggers.length < 2);
const shade = viewer.querySelector('.kodza-lightbox__shade');
const bar = viewer.querySelector('.kodza-lightbox__bar');
const close = viewer.querySelector('.kodza-lightbox__close');
const caption = viewer.querySelector('.kodza-lightbox__caption');
const count = viewer.querySelector('.kodza-lightbox__count');
let index = 0;
let image = null;
let closing = false;
let opened = 0;
let wheeled = 0;
const textOf = (i) => {
const img = triggers[i].querySelector('img');
return img.closest('figure')?.querySelector('figcaption')?.textContent.trim() || img.alt || '';
};
// Where the picture fits on screen: room kept for the close button above and the bar below
const fit = (img) => {
const ratio = (img.naturalWidth || img.width) / (img.naturalHeight || img.height) || 1;
const above = 68;
const below = 84;
const w = innerWidth - 48;
const h = innerHeight - above - below;
const width = Math.min(w, h * ratio);
const height = width / ratio;
return { left: (innerWidth - width) / 2, top: above + (h - height) / 2, width, height };
};
const place = (el, box) => Object.assign(el.style, { left: `${box.left}px`, top: `${box.top}px`, width: `${box.width}px`, height: `${box.height}px` });
// The transform that puts a box of the screen onto a picture of the article
const from = (box, rect) => `translate(${rect.left - box.left}px, ${rect.top - box.top}px) scale(${rect.width / box.width}, ${rect.height / box.height})`;
// A word that rolls: the old one up, the new one from below (or the other way)
const roll = (el, text, dir) => {
const old = el.lastElementChild;
if (old.textContent === text) return;
// On opening (no direction) or without motion, the word simply changes in place
if (reduceMotion || !dir) {
old.textContent = text;
return;
}
const next = Object.assign(document.createElement('span'), { textContent: text });
el.append(next);
const timing = { duration: 420, easing: ease };
old.animate([{ transform: 'none', opacity: 1 }, { transform: `translateY(${-dir * 100}%)`, opacity: 0 }], { ...timing, fill: 'forwards' }).finished.then(() => old.remove());
next.animate([{ transform: `translateY(${dir * 100}%)`, opacity: 0 }, { transform: 'none', opacity: 1 }], timing);
};
const label = (dir) => {
roll(caption, textOf(index), dir);
roll(count, `${index + 1} / ${triggers.length}`, dir);
};
const make = (i) => {
const source = triggers[i].querySelector('img');
const img = new Image();
img.className = 'kodza-lightbox__image';
img.alt = source.alt;
img.src = source.currentSrc || source.src;
img.width = source.naturalWidth || source.width;
img.height = source.naturalHeight || source.height;
const full = source.dataset.full;
if (full) {
const sharp = new Image();
sharp.onload = () => (img.src = full);
sharp.src = full;
}
return img;
};
// Fades that stayed at their end (fill forwards) after a close are let go before reopening
const settle = () => [shade, bar, close].forEach((el) => el.getAnimations().forEach((animation) => animation.cancel()));
const open = (i) => {
wheeled = 0;
index = i;
closing = false;
opened = Date.now();
settle();
image = make(i);
const box = fit(image);
place(image, box);
shade.after(image);
viewer.showModal();
triggers[i].classList.add('is-away');
label(0);
if (reduceMotion) return;
const rect = triggers[i].querySelector('img').getBoundingClientRect();
image.animate([{ transform: from(box, rect), borderRadius: '12px' }, { transform: 'none', borderRadius: '6px' }], { duration: 620, easing: ease });
shade.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 420, easing: 'ease' });
[bar, close].forEach((el) => el.animate([{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }], { duration: 480, delay: 200, easing: ease, fill: 'backwards' }));
};
const shut = () => {
if (closing || !viewer.open) return;
closing = true;
const trigger = triggers[index];
const done = () => {
trigger.classList.remove('is-away');
image?.remove();
image = null;
viewer.close();
settle();
};
if (reduceMotion) return done();
const box = image.getBoundingClientRect();
const rect = trigger.querySelector('img').getBoundingClientRect();
const seen = rect.bottom > 0 && rect.top < innerHeight;
// Back to its place if that place is on screen; otherwise it simply fades
image.animate(seen
? [{ transform: getComputedStyle(image).transform === 'none' ? 'none' : getComputedStyle(image).transform }, { transform: from(box, rect), borderRadius: '12px' }]
: [{ opacity: 1 }, { opacity: 0, transform: 'scale(0.96)' }], { duration: 520, easing: ease, fill: 'forwards' }).onfinish = done;
shade.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 420, easing: 'ease', fill: 'forwards' });
[bar, close].forEach((el) => el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' }));
};
const go = (step) => {
if (closing || triggers.length < 2) return;
const old = image;
triggers[index].classList.remove('is-away');
index = (index + step + triggers.length) % triggers.length;
triggers[index].classList.add('is-away');
image = make(index);
place(image, fit(image));
old.after(image);
label(step);
if (reduceMotion) return old.remove();
// A push: both move together by one screen, so they never cross
const push = { duration: 680, easing: 'cubic-bezier(0.76, 0, 0.24, 1)' };
old.getAnimations().forEach((animation) => animation.cancel());
old.animate([{ transform: 'none' }, { transform: `translateX(${-step * innerWidth}px)` }], { ...push, fill: 'forwards' }).onfinish = () => old.remove();
image.animate([{ transform: `translateX(${step * innerWidth}px)` }, { transform: 'none' }], push);
};
triggers.forEach((trigger, i) => trigger.addEventListener('click', () => open(i), { signal }));
viewer.querySelectorAll('[data-step]').forEach((button) => button.addEventListener('click', () => go(Number(button.dataset.step)), { signal }));
close.addEventListener('click', shut, { signal });
shade.addEventListener('click', shut, { signal });
viewer.addEventListener('cancel', (event) => {
event.preventDefault();
shut();
}, { signal });
viewer.addEventListener('keydown', (event) => {
if (event.key === 'ArrowRight') go(1);
if (event.key === 'ArrowLeft') go(-1);
}, { signal });
// A scroll closes it, but the page does not move underneath, and the end of the scroll that
// brought the reader here (trackpad inertia) does not close it again at once
viewer.addEventListener('wheel', (event) => {
event.preventDefault();
if (Date.now() - opened < 600) return;
wheeled += Math.abs(event.deltaY) + Math.abs(event.deltaX);
if (wheeled > 40) {
wheeled = 0;
shut();
}
}, { passive: false, signal });
viewer.addEventListener('touchmove', (event) => event.preventDefault(), { passive: false, signal });
// A swipe: sideways for the next one, down to close; a tap closes
let start = null;
viewer.addEventListener('pointerdown', (event) => {
if (event.target === image) start = { x: event.clientX, y: event.clientY };
}, { signal });
viewer.addEventListener('pointerup', (event) => {
if (!start) return;
const dx = event.clientX - start.x;
const dy = event.clientY - start.y;
start = null;
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy)) go(dx < 0 ? 1 : -1);
else if (dy > 80 || (Math.abs(dx) < 6 && Math.abs(dy) < 6)) shut();
}, { signal });
addEventListener('resize', () => image && !closing && place(image, fit(image)), { signal });
// Stopped while open (or closing): the viewer closes at once
signal.addEventListener('abort', () => viewer.open && 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 — Lightbox, for React. From https://kodza.dev/components/lightbox/
// 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: lightbox.css and lightbox.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 './lightbox.css';
export type LightboxProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: rgba(10, 10, 10, 0.9) */
"--kz-lightbox-shade"?: string | number;
/** default: #ffffff */
"--kz-lightbox-fg"?: string | number;
/** default: rgba(255, 255, 255, 0.62) */
"--kz-lightbox-muted"?: string | number;
/** default: rgba(255, 255, 255, 0.1) */
"--kz-lightbox-soft"?: string | number;
/** default: 12px */
"--kz-lightbox-radius"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-lightbox-ease"?: string | number;
};
className?: string;
};
export function Lightbox({ vars, className, style, ...rest }: LightboxProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-lightbox", 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('./lightbox.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}>
<img src="https://kodza.dev/media/kz-steps-1400.webp" data-full="https://kodza.dev/media/kz-steps-2400.webp" alt="Concrete steps climbing towards the light" width="1400" height="933" />
{' '}
<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" width="1400" height="1400" />
{' '}
<img src="https://kodza.dev/media/kz-columns-1400.webp" data-full="https://kodza.dev/media/kz-columns-2400.webp" alt="A row of concrete columns casting long shadows" width="1120" height="1400" />
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.