OTP Input
A one-time code field: one real input drawn as cells. Digits rise into place, paste and SMS autofill just work, and the cells turn to ink once complete.
View codeA drop zone over a real file input: the border marches when files hover, each file gets a row with a thumbnail and a rolling progress, too-big files say so.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Use Kodza File Upload for the attachments in my contact form.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-upload" data-max-mb="10">
<label class="kodza-upload__zone">
<input class="kodza-upload__input" type="file" name="files" multiple accept="image/*,.pdf,.zip">
<svg class="kodza-upload__border" aria-hidden="true"><rect rx="16" ry="16"/></svg>
<span class="kodza-upload__icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path class="kodza-upload__arrow" d="M12 15V4M7.5 8.5 12 4l4.5 4.5"/><path d="M4 14v3.5A2.5 2.5 0 0 0 6.5 20h11a2.5 2.5 0 0 0 2.5-2.5V14"/></svg></span>
<span class="kodza-upload__title"><span class="kodza-upload__words"><span>Drop files here, or <u>browse</u></span></span></span>
<span class="kodza-upload__hint">Images, PDFs and ZIPs. 10 MB each, which is plenty.</span>
</label>
<ul class="kodza-upload__list" aria-live="polite"></ul>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — File Upload
A drop zone over a real file input. Drag files over the page and the zone wakes up; over the
zone, its dashed border starts to march, the arrow lifts and the title rolls to "Let go".
Each file gets a row that opens up with a thumbnail, its size and a progress bar; the
percentage rolls, a tick draws itself when it is done, and a file that is too big says so.
Uploading is yours: set root.kodzaUpload = (file, onProgress) => Promise. Without it, files
are simply attached to the input for the form.
Everything is tuned with the --kz-upload-* variables. */
.kodza-upload {
--kz-upload-bg: #ffffff;
--kz-upload-fg: #0a0a0a;
--kz-upload-muted: rgba(10, 10, 10, 0.6);
--kz-upload-line: rgba(10, 10, 10, 0.24);
--kz-upload-soft: rgba(10, 10, 10, 0.05);
--kz-upload-ink: #0a0a0a;
--kz-upload-on-ink: #ffffff;
--kz-upload-radius: 16px;
--kz-upload-ease: cubic-bezier(0.22, 1, 0.36, 1);
display: grid;
gap: 12px;
width: 100%;
max-width: 460px;
color: var(--kz-upload-fg);
}
/* The zone: the whole thing is the label of the file input */
.kodza-upload__zone {
position: relative;
display: grid;
justify-items: center;
gap: 6px;
padding: 36px 24px 32px;
border-radius: var(--kz-upload-radius);
background: var(--kz-upload-bg);
text-align: center;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.4s var(--kz-upload-ease);
}
.kodza-upload__input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
/* The dashed border is an SVG rectangle, so it can march */
.kodza-upload__border {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.kodza-upload__border rect {
x: 1px;
y: 1px;
width: calc(100% - 2px);
height: calc(100% - 2px);
fill: none;
stroke: var(--kz-upload-line);
stroke-width: 1.5;
stroke-dasharray: 6 6;
transition: stroke 0.3s ease;
}
.kodza-upload__zone:hover .kodza-upload__border rect,
.kodza-upload.is-dragging .kodza-upload__border rect {
stroke: var(--kz-upload-muted);
}
.kodza-upload.is-over .kodza-upload__border rect {
stroke: var(--kz-upload-ink);
animation: kodza-upload-march 0.6s linear infinite;
}
@keyframes kodza-upload-march {
to {
stroke-dashoffset: -12;
}
}
.kodza-upload.is-over .kodza-upload__zone {
background: var(--kz-upload-soft);
transform: scale(1.015);
}
.kodza-upload__zone:has(.kodza-upload__input:focus-visible) {
outline: 2px solid var(--kz-upload-fg);
outline-offset: 3px;
}
.kodza-upload__icon {
display: grid;
place-items: center;
width: 44px;
height: 44px;
margin-bottom: 6px;
border-radius: 12px;
background: var(--kz-upload-soft);
}
.kodza-upload__icon svg {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.kodza-upload__arrow {
transition: transform 0.4s var(--kz-upload-ease);
}
.kodza-upload__zone:hover .kodza-upload__arrow {
transform: translateY(-1.5px);
}
.kodza-upload.is-over .kodza-upload__arrow {
animation: kodza-upload-lift 0.9s var(--kz-upload-ease) infinite alternate;
}
@keyframes kodza-upload-lift {
from {
transform: translateY(-1px);
}
to {
transform: translateY(-4px);
}
}
.kodza-upload__title {
font-size: 15px;
font-weight: 500;
}
.kodza-upload__words {
display: inline-grid;
overflow: hidden;
vertical-align: bottom;
}
.kodza-upload__words > span {
grid-area: 1 / 1;
}
.kodza-upload__title u {
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.kodza-upload__hint {
font-size: 13px;
color: var(--kz-upload-muted);
}
/* The files: each row opens up, then slides in */
.kodza-upload__list {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.kodza-upload__item {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.45s var(--kz-upload-ease);
}
.kodza-upload__item.is-shown {
grid-template-rows: 1fr;
}
.kodza-upload__row {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
min-height: 0;
overflow: hidden;
opacity: 0;
transform: translateY(-6px);
transition: opacity 0.3s ease, transform 0.45s var(--kz-upload-ease);
}
.kodza-upload__item.is-shown .kodza-upload__row {
padding: 8px 0;
opacity: 1;
transform: none;
}
.kodza-upload__item + .kodza-upload__item .kodza-upload__row {
border-top: 1px solid var(--kz-upload-soft);
}
.kodza-upload__thumb {
display: grid;
place-items: center;
width: 40px;
height: 40px;
overflow: hidden;
border-radius: 8px;
background: var(--kz-upload-soft);
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.kodza-upload__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.kodza-upload__info {
display: grid;
gap: 5px;
min-width: 0;
}
.kodza-upload__name {
overflow: hidden;
font-size: 14px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.kodza-upload__meta {
display: flex;
gap: 8px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--kz-upload-muted);
}
.kodza-upload__status {
display: inline-grid;
overflow: hidden;
}
.kodza-upload__status > span {
grid-area: 1 / 1;
white-space: nowrap;
}
/* The bar fills, then folds away once done */
.kodza-upload__bar {
height: 2px;
overflow: hidden;
border-radius: 2px;
background: var(--kz-upload-soft);
transition: opacity 0.4s ease 0.4s;
}
.kodza-upload__bar span {
display: block;
height: 100%;
background: var(--kz-upload-ink);
transform: scaleX(var(--kz-progress, 0));
transform-origin: left;
transition: transform 0.3s var(--kz-upload-ease);
}
.kodza-upload__item.is-done .kodza-upload__bar,
.kodza-upload__item.is-error .kodza-upload__bar,
.kodza-upload__item.is-ready .kodza-upload__bar {
opacity: 0;
}
.kodza-upload__item.is-error .kodza-upload__meta {
color: var(--kz-upload-fg);
font-weight: 500;
}
/* The end of the row: a tick when done, a cross to remove */
.kodza-upload__end {
display: flex;
align-items: center;
gap: 4px;
}
.kodza-upload__tick {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset 0.45s var(--kz-upload-ease) 0.15s;
}
.kodza-upload__item.is-done .kodza-upload__tick {
stroke-dashoffset: 0;
}
.kodza-upload__remove {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: var(--kz-upload-muted);
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.kodza-upload__remove:hover {
background: var(--kz-upload-soft);
color: var(--kz-upload-fg);
}
.kodza-upload__remove:focus-visible {
outline: 2px solid var(--kz-upload-fg);
outline-offset: 1px;
}
.kodza-upload__remove svg {
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
}
@media (prefers-reduced-motion: reduce) {
.kodza-upload.is-over .kodza-upload__border rect,
.kodza-upload.is-over .kodza-upload__arrow {
animation: none;
}
.kodza-upload__zone,
.kodza-upload__item,
.kodza-upload__row,
.kodza-upload__bar,
.kodza-upload__bar span,
.kodza-upload__tick {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-upload {
--kz-upload-bg: #161616;
--kz-upload-fg: #f2f2f0;
--kz-upload-muted: rgba(242, 242, 240, 0.62);
--kz-upload-line: rgba(255, 255, 255, 0.26);
--kz-upload-soft: rgba(255, 255, 255, 0.07);
--kz-upload-ink: #f2f2f0;
--kz-upload-on-ink: #0a0a0a;
}
*/
/* kodza-guard c0f625c799e7 — 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-upload__zone):not(#_) {
outline: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-upload:not(#_),
.kodza-upload__icon:not(#_),
.kodza-upload__arrow:not(#_),
.kodza-upload__title:not(#_),
.kodza-upload__words:not(#_),
.kodza-upload__hint:not(#_),
.kodza-upload__words span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-upload__zone:not(#_) {
border: revert;
margin: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: 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-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-upload__input: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;
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;
white-space: revert;
min-height: revert;
}
.kodza-upload__border: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;
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-upload__list:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: 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-upload__icon 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-upload__words u:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: 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-decoration-line: revert;
text-decoration-color: revert;
text-decoration-style: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-upload__input:not(#_):where(:not([hidden])),
.kodza-upload__border:not(#_):where(:not([hidden])),
.kodza-upload__icon svg:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])),
.kodza-upload__words u:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-upload__input:not(#_):where([hidden]),
.kodza-upload__border:not(#_):where([hidden]),
.kodza-upload__icon svg:where(:not([class*="kodza-"])):not(#_):where([hidden]),
.kodza-upload__words u: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-upload:not(#_) {
width: min(100%, 460px);
}
.kodza-upload__zone:not(#_) {
position: relative;
display: grid;
padding: 36px 24px 32px;
border-radius: var(--kz-upload-radius);
background: var(--kz-upload-bg);
text-align: center;
transition: background-color 0.3s ease, transform 0.4s var(--kz-upload-ease);
}
.kodza-upload__input:not(#_) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.kodza-upload__border:not(#_) {
position: absolute;
}
.kodza-upload.is-over .kodza-upload__zone:not(#_) {
background: var(--kz-upload-soft);
}
.kodza-upload__zone:has(.kodza-upload__input:focus-visible):not(#_) {
outline: 2px solid var(--kz-upload-fg);
outline-offset: 3px;
}
.kodza-upload__title u:not(#_) {
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.kodza-upload__list:not(#_) {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
@media (prefers-reduced-motion: reduce) {
.kodza-upload__zone:not(#_) {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — File Upload
// Wakes the zone up while files are dragged over the page, takes drops and picks, and gives
// each file a row: thumbnail, size, progress, done or error. Files too big (data-max-mb) are
// refused with a reason. If root.kodzaUpload = (file, onProgress) => Promise is set, each file
// is uploaded through it; otherwise the files stay attached to the input for the form.
// root.kodzaAdd(files) adds files from a script. Fires "kodza:files" with { files }.
(() => {
const SELECTOR = '.kodza-upload';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';
const size = (bytes) => (bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(0)} KB` : `${Number((bytes / 1048576).toFixed(1))} MB`);
const tick = '<svg class="kodza-upload__tick" viewBox="0 0 18 18" aria-hidden="true"><path d="M4 9.5l3.2 3 6.8-7.5" pathLength="1"/></svg>';
const cross = '<svg viewBox="0 0 12 12" aria-hidden="true"><path d="M3 3l6 6M9 3 3 9"/></svg>';
// A word rolls to its new value
const roll = (box, text) => {
const prev = box.lastElementChild;
if (prev && prev.textContent === text) return;
const span = Object.assign(document.createElement('span'), { textContent: text });
box.append(span);
if (!prev) return;
if (reduceMotion) return prev.remove();
const timing = { duration: 380, easing: ease };
prev.animate([{ transform: 'none' }, { transform: 'translateY(-110%)' }], { ...timing, fill: 'forwards' }).finished.then(() => prev.remove());
span.animate([{ transform: 'translateY(110%)' }, { transform: 'none' }], timing);
};
const hasFiles = (event) => [...(event.dataTransfer?.types || [])].includes('Files');
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const input = root.querySelector('.kodza-upload__input');
const zone = root.querySelector('.kodza-upload__zone');
const list = root.querySelector('.kodza-upload__list');
const words = root.querySelector('.kodza-upload__words');
if (!input || !zone || !list) return;
// Files dragged anywhere over the page: the zone wakes up
let depth = 0;
window.addEventListener('dragenter', (event) => {
if (!hasFiles(event)) return;
depth++;
root.classList.add('is-dragging');
}, { signal });
window.addEventListener('dragleave', () => {
depth = Math.max(0, depth - 1);
if (!depth) root.classList.remove('is-dragging');
}, { signal });
window.addEventListener('drop', () => {
depth = 0;
root.classList.remove('is-dragging', 'is-over');
}, { signal });
// A drop next to the zone should not open the file in the tab
window.addEventListener('dragover', (event) => hasFiles(event) && event.preventDefault(), { signal });
const maxBytes = (Number(root.dataset.maxMb) || 0) * 1048576;
const idle = words?.firstElementChild?.cloneNode(true);
const attached = new DataTransfer();
const setWords = (over) => {
if (!words) return;
const prev = words.lastElementChild;
if (over === prev.classList.contains('is-over')) return;
const span = over ? Object.assign(document.createElement('span'), { textContent: 'Let go. We have got it.', className: 'is-over' }) : idle.cloneNode(true);
words.append(span);
if (reduceMotion) return prev.remove();
const timing = { duration: 380, easing: ease };
prev.animate([{ transform: 'none' }, { transform: 'translateY(-110%)' }], { ...timing, fill: 'forwards' }).finished.then(() => prev.remove());
span.animate([{ transform: 'translateY(110%)' }, { transform: 'none' }], timing);
};
const row = (file) => {
const item = document.createElement('li');
item.className = 'kodza-upload__item';
const ext = (file.name.split('.').pop() || '').slice(0, 4);
item.innerHTML = `
<div class="kodza-upload__row">
<span class="kodza-upload__thumb" aria-hidden="true"></span>
<span class="kodza-upload__info">
<span class="kodza-upload__name"></span>
<span class="kodza-upload__meta"><span>${size(file.size)}</span><span class="kodza-upload__status"></span></span>
<span class="kodza-upload__bar"><span></span></span>
</span>
<span class="kodza-upload__end">${tick}<button class="kodza-upload__remove" type="button">${cross}</button></span>
</div>`;
item.querySelector('.kodza-upload__name').textContent = file.name;
item.querySelector('.kodza-upload__remove').setAttribute('aria-label', `Remove ${file.name}`);
const thumb = item.querySelector('.kodza-upload__thumb');
if (file.type.startsWith('image/')) {
const img = document.createElement('img');
img.alt = '';
img.src = URL.createObjectURL(file);
img.onload = () => URL.revokeObjectURL(img.src);
thumb.append(img);
} else {
thumb.textContent = ext;
}
return item;
};
const remove = (item, file) => {
item.classList.remove('is-shown');
item.kzCancel?.();
// Take it off the input too
const keep = new DataTransfer();
[...attached.files].filter((f) => f !== file).forEach((f) => keep.items.add(f));
attached.items.clear();
[...keep.files].forEach((f) => attached.items.add(f));
input.files = attached.files;
setTimeout(() => item.remove(), reduceMotion ? 0 : 450);
};
const add = (files) => {
const added = [...files];
added.forEach((file, i) => {
const item = row(file);
const status = item.querySelector('.kodza-upload__status');
const bar = item.querySelector('.kodza-upload__bar');
list.append(item);
// One after the other: the row opens, then fills
setTimeout(() => item.classList.add('is-shown'), reduceMotion ? 0 : 30 + i * 90);
item.querySelector('.kodza-upload__remove').addEventListener('click', () => remove(item, file), { signal });
if (maxBytes && file.size > maxBytes) {
item.classList.add('is-error');
roll(status, `Too big. The limit is ${size(maxBytes)}.`);
return;
}
attached.items.add(file);
input.files = attached.files;
const upload = root.kodzaUpload;
if (typeof upload !== 'function') {
item.classList.add('is-ready');
roll(status, 'Ready');
return;
}
let cancelled = false;
item.kzCancel = () => (cancelled = true);
roll(status, '0%');
upload(file, (fraction) => {
if (cancelled) return;
const pct = Math.max(0, Math.min(100, Math.round(fraction * 100)));
bar.style.setProperty('--kz-progress', pct / 100);
roll(status, `${pct}%`);
}).then(() => {
if (cancelled) return;
bar.style.setProperty('--kz-progress', 1);
item.classList.add('is-done');
roll(status, 'Done');
}).catch((error) => {
if (cancelled) return;
item.classList.add('is-error');
roll(status, error?.message || 'Upload failed. Try again.');
});
});
root.dispatchEvent(new CustomEvent('kodza:files', { detail: { files: added }, bubbles: true }));
};
root.kodzaAdd = add;
signal.addEventListener('abort', () => delete root.kodzaAdd);
input.addEventListener('change', () => {
// The picker replaces the input's files: take them, then put the whole set back
const picked = [...input.files];
input.files = attached.files;
add(picked);
}, { signal });
zone.addEventListener('dragover', (event) => {
if (!hasFiles(event)) return;
event.preventDefault();
root.classList.add('is-over');
setWords(true);
}, { signal });
zone.addEventListener('dragleave', (event) => {
if (zone.contains(event.relatedTarget)) return;
root.classList.remove('is-over');
setWords(false);
}, { signal });
zone.addEventListener('drop', (event) => {
event.preventDefault();
root.classList.remove('is-over');
setWords(false);
if (event.dataTransfer?.files.length) add(event.dataTransfer.files);
}, { signal });
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load.
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const init = (scope) => find(scope).forEach((root) => {
if (root.classList.contains('is-ready')) return; // already running, maybe from another copy of this script
const controller = new AbortController();
instances.set(root, { controller, html: root.innerHTML, attrs: [...root.attributes].map((a) => [a.name, a.value]) });
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
instance.controller.abort();
[...root.attributes].forEach((a) => root.removeAttribute(a.name));
instance.attrs.forEach(([name, value]) => root.setAttribute(name, value));
root.innerHTML = instance.html;
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — File Upload, for React. From https://kodza.dev/components/file-upload/
// 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: file-upload.css and file-upload.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 './file-upload.css';
export type FileUploadProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars" | "maxMb" | "onFiles"> & {
/** data-max-mb on the root (default: 10) */
maxMb?: "10";
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-upload-bg"?: string | number;
/** default: #0a0a0a */
"--kz-upload-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.6) */
"--kz-upload-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.24) */
"--kz-upload-line"?: string | number;
/** default: rgba(10, 10, 10, 0.05) */
"--kz-upload-soft"?: string | number;
/** default: #0a0a0a */
"--kz-upload-ink"?: string | number;
/** default: #ffffff */
"--kz-upload-on-ink"?: string | number;
/** default: 16px */
"--kz-upload-radius"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-upload-ease"?: string | number;
};
/** "kodza:files", fired by the script (bubbles from the root) */
onFiles?: (event: CustomEvent) => void;
className?: string;
};
export function FileUpload({ maxMb = "10", vars, onFiles, className, style, ...rest }: FileUploadProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-upload", 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, maxMb, 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('./file-upload.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
useEffect(() => {
const root = ref.current;
if (!root || !onFiles) return;
const listener = (event: Event) => onFiles(event as CustomEvent);
root.addEventListener("kodza:files", listener);
return () => root.removeEventListener("kodza:files", listener);
}, [onFiles, key]);
return (
<div key={key} {...rest} className={classes} data-max-mb={maxMb} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<label className="kodza-upload__zone">
<input className="kodza-upload__input" type="file" name="files" multiple accept="image/*,.pdf,.zip" />
{' '}
<svg className="kodza-upload__border" aria-hidden="true">
<rect rx="16" ry="16" />
</svg>
{' '}
<span className="kodza-upload__icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path className="kodza-upload__arrow" d="M12 15V4M7.5 8.5 12 4l4.5 4.5" />
<path d="M4 14v3.5A2.5 2.5 0 0 0 6.5 20h11a2.5 2.5 0 0 0 2.5-2.5V14" />
</svg>
</span>
{' '}
<span className="kodza-upload__title">
<span className="kodza-upload__words">
<span>Drop files here, or <u>browse</u></span>
</span>
</span>
{' '}
<span className="kodza-upload__hint">Images, PDFs and ZIPs. 10 MB each, which is plenty.</span>
</label>
<ul className="kodza-upload__list" aria-live="polite" />
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.