Foto's uploaden en ordenen New
Photo gallery for a listing or request: add photos with the button or by dragging them in (shown immediately via FileReader), reorder by dragging or with the keyboard (Space to pick up, arrows to move, Space to drop, Esc to cancel), choose the main photo and delete with undo. Errors for non-images, too large or too many. Every move is announced. Animates with View Transitions where available; otherwise the order changes instantly.
Foto's van je woning
De eerste foto is de hoofdfoto. Versleep met de greep of gebruik Spatie en de pijltjes.
How to use it
- Paste the HTML where the forms & inputs should appear.
- Add the CSS to your stylesheet. All classes are prefixed, so nothing clashes with your own styles.
- Add the JavaScript and call init() with the element that contains the HTML.
<!-- Foto's uploaden en ordenen · NedDev · eigen werk. -->
<div class="jouw-container">
<div class="ugh">
<div class="ugh__head">
<h3 class="ugh__h">Foto's van je woning</h3>
<span class="ugh__n" aria-live="polite"></span>
</div>
<p class="ugh__help" id="ugh-help">De eerste foto is de hoofdfoto. Versleep met de greep of gebruik Spatie en de pijltjes.</p>
<ul class="ugh__grid" role="list"></ul>
<p class="ugh__err" role="alert"></p>
<div class="ugh__undo" hidden><span></span><button type="button">Ongedaan maken</button></div>
<input type="file" class="ugh__file" id="ugh-file" accept="image/jpeg,image/png,image/webp" multiple hidden>
<p class="ugh__sr" aria-live="assertive"></p>
</div>
</div>/* Foto's uploaden en ordenen · NedDev · eigen werk. */
.ugh{--ink:#0a0a0a;--pop:#F5480F;--pd:#b83a0b;--mut:#5b5b5b;--line:rgba(10,10,10,.14);position:absolute;inset:0;overflow:auto;padding:10px 12px;font-family:'Switzer',system-ui,sans-serif;font-size:12.5px;line-height:1.35;color:var(--ink);background:#fff;scrollbar-width:thin;text-align:left}
.ugh *,.ugh *::before,.ugh *::after{box-sizing:border-box}
.ugh svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.ugh__sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);margin:0}
.ugh__head{display:flex;align-items:baseline;justify-content:space-between;gap:8px}
.ugh__h{margin:0;font-size:14px;font-weight:600}
.ugh__n{font-size:11.5px;color:var(--mut);font-variant-numeric:tabular-nums}
.ugh__help{margin:2px 0 8px;font-size:11.5px;color:var(--mut)}
.ugh__grid{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(86px,1fr));gap:8px}
.ugh__it{position:relative;display:flex;flex-direction:column;border:1px solid var(--line);border-radius:4px;background:#fff;overflow:hidden;transition:border-color .15s,opacity .15s}
.ugh__it.is-main{border-color:var(--ink)}
.ugh__it.is-drag{opacity:.45;outline:2px dashed var(--ink);outline-offset:1px}
.ugh__it.is-grab{outline:2px solid var(--pop);outline-offset:1px}
.ugh__img{display:block;width:100%;aspect-ratio:4/3;object-fit:cover;background:#f4f3f1}
.ugh__main{position:absolute;left:4px;top:4px;padding:1px 5px;border-radius:4px;background:var(--ink);color:#fff;font-size:11px;font-weight:600}
.ugh__bar{display:flex;align-items:center;justify-content:space-between;padding:2px}
.ugh__b{display:grid;place-items:center;width:26px;height:26px;padding:0;border:0;border-radius:4px;background:none;color:var(--ink);cursor:pointer;transition:background-color .15s}
.ugh__b:hover{background:#f4f3f1}
.ugh__b:disabled{color:#b5b2ad;cursor:default}
.ugh__b:disabled:hover{background:none}
.ugh__grip{cursor:grab;touch-action:none}
.ugh__grip:active{cursor:grabbing}
.ugh__b[aria-pressed="true"]{background:var(--ink);color:#fff}
.ugh__add{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;min-height:98px;width:100%;height:100%;padding:8px;border:1px dashed rgba(10,10,10,.4);border-radius:4px;background:#f4f3f1;color:var(--ink);font:500 12px/1.25 'Switzer',system-ui,sans-serif;text-align:center;cursor:pointer;transition:border-color .15s,background-color .15s}
.ugh__add:hover,.ugh.is-over .ugh__add{border-color:var(--ink);background:#ecebe8}
.ugh__add small{font-size:11px;color:var(--mut)}
.ugh__add svg{width:18px;height:18px}
.ugh__empty{grid-column:1/-1}
.ugh__empty .ugh__add{min-height:110px}
.ugh__load{display:grid;place-items:center;aspect-ratio:4/3;background:linear-gradient(90deg,#f4f3f1,#ecebe8,#f4f3f1) 0 0/200% 100%;animation:ugh-sh 1.2s linear infinite;font-size:11px;color:var(--mut)}
@keyframes ugh-sh{to{background-position:-200% 0}}
.ugh__err{margin:8px 0 0;color:var(--pd);font-size:11.5px;font-weight:500}
.ugh__err:empty{display:none}
.ugh__undo{display:flex;align-items:center;gap:10px;margin-top:8px;padding:6px 6px 6px 10px;border-radius:4px;background:var(--ink);color:#fff;font-size:12px}
.ugh__undo[hidden]{display:none}
.ugh__undo span{flex:1;min-width:0}
.ugh__undo button{height:24px;padding:0 8px;border:1px solid rgba(255,255,255,.6);border-radius:4px;background:none;color:#fff;font:600 11.5px/1 'Switzer',system-ui,sans-serif;cursor:pointer}
.ugh__undo button:hover{background:#fff;color:var(--ink)}
.ugh button:focus-visible,.ugh__add:focus-visible{outline:2px solid var(--pop);outline-offset:1px}
@media (prefers-reduced-motion:reduce){.ugh *{transition:none!important}.ugh__load{animation:none}}// Foto's uploaden en ordenen · NedDev · eigen werk.
// root = het element waarin de HTML staat
function init(root){
const w = root.querySelector('.ugh'); if (!w) return;
const grid = w.querySelector('.ugh__grid'), file = w.querySelector('.ugh__file'), err = w.querySelector('.ugh__err');
const count = w.querySelector('.ugh__n'), sr = w.querySelector('.ugh__sr'), undoBar = w.querySelector('.ugh__undo');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const MAX = 8, MB = 10;
const src = n => 'https://neddev.com/lab-media/uploadgalerij-herordenen-' + n + '.webp';
let P = [{ id: 'a', src: src(4), alt: 'Voorgevel' }, { id: 'b', src: src(1), alt: 'Woonkamer' }, { id: 'c', src: src(2), alt: 'Keuken' }, { id: 'd', src: src(3), alt: 'Achtertuin' }];
let loading = 0, grab = null, undo = null, undoT = 0, uid = 0;
const I = {
grip: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="6" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="18" r="1"/></svg>',
main: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 11 12 4l8 7v9h-5v-6H9v6H4z"/></svg>',
del: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7h14M10 7V4h4v3M7 7l1 13h8l1-13"/></svg>',
add: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>'
};
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
const say = t => { sr.textContent = ''; setTimeout(() => { sr.textContent = t; }, 20); };
const vt = fn => { if (document.startViewTransition && !reduce) document.startViewTransition(fn); else fn(); };
function render(focusSel) {
count.textContent = P.length + ' van ' + MAX;
let h = P.map((p, i) => '<li class="ugh__it' + (i === 0 ? ' is-main' : '') + (grab && grab.id === p.id ? ' is-grab' : '') + '" data-id="' + p.id + '" style="view-transition-name:ugh-' + p.id + '">' +
'<img class="ugh__img" src="' + p.src + '" alt="' + esc(p.alt) + '" draggable="false">' + (i === 0 ? '<span class="ugh__main">Hoofdfoto</span>' : '') +
'<div class="ugh__bar"><button type="button" class="ugh__b ugh__grip" aria-describedby="ugh-help" aria-pressed="' + !!(grab && grab.id === p.id) + '" aria-label="Verplaats ' + esc(p.alt) + ', positie ' + (i + 1) + ' van ' + P.length + '" title="Verplaatsen">' + I.grip + '</button>' +
'<button type="button" class="ugh__b ugh__mk" aria-label="Maak ' + esc(p.alt) + ' de hoofdfoto" title="Hoofdfoto"' + (i === 0 ? ' disabled' : '') + '>' + I.main + '</button>' +
'<button type="button" class="ugh__b ugh__del" aria-label="Verwijder ' + esc(p.alt) + '" title="Verwijderen">' + I.del + '</button></div></li>').join('');
for (let k = 0; k < loading; k++) h += '<li class="ugh__it"><div class="ugh__load">Bezig met laden</div></li>';
const addBtn = '<label class="ugh__add" for="ugh-file" tabindex="0" role="button">' + I.add + '<span>' + (P.length ? 'Foto toevoegen' : 'Nog geen foto\'s. Voeg er minimaal 1 toe') + '</span><small>JPG, PNG of WebP, max ' + MB + ' MB</small></label>';
if (P.length + loading < MAX) h += P.length ? '<li>' + addBtn + '</li>' : '<li class="ugh__empty">' + addBtn + '</li>';
grid.innerHTML = h;
if (focusSel) { const f = grid.querySelector(focusSel); if (f) f.focus(); }
}
function move(id, to) {
const from = P.findIndex(p => p.id === id); if (from < 0 || to < 0 || to >= P.length || to === from) return false;
const [x] = P.splice(from, 1); P.splice(to, 0, x); return true;
}
function del(id) {
const i = P.findIndex(p => p.id === id); if (i < 0) return;
const [x] = P.splice(i, 1); undo = { x, i };
vt(() => {
render();
const next = grid.querySelectorAll('.ugh__grip')[Math.min(i, P.length - 1)];
(next || grid.querySelector('.ugh__add')).focus();
});
undoBar.querySelector('span').textContent = '“' + x.alt + '” verwijderd.'; undoBar.hidden = false;
say(x.alt + ' verwijderd. Ongedaan maken staat onder de foto\'s.');
clearTimeout(undoT); undoT = setTimeout(() => { undoBar.hidden = true; undo = null; }, 8000);
}
undoBar.querySelector('button').addEventListener('click', () => {
if (!undo) return; P.splice(undo.i, 0, undo.x); const id = undo.x.id; say(undo.x.alt + ' teruggezet op positie ' + (undo.i + 1));
undo = null; undoBar.hidden = true; clearTimeout(undoT);
vt(() => render('[data-id="' + id + '"] .ugh__grip'));
});
grid.addEventListener('click', e => {
const li = e.target.closest('.ugh__it'); if (!li || !li.dataset.id) return;
const id = li.dataset.id, p = P.find(x => x.id === id);
if (e.target.closest('.ugh__del')) { del(id); return; }
if (e.target.closest('.ugh__mk')) { move(id, 0); vt(() => render('[data-id="' + id + '"] .ugh__grip')); say(p.alt + ' is nu de hoofdfoto'); return; }
if (e.target.closest('.ugh__grip') && e.detail === 0) toggleGrab(id);
});
function toggleGrab(id) {
const p = P.find(x => x.id === id), i = P.indexOf(p);
if (grab && grab.id === id) { grab = null; render('[data-id="' + id + '"] .ugh__grip'); say(p.alt + ' neergezet op positie ' + (i + 1) + ' van ' + P.length); }
else { grab = { id, start: i }; render('[data-id="' + id + '"] .ugh__grip'); say(p.alt + ' opgepakt, positie ' + (i + 1) + ' van ' + P.length + '. Gebruik de pijltjes, Spatie om neer te zetten, Esc om te annuleren.'); }
}
grid.addEventListener('keydown', e => {
const g = e.target.closest('.ugh__grip'); if (!g) return;
const id = g.closest('.ugh__it').dataset.id, p = P.find(x => x.id === id);
if (!grab || grab.id !== id) return;
const i = P.indexOf(p);
if (/Arrow(Left|Up|Right|Down)/.test(e.key)) {
e.preventDefault();
const to = i + (/Left|Up/.test(e.key) ? -1 : 1);
if (move(id, to)) { vt(() => render('[data-id="' + id + '"] .ugh__grip')); say('Positie ' + (to + 1) + ' van ' + P.length + (to === 0 ? ', hoofdfoto' : '')); }
} else if (e.key === 'Escape') {
e.preventDefault(); move(id, grab.start); const s = grab.start; grab = null; vt(() => render('[data-id="' + id + '"] .ugh__grip')); say('Verplaatsen geannuleerd, terug op positie ' + (s + 1));
}
});
grid.addEventListener('focusout', e => {
if (!grab || !e.target.closest('.ugh__grip')) return;
const li = e.target.closest('.ugh__it');
if (li && li.dataset.id === grab.id && !(e.relatedTarget && li.contains(e.relatedTarget) && e.relatedTarget.classList.contains('ugh__grip'))) {
if (!document.hasFocus() || !e.relatedTarget) return;
grab = null; li.classList.remove('is-grab'); e.target.setAttribute('aria-pressed', 'false');
}
});
// Slepen met muis of vinger: de rij wordt live omgezet, bij loslaten opgeslagen.
let drag = null;
grid.addEventListener('pointerdown', e => {
const g = e.target.closest('.ugh__grip'); if (!g || e.button > 0) return;
e.preventDefault();
const li = g.closest('.ugh__it'); drag = { id: li.dataset.id, li, moved: false, x: e.clientX, y: e.clientY };
});
document.addEventListener('pointermove', e => {
if (!drag) return;
if (!drag.moved && Math.hypot(e.clientX - drag.x, e.clientY - drag.y) < 5) return;
drag.moved = true; drag.li.classList.add('is-drag');
const over = document.elementFromPoint(e.clientX, e.clientY), li = over && over.closest('.ugh__it');
if (!li || li === drag.li || !li.dataset.id || li.parentNode !== grid) return;
const items = [...grid.querySelectorAll('.ugh__it[data-id]')];
const a = items.indexOf(drag.li), b = items.indexOf(li);
grid.insertBefore(drag.li, a < b ? li.nextSibling : li);
});
document.addEventListener('pointerup', () => {
if (!drag) return;
const d = drag; drag = null;
if (!d.moved) { const g = d.li.querySelector('.ugh__grip'); if (g) g.focus(); return; }
const order = [...grid.querySelectorAll('.ugh__it[data-id]')].map(li => li.dataset.id);
P = order.map(id => P.find(p => p.id === id));
const i = order.indexOf(d.id); render('[data-id="' + d.id + '"] .ugh__grip');
say(P[i].alt + ' verplaatst naar positie ' + (i + 1) + (i === 0 ? ', nu de hoofdfoto' : ''));
});
// Toevoegen
function addFiles(list) {
err.textContent = '';
const msgs = [];
[...list].forEach(f => {
if (!/^image\/(jpeg|png|webp)$/.test(f.type)) { msgs.push(f.name + ' is geen foto (JPG, PNG of WebP).'); return; }
if (f.size > MB * 1048576) { msgs.push(f.name + ' is groter dan ' + MB + ' MB.'); return; }
if (P.length + loading >= MAX) { msgs.push('Maximaal ' + MAX + ' foto\'s; ' + f.name + ' is niet toegevoegd.'); return; }
loading++;
const r = new FileReader();
r.onload = () => { loading--; P.push({ id: 'u' + (++uid), src: r.result, alt: f.name.replace(/\.[^.]+$/, '') }); render(); say(f.name + ' toegevoegd, positie ' + P.length); };
r.onerror = () => { loading--; err.textContent = f.name + ' kon niet worden gelezen. Probeer het opnieuw.'; render(); };
r.readAsDataURL(f);
});
render();
if (msgs.length) err.textContent = msgs.join(' ');
}
file.addEventListener('change', () => { addFiles(file.files); file.value = ''; });
grid.addEventListener('keydown', e => { const a = e.target.closest('.ugh__add'); if (a && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); file.click(); } });
w.addEventListener('dragover', e => { if (e.dataTransfer && [...e.dataTransfer.types].includes('Files')) { e.preventDefault(); w.classList.add('is-over'); } });
w.addEventListener('dragleave', e => { if (!w.contains(e.relatedTarget)) w.classList.remove('is-over'); });
w.addEventListener('drop', e => { e.preventDefault(); w.classList.remove('is-over'); if (e.dataTransfer && e.dataTransfer.files.length) addFiles(e.dataTransfer.files); });
render();
}
init(document.querySelector('.jouw-container'));
More forms & inputs
Seen something you like?
Add it to your selection and send it over. We build your site around it, in your brand.
Get in touch →