Open demo

CSS code

HTML
<div><span class="lbl">Dropzone with CSS-only states</span><div class="card">
<!-- method="dialog" keeps this demo on the page; point action/method at your upload endpoint instead. -->
<form class="mz" method="dialog">
  <div class="mz-zone">
    <div class="mz-top">
      <span class="mz-icon" aria-hidden="true"><svg class="up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M12 13v8"/><path d="M4 14.9A7 7 0 1 1 15.7 8h1.8a4.5 4.5 0 0 1 2.5 8.2"/><path d="m8 17 4-4 4 4"/></svg><svg class="ok" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M20 6 9 17l-5-5"/></svg></span>
      <div><label class="mz-title" for="mz-lease">Signed lease agreement</label><p class="mz-hint" id="mz-lease-hint">Drop the PDF in the box below or choose it.</p></div>
      <span class="mz-state" aria-hidden="true"></span>
    </div>
    <input class="mz-file mz-drop" type="file" id="mz-lease" name="lease" accept=".pdf" required aria-describedby="mz-lease-hint mz-lease-msg">
    <p class="mz-msg" id="mz-lease-msg">Choose the signed lease before sending.</p>
  </div>
  <div class="mz-foot"><span class="mz-count">Checked by the browser, no script.</span><button class="mz-send" type="submit">Send to landlord</button></div>
</form></div></div>

<div><span class="lbl">Document checklist with a CSS counter</span><div class="card">
<form class="mz" method="dialog">
  <ul class="mz-docs" aria-label="Documents for your rental application">
    <li class="mz-doc"><span class="mz-tick" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M20 6 9 17l-5-5"/></svg></span>
      <label for="mz-id">Passport or ID card</label><input class="mz-file" type="file" id="mz-id" name="id" accept=".pdf,image/*" required><span class="mz-ring" aria-hidden="true"></span></li>
    <li class="mz-doc"><span class="mz-tick" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M20 6 9 17l-5-5"/></svg></span>
      <label for="mz-address">Proof of address</label><input class="mz-file" type="file" id="mz-address" name="address" accept=".pdf,image/*" required><span class="mz-ring" aria-hidden="true"></span></li>
    <li class="mz-doc"><span class="mz-tick" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M20 6 9 17l-5-5"/></svg></span>
      <label for="mz-income">Last three payslips</label><input class="mz-file" type="file" id="mz-income" name="income" accept=".pdf" multiple required><span class="mz-ring" aria-hidden="true"></span></li>
  </ul>
  <div class="mz-foot"><span class="mz-count total" aria-hidden="true"></span><button class="mz-send" type="submit">Submit application</button></div>
</form></div></div>
CSS
/* CSS-only upload states, no JavaScript. A required file input is :invalid while empty and :valid once a file is
   chosen; :has() lifts that state to the whole zone, :user-invalid adds the error only after a submit attempt,
   and a CSS counter adds up the chosen documents. The native input stays visible and prints the file name itself. */
.mz{--mz-accent:#3b5bdb;--mz-accent-ink:#2f4ac0;--mz-tint:#eef2ff;--mz-ink:#0f172a;--mz-muted:#5b6474;--mz-line:#8391a7;
  --mz-ok:#15803d;--mz-ok-tint:#ecfdf3;--mz-bad:#b42318;--mz-bad-tint:#fef3f2;display:grid;gap:14px;color:var(--mz-ink)}
/* A: header + a large dashed input that is itself the drop target */
.mz-zone{display:grid;gap:14px}
.mz-top{display:flex;align-items:flex-start;gap:14px}
.mz-icon{display:grid;place-items:center;flex:none;width:44px;height:44px;border-radius:12px;color:var(--mz-accent);background:var(--mz-tint);transition:background-color .2s,color .2s}
.mz-icon svg{width:22px;height:22px}
.mz-icon .ok{display:none}
.mz-title{display:block;font-size:1rem;font-weight:700}
.mz-hint{margin:2px 0 0;font-size:.85rem;color:var(--mz-muted)}
.mz-state{margin-left:auto;padding:3px 10px;font-size:.75rem;font-weight:700;white-space:nowrap;border-radius:999px;color:var(--mz-muted);background:#fff;border:1px solid #d0d5dd}
.mz-state::before{content:"Required"}
.mz-file{display:block;width:100%;margin:0;font:inherit;font-size:.9rem;color:#334155;background:none;border:0;cursor:pointer}
.mz-file::file-selector-button{margin-right:14px;padding:9px 16px;font:inherit;font-weight:600;color:#fff;background:var(--mz-accent);border:0;border-radius:10px;cursor:pointer;
  transition:background-color .15s}
.mz-file:hover::file-selector-button{background:var(--mz-accent-ink)}
.mz-drop{padding:22px 18px;background:#f8faff;border:2px dashed var(--mz-line);border-radius:14px;transition:border-color .2s,background-color .2s}
.mz-drop:hover{border-color:var(--mz-accent);background:var(--mz-tint)}
.mz-drop:focus-visible{border-color:var(--mz-accent);outline:3px solid var(--mz-accent);outline-offset:3px}
/* chosen: the required input turns :valid, and :has() passes that up to the icon and the badge */
.mz-drop:valid{border-style:solid;border-color:var(--mz-ok);background:var(--mz-ok-tint)}
.mz-zone:has(.mz-drop:valid) .mz-icon{color:#fff;background:var(--mz-ok)}
.mz-zone:has(.mz-drop:valid) .mz-icon .up{display:none}
.mz-zone:has(.mz-drop:valid) .mz-icon .ok{display:block}
.mz-zone:has(.mz-drop:valid) .mz-state{color:var(--mz-ok);border-color:#abefc6}
.mz-zone:has(.mz-drop:valid) .mz-state::before{content:"Ready to send"}
/* after a submit attempt with nothing chosen */
.mz-msg{display:none;margin:0;font-size:.85rem;font-weight:600;color:var(--mz-bad)}
.mz-drop:user-invalid{border-color:var(--mz-bad);background:var(--mz-bad-tint)}
.mz-zone:has(.mz-drop:user-invalid) .mz-state{color:var(--mz-bad);border-color:#fecdca}
.mz-zone:has(.mz-drop:user-invalid) .mz-state::before{content:"Missing"}
.mz-zone:has(.mz-drop:user-invalid) .mz-msg{display:block}
.mz-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.mz-count{font-size:.85rem;color:var(--mz-muted)}
.mz-send{padding:10px 18px;font:inherit;font-size:.9rem;font-weight:600;color:#fff;background:var(--mz-ink);border:0;border-radius:10px;cursor:pointer;transition:background-color .15s}
.mz-send:hover{background:#1e293b}
.mz-send:focus-visible{outline:3px solid var(--mz-accent);outline-offset:2px}
/* B: a document checklist; a counter adds up the rows whose input is :valid */
.mz-docs{counter-reset:docs;display:grid;gap:10px;margin:0;padding:0;list-style:none}
.mz-doc{display:grid;grid-template-columns:auto 1fr;align-items:center;gap:4px 12px;padding:12px 14px;border:1px solid #d5dbe5;border-radius:12px;transition:border-color .2s,background-color .2s}
.mz-tick{grid-row:span 2;display:grid;place-items:center;width:26px;height:26px;border:2px solid var(--mz-line);border-radius:50%;color:transparent;transition:background-color .2s,border-color .2s}
.mz-tick svg{width:14px;height:14px}
.mz-doc label{font-size:.92rem;font-weight:600}
.mz-doc .mz-file{grid-column:2;margin:0;padding:0;font-size:.82rem}
.mz-doc .mz-file::file-selector-button{padding:6px 12px;margin-right:10px;color:var(--mz-accent-ink);background:var(--mz-tint);border-radius:8px}
.mz-doc .mz-file:hover::file-selector-button{color:#fff;background:var(--mz-accent)}
.mz-doc{position:relative}
/* the row's focus ring is a sibling of the input, so it shows even without :has() */
.mz-ring{position:absolute;inset:-1px;border-radius:12px;pointer-events:none}
.mz-file:focus-visible ~ .mz-ring{outline:3px solid var(--mz-accent);outline-offset:2px}
.mz-doc .mz-file:focus-visible{outline:none}
.mz-doc:has(.mz-file:valid){counter-increment:docs;border-color:#abefc6;background:var(--mz-ok-tint)}
.mz-doc:has(.mz-file:valid) .mz-tick{color:#fff;background:var(--mz-ok);border-color:var(--mz-ok)}
.mz-doc:has(.mz-file:user-invalid){border-color:var(--mz-bad);background:var(--mz-bad-tint)}
.mz-doc:has(.mz-file:user-invalid) .mz-tick{border-color:var(--mz-bad)}
.mz-count.total::before{content:counter(docs) " of 3 documents attached"}
@media (prefers-reduced-motion:reduce){.mz-zone,.mz-icon,.mz-file::file-selector-button,.mz-send,.mz-doc,.mz-tick{transition:none}}

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Dropzone with CSS-only states</span><div class="card demo-card"><div class="card-body">
<!-- method="dialog" keeps this demo on the page; point action/method at your upload endpoint instead. -->
<form class="mz d-grid gap-3" method="dialog">
  <div class="mz-zone d-grid gap-3">
    <div class="d-flex align-items-start gap-3">
      <span class="mz-icon d-grid flex-shrink-0 rounded-3 align-items-center justify-content-center" aria-hidden="true"><svg class="up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M12 13v8"/><path d="M4 14.9A7 7 0 1 1 15.7 8h1.8a4.5 4.5 0 0 1 2.5 8.2"/><path d="m8 17 4-4 4 4"/></svg><svg class="ok" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M20 6 9 17l-5-5"/></svg></span>
      <div><label class="d-block fw-bold" for="mz-lease">Signed lease agreement</label><p class="mz-hint mt-1 mb-0" id="mz-lease-hint">Drop the PDF in the box below or choose it.</p></div>
      <span class="badge rounded-pill ms-auto" aria-hidden="true"></span>
    </div>
    <input class="form-control mz-drop" type="file" id="mz-lease" name="lease" accept=".pdf" required aria-describedby="mz-lease-hint mz-lease-msg">
    <p class="mz-msg mb-0" id="mz-lease-msg">Choose the signed lease before sending.</p>
  </div>
  <div class="d-flex align-items-center justify-content-between flex-wrap gap-3"><span class="mz-count">Checked by the browser, no script.</span><button class="btn btn-dark" type="submit">Send to landlord</button></div>
</form></div></div></div>

<div><span class="lbl">Document checklist with a CSS counter</span><div class="card demo-card"><div class="card-body">
<form class="mz d-grid gap-3" method="dialog">
  <ul class="mz-docs list-unstyled d-grid mb-0" style="gap:10px" aria-label="Documents for your rental application">
    <li class="mz-doc position-relative d-grid align-items-center"><span class="mz-tick d-grid rounded-circle align-items-center justify-content-center" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M20 6 9 17l-5-5"/></svg></span>
      <label class="fw-semibold" for="mz-id">Passport or ID card</label><input class="form-control" type="file" id="mz-id" name="id" accept=".pdf,image/*" required><span class="mz-ring position-absolute" aria-hidden="true"></span></li>
    <li class="mz-doc position-relative d-grid align-items-center"><span class="mz-tick d-grid rounded-circle align-items-center justify-content-center" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M20 6 9 17l-5-5"/></svg></span>
      <label class="fw-semibold" for="mz-address">Proof of address</label><input class="form-control" type="file" id="mz-address" name="address" accept=".pdf,image/*" required><span class="mz-ring position-absolute" aria-hidden="true"></span></li>
    <li class="mz-doc position-relative d-grid align-items-center"><span class="mz-tick d-grid rounded-circle align-items-center justify-content-center" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M20 6 9 17l-5-5"/></svg></span>
      <label class="fw-semibold" for="mz-income">Last three payslips</label><input class="form-control" type="file" id="mz-income" name="income" accept=".pdf" multiple required><span class="mz-ring position-absolute" aria-hidden="true"></span></li>
  </ul>
  <div class="d-flex align-items-center justify-content-between flex-wrap gap-3"><span class="mz-count total" aria-hidden="true"></span><button class="btn btn-dark" type="submit">Submit application</button></div>
</form></div></div></div>
CSS
/* CSS-only upload states for Bootstrap 5, no JavaScript and no .was-validated class: a required .form-control[type=file]
   is :invalid until a file is chosen, :has() passes that to the badge and icon, :user-invalid marks it after a
   submit attempt, and a CSS counter adds up the chosen documents. */
.mz{--mz-accent:#3b5bdb;--mz-accent-ink:#2f4ac0;--mz-tint:#eef2ff;--mz-muted:#5b6474;--mz-line:#8391a7;--mz-ok:#15803d;--mz-ok-tint:#ecfdf3;
  --mz-bad:#b42318;--mz-bad-tint:#fef3f2;color:#0f172a}
.mz-icon{width:44px;height:44px;color:var(--mz-accent);background:var(--mz-tint);transition:background-color .2s,color .2s}
.mz-icon svg{width:22px;height:22px}
.mz-icon .ok{display:none}
.mz-hint{font-size:.85rem;color:var(--mz-muted)}
.mz .badge{--bs-badge-font-size:.75rem;--bs-badge-padding-x:10px;--bs-badge-padding-y:4px;--bs-badge-color:var(--mz-muted);background:#fff;border:1px solid #d0d5dd}
.mz .badge:empty{display:inline-block}
.mz .badge::before{content:"Required"}
.mz-doc label{font-size:.92rem}
.mz .form-control[type="file"]{font-size:.9rem;color:#334155;cursor:pointer;box-shadow:none;transition:border-color .2s,background-color .2s}
.mz .form-control[type="file"]::file-selector-button{margin:0 14px 0 0;padding:9px 16px;font-weight:600;color:#fff;background:var(--mz-accent);border:0;border-radius:10px;cursor:pointer}
.mz .form-control[type="file"]:hover:not(:disabled)::file-selector-button{background:var(--mz-accent-ink)}
.mz-drop.form-control{padding:22px 18px;background:#f8faff;border:2px dashed var(--mz-line);border-radius:14px}
.mz-drop.form-control:hover{border-color:var(--mz-accent);background:var(--mz-tint)}
.mz-drop.form-control:focus{border-color:var(--mz-accent);background:#f8faff}
.mz-drop.form-control:focus-visible{outline:3px solid var(--mz-accent);outline-offset:3px}
.mz-drop.form-control:valid{border-style:solid;border-color:var(--mz-ok);background:var(--mz-ok-tint)}
.mz-zone:has(.mz-drop:valid) .mz-icon{color:#fff;background:var(--mz-ok)}
.mz-zone:has(.mz-drop:valid) .mz-icon .up{display:none}
.mz-zone:has(.mz-drop:valid) .mz-icon .ok{display:block}
.mz-zone:has(.mz-drop:valid) .badge{--bs-badge-color:var(--mz-ok);border-color:#abefc6}
.mz-zone:has(.mz-drop:valid) .badge::before{content:"Ready to send"}
.mz-msg{display:none;font-size:.85rem;font-weight:600;color:var(--mz-bad)}
.mz-drop.form-control:user-invalid{border-color:var(--mz-bad);background:var(--mz-bad-tint)}
.mz-zone:has(.mz-drop:user-invalid) .badge{--bs-badge-color:var(--mz-bad);border-color:#fecdca}
.mz-zone:has(.mz-drop:user-invalid) .badge::before{content:"Missing"}
.mz-zone:has(.mz-drop:user-invalid) .mz-msg{display:block}
.mz .btn-dark{--bs-btn-bg:#0f172a;--bs-btn-border-color:#0f172a;--bs-btn-hover-bg:#1e293b;--bs-btn-hover-border-color:#1e293b;--bs-btn-padding-x:18px;
  --bs-btn-padding-y:10px;--bs-btn-font-size:.9rem;--bs-btn-font-weight:600;--bs-btn-border-radius:10px}
.mz .btn-dark:focus-visible{outline:3px solid var(--mz-accent);outline-offset:2px;box-shadow:none}
.mz-count{font-size:.85rem;color:var(--mz-muted)}
.mz-docs{counter-reset:docs}
.mz-doc{grid-template-columns:auto 1fr;gap:4px 12px;padding:12px 14px;border:1px solid #d5dbe5;border-radius:12px;transition:border-color .2s,background-color .2s}
.mz-tick{grid-row:span 2;width:26px;height:26px;border:2px solid var(--mz-line);color:transparent;transition:background-color .2s,border-color .2s}
.mz-tick svg{width:14px;height:14px}
.mz-doc .form-control[type="file"]{grid-column:2;padding:0;font-size:.82rem;background:none;border:0}
.mz-doc .form-control[type="file"]::file-selector-button{padding:6px 12px;margin-right:10px;color:var(--mz-accent-ink);background:var(--mz-tint);border-radius:8px}
.mz-doc .form-control[type="file"]:hover:not(:disabled)::file-selector-button{color:#fff;background:var(--mz-accent)}
.mz-doc .form-control[type="file"]:focus-visible{outline:none}
.mz-ring{inset:-1px;border-radius:12px;pointer-events:none}
.mz-doc .form-control:focus-visible ~ .mz-ring{outline:3px solid var(--mz-accent);outline-offset:2px}
.mz-doc:has(.form-control:valid){counter-increment:docs;border-color:#abefc6;background:var(--mz-ok-tint)}
.mz-doc:has(.form-control:valid) .mz-tick{color:#fff;background:var(--mz-ok);border-color:var(--mz-ok)}
.mz-doc:has(.form-control:user-invalid){border-color:var(--mz-bad);background:var(--mz-bad-tint)}
.mz-doc:has(.form-control:user-invalid) .mz-tick{border-color:var(--mz-bad)}
.mz-count.total::before{content:counter(docs) " of 3 documents attached"}
@media (prefers-reduced-motion:reduce){.mz-icon,.mz .form-control,.mz-doc,.mz-tick,.mz .btn{transition:none}}

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-mz-muted uppercase">Dropzone with CSS-only states</span><div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,.05)] max-sm:p-[18px]">
<!-- method="dialog" keeps this demo on the page; point action/method at your upload endpoint instead. -->
<form class="grid gap-3.5" method="dialog">
  <div class="group/zone grid gap-3.5">
    <div class="flex items-start gap-3.5">
      <span class="grid size-11 flex-none place-items-center rounded-xl bg-mz-tint text-mz-accent transition-colors duration-200 group-has-[input:valid]/zone:bg-mz-ok group-has-[input:valid]/zone:text-white motion-reduce:transition-none" aria-hidden="true"><svg class="size-[22px] group-has-[input:valid]/zone:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M12 13v8"/><path d="M4 14.9A7 7 0 1 1 15.7 8h1.8a4.5 4.5 0 0 1 2.5 8.2"/><path d="m8 17 4-4 4 4"/></svg><svg class="hidden size-[22px] group-has-[input:valid]/zone:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M20 6 9 17l-5-5"/></svg></span>
      <div><label class="block font-bold" for="mz-lease">Signed lease agreement</label><p class="mt-0.5 text-[.85rem] text-mz-muted" id="mz-lease-hint">Drop the PDF in the box below or choose it.</p></div>
      <span class="ml-auto rounded-full border border-[#d0d5dd] bg-white px-2.5 py-[3px] text-[.75rem] font-bold whitespace-nowrap text-mz-muted before:content-['Required'] group-has-[input:valid]/zone:border-[#abefc6] group-has-[input:valid]/zone:text-mz-ok group-has-[input:valid]/zone:before:content-['Ready_to_send'] group-has-[input:user-invalid]/zone:border-[#fecdca] group-has-[input:user-invalid]/zone:text-mz-bad group-has-[input:user-invalid]/zone:before:content-['Missing']" aria-hidden="true"></span>
    </div>
    <input class="block w-full cursor-pointer rounded-[14px] border-2 border-dashed border-mz-line bg-[#f8faff] px-[18px] py-[22px] text-[.9rem] text-slate-700 transition-[border-color,background-color] duration-200 [&:invalid:not(:user-invalid):hover]:border-mz-accent [&:invalid:not(:user-invalid):hover]:bg-mz-tint [&:invalid:not(:user-invalid):focus-visible]:border-mz-accent focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-mz-accent valid:border-solid valid:border-mz-ok valid:bg-mz-ok-tint user-invalid:border-mz-bad user-invalid:bg-mz-bad-tint motion-reduce:transition-none file:mr-3.5 file:cursor-pointer file:rounded-[10px] file:border-0 file:bg-mz-accent file:px-4 file:py-[9px] file:font-semibold file:text-white file:transition-colors hover:file:bg-mz-accent-ink motion-reduce:file:transition-none" type="file" id="mz-lease" name="lease" accept=".pdf" required aria-describedby="mz-lease-hint mz-lease-msg">
    <p class="hidden text-[.85rem] font-semibold text-mz-bad group-has-[input:user-invalid]/zone:block" id="mz-lease-msg">Choose the signed lease before sending.</p>
  </div>
  <div class="flex flex-wrap items-center justify-between gap-3"><span class="text-[.85rem] text-mz-muted">Checked by the browser, no script.</span><button class="cursor-pointer rounded-[10px] bg-slate-900 px-[18px] py-2.5 text-[.9rem] font-semibold text-white transition-[background-color] hover:bg-slate-800 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-mz-accent motion-reduce:transition-none" type="submit">Send to landlord</button></div>
</form></div></div>

<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-mz-muted uppercase">Document checklist with a CSS counter</span><div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,.05)] max-sm:p-[18px]">
<form class="grid gap-3.5" method="dialog">
  <ul class="grid gap-2.5 [counter-reset:docs]" aria-label="Documents for your rental application">
    <li class="group relative grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-1 rounded-xl border border-[#d5dbe5] px-3.5 py-3 transition-[border-color,background-color] duration-200 has-[input:valid]:border-[#abefc6] has-[input:valid]:bg-mz-ok-tint has-[input:valid]:[counter-increment:docs] has-[input:user-invalid]:border-mz-bad has-[input:user-invalid]:bg-mz-bad-tint motion-reduce:transition-none"><span class="row-span-2 grid size-[26px] place-items-center rounded-full border-2 border-mz-line text-transparent group-has-[input:valid]:border-mz-ok group-has-[input:valid]:bg-mz-ok group-has-[input:valid]:text-white group-has-[input:user-invalid]:border-mz-bad" aria-hidden="true"><svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M20 6 9 17l-5-5"/></svg></span>
      <label class="text-[.92rem] font-semibold" for="mz-id">Passport or ID card</label><input class="peer col-start-2 block w-full cursor-pointer text-[.82rem] text-slate-700 focus-visible:outline-none file:mr-2.5 file:cursor-pointer file:rounded-lg file:border-0 file:bg-mz-tint file:px-3 file:py-1.5 file:font-semibold file:text-mz-accent-ink hover:file:bg-mz-accent hover:file:text-white" type="file" id="mz-id" name="id" accept=".pdf,image/*" required><span class="pointer-events-none absolute -inset-px rounded-xl peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-mz-accent" aria-hidden="true"></span></li>
    <li class="group relative grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-1 rounded-xl border border-[#d5dbe5] px-3.5 py-3 transition-[border-color,background-color] duration-200 has-[input:valid]:border-[#abefc6] has-[input:valid]:bg-mz-ok-tint has-[input:valid]:[counter-increment:docs] has-[input:user-invalid]:border-mz-bad has-[input:user-invalid]:bg-mz-bad-tint motion-reduce:transition-none"><span class="row-span-2 grid size-[26px] place-items-center rounded-full border-2 border-mz-line text-transparent group-has-[input:valid]:border-mz-ok group-has-[input:valid]:bg-mz-ok group-has-[input:valid]:text-white group-has-[input:user-invalid]:border-mz-bad" aria-hidden="true"><svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M20 6 9 17l-5-5"/></svg></span>
      <label class="text-[.92rem] font-semibold" for="mz-address">Proof of address</label><input class="peer col-start-2 block w-full cursor-pointer text-[.82rem] text-slate-700 focus-visible:outline-none file:mr-2.5 file:cursor-pointer file:rounded-lg file:border-0 file:bg-mz-tint file:px-3 file:py-1.5 file:font-semibold file:text-mz-accent-ink hover:file:bg-mz-accent hover:file:text-white" type="file" id="mz-address" name="address" accept=".pdf,image/*" required><span class="pointer-events-none absolute -inset-px rounded-xl peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-mz-accent" aria-hidden="true"></span></li>
    <li class="group relative grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-1 rounded-xl border border-[#d5dbe5] px-3.5 py-3 transition-[border-color,background-color] duration-200 has-[input:valid]:border-[#abefc6] has-[input:valid]:bg-mz-ok-tint has-[input:valid]:[counter-increment:docs] has-[input:user-invalid]:border-mz-bad has-[input:user-invalid]:bg-mz-bad-tint motion-reduce:transition-none"><span class="row-span-2 grid size-[26px] place-items-center rounded-full border-2 border-mz-line text-transparent group-has-[input:valid]:border-mz-ok group-has-[input:valid]:bg-mz-ok group-has-[input:valid]:text-white group-has-[input:user-invalid]:border-mz-bad" aria-hidden="true"><svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M20 6 9 17l-5-5"/></svg></span>
      <label class="text-[.92rem] font-semibold" for="mz-income">Last three payslips</label><input class="peer col-start-2 block w-full cursor-pointer text-[.82rem] text-slate-700 focus-visible:outline-none file:mr-2.5 file:cursor-pointer file:rounded-lg file:border-0 file:bg-mz-tint file:px-3 file:py-1.5 file:font-semibold file:text-mz-accent-ink hover:file:bg-mz-accent hover:file:text-white" type="file" id="mz-income" name="income" accept=".pdf" multiple required><span class="pointer-events-none absolute -inset-px rounded-xl peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-mz-accent" aria-hidden="true"></span></li>
  </ul>
  <div class="flex flex-wrap items-center justify-between gap-3"><span class="text-[.85rem] text-mz-muted before:content-[counter(docs)_'_of_3_documents_attached']" aria-hidden="true"></span><button class="cursor-pointer rounded-[10px] bg-slate-900 px-[18px] py-2.5 text-[.9rem] font-semibold text-white transition-[background-color] hover:bg-slate-800 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-mz-accent motion-reduce:transition-none" type="submit">Submit application</button></div>
</form></div></div>
CSS
@theme {
  --color-mz-accent: #3b5bdb;
  --color-mz-accent-ink: #2f4ac0;
  --color-mz-tint: #eef2ff;
  --color-mz-muted: #5b6474;
  --color-mz-line: #8391a7;
  --color-mz-ok: #15803d;
  --color-mz-ok-tint: #ecfdf3;
  --color-mz-bad: #b42318;
  --color-mz-bad-tint: #fef3f2;
}

About this set

File upload states that usually need JavaScript, done with HTML validation and modern selectors. Dropzone with CSS-only states has a header with an icon, a title and a Required badge above a large dashed file input that is itself the drop target. The input is required, so it matches :invalid while empty and :valid once a file is chosen; the box turns solid green, and :has() passes the state up so the icon becomes a tick and the badge reads Ready to send. Press Send with nothing chosen and the browser blocks the submit, the input matches :user-invalid, the box turns red, the badge reads Missing and an error line appears. Document checklist with a CSS counter lists three required documents for a rental application; each row gets a tick and a green background when its file is chosen, and a counter incremented by :has(input:valid) prints 2 of 3 documents attached under the list. The native input stays visible and prints the chosen file name itself, and the form’s method=dialog only keeps the demo on the page. CSS cannot react to a file being dragged over the box or check a file’s size, so add a script if you need those.

What’s included

  • No JavaScript in the component
  • Empty, chosen and missing states from :valid and :user-invalid
  • :has() lifts the input state to the icon, badge and row
  • CSS counter that counts attached documents
  • Focus ring drawn by a sibling so it works without :has()

Accessibility

Each input has a visible label, the dropzone is described by its hint and its error line, and the browser's own validation message appears on a submit with a missing file. The native input announces the chosen file name itself, so the CSS badges and counter are marked aria-hidden rather than repeated. Focus rings are drawn on the box or on the whole row, and colour transitions switch off under prefers-reduced-motion.

Customise it

Change --mz-accent, --mz-ok and --mz-bad on .mz for the neutral, chosen and missing colours, and the badge wording in the ::before content rules. Bootstrap styles .form-control:valid without .was-validated; Tailwind uses valid:, user-invalid: and group-has-[input:valid]: variants.