{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropzone",
  "type": "registry:block",
  "title": "Dropzone",
  "description": "A file dropzone with a light that follows the pointer while you drag, a marching lit edge and a zone that lifts to meet the files. Dropped files fly in as rows with image previews, type badges, per-file progress, a drawn tick when done and clear error rows for files that are too large.",
  "docs": "Dropzone was added to public/vividsites/dropzone.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/dropzone",
  "categories": [
    "ui",
    "forms"
  ],
  "files": [
    {
      "path": "components/vividsites/dropzone.html",
      "type": "registry:file",
      "target": "public/vividsites/dropzone.html",
      "content": "<!--\nvs-dropzone: a file dropzone that feels physical. Files dragged anywhere over the page arm\n  the zone; over it, a light follows the pointer, the dashed edge lights up near the cursor\n  and marches, the zone lifts and breathes and the little card stack fans open. Dropped files\n  fly in as rows on springs: image previews (object URLs, revoked on remove), type badges for\n  everything else, a per-row upload bar with a live percentage and a tick that draws on, and\n  error rows for files that are too large or of the wrong type. Removing a row collapses it\n  and the rows below spring up into place (FLIP, transform only).\n\n  Markup: the block below. Put your own words in .vs-dropzone-t1 / -t2; leave .vs-dropzone-hint\n  empty to have it written from data-accept + data-max-size.\n  Attributes (on .vs-dropzone):\n    data-accept     like <input accept>: \"image/*,.pdf,.zip\" (empty = any file)\n    data-max-size   per-file limit: \"12MB\", \"500KB\", \"1GB\" (empty = no limit)\n    data-max-files  optional cap on accepted files\n    data-disabled   dims the zone and ignores drops, clicks and keys\n  Keyboard: the zone is a button: Enter or Space opens the file picker; Ctrl/Cmd+V pastes\n    files from the clipboard while it has focus. Every row has a real remove button.\n  Variables (set on .vs-dropzone): --vs-dropzone-accent, -on-accent, -ink, -mute, -surface,\n    -row, -line, -error, -radius, -height (zone), -size (base font size; everything is in em),\n    -font, -mono, -speed (simulated upload speed multiplier).\n  JS:\n    el.addFiles(File[])   same path as a drop or the picker (handy for tests and demos)\n    el.clear()            animates every row out\n    el.files              the accepted files still in the list\n    el.vsUpload = (file, report) => Promise   plug in a real upload: call report(0..1) as it\n                          goes, resolve when done, reject(Error('message')) to fail the row.\n                          Without it, uploads are simulated.\n  Events (bubble): \"vs-dropzone-add\" {files, rejected}, \"vs-dropzone-complete\" {file},\n    \"vs-dropzone-reject\" {file, reason}, \"vs-dropzone-remove\" {file}.\n  Reduced motion: no springs, marching or breathing; states still change.\n-->\n<style>\n.vs-dropzone {\n  --vs-dropzone-accent: #d4ff3a;                  /* glow, lit edge, bars, ticks */\n  --vs-dropzone-on-accent: #10160a;               /* ink on the accent */\n  --vs-dropzone-ink: #e6eeef;\n  --vs-dropzone-mute: rgba(214,230,233,.54);\n  --vs-dropzone-accent-ink: var(--vs-dropzone-accent); /* accent used as text (\"browse\", \"Uploaded\") */\n  --vs-dropzone-surface: #10181a;                 /* zone fill */\n  --vs-dropzone-row: #131c1e;                     /* row fill */\n  --vs-dropzone-line: rgba(200,230,235,.16);      /* hairlines + resting dashes */\n  --vs-dropzone-error: #ff7a64;\n  --vs-dropzone-radius: 18px;\n  --vs-dropzone-height: 14em;\n  --vs-dropzone-size: 15px;\n  --vs-dropzone-font: inherit;\n  --vs-dropzone-mono: ui-monospace, \"SF Mono\", Menlo, Consolas, monospace;\n  --vs-dropzone-speed: 1;\n\n  position: relative; width: 100%; max-width: 40em; font-size: var(--vs-dropzone-size);\n  font-family: var(--vs-dropzone-font); color: var(--vs-dropzone-ink); -webkit-font-smoothing: antialiased;\n}\n.vs-dropzone *, .vs-dropzone *::before, .vs-dropzone *::after { box-sizing: border-box; }\n/* light surfaces: data-theme=\"light\" */\n.vs-dropzone[data-theme=\"light\"] {\n  --vs-dropzone-accent: #c6f432; --vs-dropzone-accent-ink: #4f6a00; --vs-dropzone-on-accent: #1a2300;\n  --vs-dropzone-ink: #121a1c; --vs-dropzone-mute: rgba(18,26,28,.56); --vs-dropzone-surface: #f7f9f8; --vs-dropzone-row: #ffffff;\n  --vs-dropzone-line: rgba(18,26,28,.17); --vs-dropzone-error: #e0482f;\n}\n.vs-dropzone[data-theme=\"light\"] .vs-dropzone-glow { mix-blend-mode: multiply; }\n.vs-dropzone[data-theme=\"light\"] .vs-dropzone-lit rect { stroke: color-mix(in srgb, var(--vs-dropzone-accent), #000 30%); }\n.vs-dropzone[data-theme=\"light\"] .vs-dropzone-card { background: linear-gradient(180deg, #fff, #f1f4f3); box-shadow: inset 0 0 0 .5px rgba(18,26,28,.16), 0 6px 14px -6px rgba(18,26,28,.25); }\n.vs-dropzone[data-theme=\"light\"] .vs-dropzone-card:nth-child(3) { background: linear-gradient(180deg, #1a2426, #10181a); }\n.vs-dropzone[data-theme=\"light\"] .vs-dropzone-row { box-shadow: inset 0 0 0 .5px var(--vs-dropzone-line), 0 1px 2px rgba(18,26,28,.06), 0 10px 24px -16px rgba(18,26,28,.35); }\n.vs-dropzone[data-theme=\"light\"] .vs-dropzone-thumb { background: #eef2f1; }\n.vs-dropzone[data-theme=\"light\"] .vs-dropzone-bar { background: rgba(18,26,28,.08); }\n.vs-dropzone[data-theme=\"light\"] .vs-dropzone-fill { background: color-mix(in srgb, var(--vs-dropzone-accent), #000 12%); box-shadow: none; }\n.vs-dropzone-zone {\n  --vs-dropzone-lift: 0;\n  position: relative; display: grid; justify-items: center; align-content: center; gap: .95em;\n  min-height: var(--vs-dropzone-height); padding: 2em 1.5em; border-radius: var(--vs-dropzone-radius);\n  background: var(--vs-dropzone-surface); cursor: pointer; outline: none; isolation: isolate;\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.035);\n  -webkit-tap-highlight-color: transparent; will-change: transform; user-select: none; -webkit-user-select: none;\n  transition: background-color .35s cubic-bezier(.2,0,0,1);\n}\n.vs-dropzone-zone > * { pointer-events: none; }\n/* the soft shadow that grows as the zone lifts */\n.vs-dropzone-zone::before {\n  content: \"\"; position: absolute; inset: 6% 4% -4%; z-index: -2; border-radius: inherit; pointer-events: none;\n  background: color-mix(in srgb, var(--vs-dropzone-accent) 10%, #000); filter: blur(28px);\n  opacity: calc(var(--vs-dropzone-lift) * .55); transform: translateY(calc(var(--vs-dropzone-lift) * 10px));\n}\n.vs-dropzone-zone::after { /* clip for the glow, with the rounded corners */\n  content: \"\"; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; z-index: -1;\n  background: radial-gradient(120% 90% at 50% 0%, rgba(255,255,255,.035), transparent 60%);\n}\n.vs-dropzone-glow {\n  position: absolute; left: 0; top: 0; width: 26em; height: 26em; margin: -13em 0 0 -13em; z-index: -1; opacity: 0;\n  border-radius: 50%; will-change: transform, opacity; mix-blend-mode: screen;\n  background: radial-gradient(closest-side, color-mix(in srgb, var(--vs-dropzone-accent) 30%, transparent), color-mix(in srgb, var(--vs-dropzone-accent) 9%, transparent) 55%, transparent);\n}\n.vs-dropzone-glowclip { position: absolute; inset: 0; border-radius: inherit; overflow: hidden; z-index: -1; pointer-events: none; }\n.vs-dropzone-edge { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }\n.vs-dropzone-edge rect {\n  x: .75px; y: .75px; width: calc(100% - 1.5px); height: calc(100% - 1.5px);\n  rx: calc(var(--vs-dropzone-radius) - .75px); ry: calc(var(--vs-dropzone-radius) - .75px);\n  fill: none; stroke: var(--vs-dropzone-line); stroke-width: 1.5; stroke-dasharray: 7 6; stroke-linecap: round;\n  transition: stroke .35s cubic-bezier(.2,0,0,1);\n}\n.vs-dropzone-lit { opacity: 0; transition: opacity .35s cubic-bezier(.2,0,0,1);\n  -webkit-mask-image: radial-gradient(15em circle at var(--x, 50%) var(--y, 50%), #000 0, rgba(0,0,0,.28) 100%);\n  mask-image: radial-gradient(15em circle at var(--x, 50%) var(--y, 50%), #000 0, rgba(0,0,0,.28) 100%); }\n.vs-dropzone-lit rect { stroke: var(--vs-dropzone-accent); stroke-width: 1.75; }\n.vs-dropzone.is-armed .vs-dropzone-edge rect { stroke: color-mix(in srgb, var(--vs-dropzone-line), var(--vs-dropzone-accent) 40%); animation: vs-dropzone-march 1.4s linear infinite; }\n.vs-dropzone.is-over .vs-dropzone-edge rect { animation: vs-dropzone-march .55s linear infinite; }\n.vs-dropzone.is-over .vs-dropzone-lit { opacity: 1; }\n.vs-dropzone.is-over .vs-dropzone-zone { background: color-mix(in srgb, var(--vs-dropzone-surface), var(--vs-dropzone-accent) 5%); }\n.vs-dropzone.is-reject .vs-dropzone-lit rect { stroke: var(--vs-dropzone-error); }\n.vs-dropzone.is-reject .vs-dropzone-glow { filter: hue-rotate(-70deg) saturate(.8); }\n@keyframes vs-dropzone-march { to { stroke-dashoffset: -13; } }\n.vs-dropzone-ripple { /* a ring that spreads from where the files land */\n  position: absolute; left: 0; top: 0; width: 18em; height: 18em; margin: -9em 0 0 -9em; border-radius: 50%; pointer-events: none; opacity: 0;\n  box-shadow: inset 0 0 0 1.5px var(--vs-dropzone-accent), 0 0 24px -4px var(--vs-dropzone-accent);\n}\n\n/* the card stack: fans open on hover, wide open with the front card lit while dragging */\n.vs-dropzone-stack { position: relative; width: 3.2em; height: 3.9em; margin-bottom: .4em; }\n.vs-dropzone-card {\n  position: absolute; inset: 0; border-radius: .55em; transform-origin: 50% 110%; will-change: transform;\n  background: linear-gradient(180deg, #223033, #182225);\n  box-shadow: inset 0 0 0 .5px rgba(220,240,244,.22), inset 0 1px 0 rgba(255,255,255,.08), 0 6px 14px -6px rgba(0,0,0,.6);\n  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);\n}\n.vs-dropzone-card:nth-child(3) {\n  display: grid; place-items: center; color: var(--vs-dropzone-accent);\n  background: linear-gradient(180deg, #243033, #182124);\n  box-shadow: inset 0 0 0 .5px rgba(220,240,244,.26), inset 0 1px 0 rgba(255,255,255,.12), 0 10px 22px -8px rgba(0,0,0,.7);\n  transition: background .3s cubic-bezier(.2,0,0,1), color .3s;\n}\n.vs-dropzone-card svg { width: 1.35em; height: 1.35em; }\n.vs-dropzone.is-over .vs-dropzone-card:nth-child(3) { background: var(--vs-dropzone-accent); color: var(--vs-dropzone-on-accent); box-shadow: inset 0 1px 0 rgba(255,255,255,.5), 0 0 0 .5px color-mix(in srgb, var(--vs-dropzone-accent), #000 20%), 0 12px 30px -8px color-mix(in srgb, var(--vs-dropzone-accent) 60%, transparent); }\n.vs-dropzone-title { display: grid; font-size: 1.12em; font-weight: 500; letter-spacing: -.012em; text-align: center; }\n.vs-dropzone-t1, .vs-dropzone-t2 { grid-area: 1 / 1; transition: opacity .28s cubic-bezier(.2,0,0,1), transform .45s cubic-bezier(.34,1.4,.64,1), filter .28s; }\n.vs-dropzone-t1 b { font-weight: 500; color: var(--vs-dropzone-accent-ink); text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: .22em; text-decoration-color: color-mix(in srgb, var(--vs-dropzone-accent-ink) 45%, transparent); }\n.vs-dropzone-t2 { opacity: 0; transform: translateY(.5em); filter: blur(3px); color: var(--vs-dropzone-accent-ink); }\n.vs-dropzone.is-over .vs-dropzone-t1 { opacity: 0; transform: translateY(-.5em); filter: blur(3px); }\n.vs-dropzone.is-over .vs-dropzone-t2 { opacity: 1; transform: none; filter: none; }\n.vs-dropzone.is-reject .vs-dropzone-t2 { color: var(--vs-dropzone-error); }\n.vs-dropzone-hint { font: 400 .74em/1.4 var(--vs-dropzone-mono); letter-spacing: .04em; color: var(--vs-dropzone-mute); text-align: center; }\n.vs-dropzone-input { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; clip: rect(0 0 0 0); }\n.vs-dropzone-zone:focus-visible { box-shadow: inset 0 1px 0 rgba(255,255,255,.035), 0 0 0 2px color-mix(in srgb, var(--vs-dropzone-accent) 85%, transparent), 0 0 0 6px color-mix(in srgb, var(--vs-dropzone-accent) 16%, transparent); }\n.vs-dropzone-zone:active { --vs-dropzone-press: 1; }\n@media (hover: hover) { .vs-dropzone:not(.is-over):not([data-disabled]) .vs-dropzone-zone:hover .vs-dropzone-edge:not(.vs-dropzone-lit) rect { stroke: color-mix(in srgb, var(--vs-dropzone-line), var(--vs-dropzone-ink) 22%); } }\n.vs-dropzone[data-disabled] .vs-dropzone-zone { opacity: .45; cursor: not-allowed; filter: saturate(.3); }\n\n/* rows */\n.vs-dropzone-list { position: relative; list-style: none; margin: 0; padding: 0; display: grid; gap: .5em; }\n.vs-dropzone-list:not(:empty) { margin-top: .85em; }\n.vs-dropzone-row {\n  position: relative; display: grid; grid-template-columns: 3em minmax(0, 1fr) auto; align-items: center; gap: .9em;\n  padding: .6em .55em .6em .6em; border-radius: calc(var(--vs-dropzone-radius) * .7); overflow: hidden;\n  background: var(--vs-dropzone-row); will-change: transform, opacity;\n  box-shadow: inset 0 0 0 .5px var(--vs-dropzone-line), inset 0 1px 0 rgba(255,255,255,.045), 0 1px 2px rgba(0,0,0,.25), 0 10px 24px -14px rgba(0,0,0,.7);\n  transition: box-shadow .4s cubic-bezier(.2,0,0,1), background-color .4s cubic-bezier(.2,0,0,1);\n}\n.vs-dropzone-washclip { /* spans the row up to the end of the bar's track, so the wash never reaches the % or the actions */\n  position: absolute; left: 0; top: 0; bottom: 0; width: var(--vs-dropzone-wash-w, 0); overflow: hidden; z-index: -1; pointer-events: none;\n}\n.vs-dropzone-wash { /* progress as a soft wash whose brightest point trails the bar's end and fades out exactly at it */\n  position: absolute; inset: 0; transform: translateX(-100%); will-change: transform;\n  background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--vs-dropzone-accent) 3%, transparent) 40%, color-mix(in srgb, var(--vs-dropzone-accent) 9%, transparent) calc(100% - 3.5em), transparent);\n  transition: opacity .6s cubic-bezier(.2,0,0,1);\n}\n.vs-dropzone-row { isolation: isolate; }\n.vs-dropzone-thumb {\n  position: relative; width: 3em; height: 3em; border-radius: .6em; overflow: hidden; display: grid; place-items: center;\n  background: rgba(200,230,235,.06); box-shadow: inset 0 0 0 .5px rgba(220,240,244,.16);\n}\n.vs-dropzone-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; opacity: 0; transform: scale(1.15); transition: opacity .45s cubic-bezier(.2,0,0,1), transform .8s cubic-bezier(.16,1,.3,1); }\n.vs-dropzone-thumb.is-loaded img { opacity: 1; transform: none; }\n.vs-dropzone-thumb::after { content: \"\"; position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 0 0 .5px rgba(255,255,255,.14), inset 0 1px 0 rgba(255,255,255,.12); pointer-events: none; }\n.vs-dropzone-badge {\n  --c: #9fb4b8; position: relative; width: 2.1em; height: 2.55em; border-radius: .3em .7em .3em .3em;\n  background: linear-gradient(180deg, color-mix(in srgb, var(--c) 30%, #1b2427), color-mix(in srgb, var(--c) 16%, #141b1d));\n  box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--c) 55%, transparent), inset 0 1px 0 rgba(255,255,255,.1);\n  display: grid; align-items: end; justify-items: center; padding-bottom: .38em;\n  font: 700 .52em/1 var(--vs-dropzone-mono); letter-spacing: .04em; color: color-mix(in srgb, var(--c), #fff 25%);\n}\n.vs-dropzone-badge { font-size: .5em; width: 4.2em; height: 5.1em; }\n.vs-dropzone-badge::before { content: \"\"; position: absolute; right: 0; top: 0; width: 1.3em; height: 1.3em; border-radius: 0 .6em 0 .35em; background: color-mix(in srgb, var(--c) 35%, #0e1416); box-shadow: inset .5px -.5px 0 color-mix(in srgb, var(--c) 55%, transparent); }\n.vs-dropzone-badge[data-k=\"pdf\"] { --c: #ff8a70; } .vs-dropzone-badge[data-k=\"zip\"] { --c: #7fb8ff; }\n.vs-dropzone-badge[data-k=\"sheet\"] { --c: #72dca4; } .vs-dropzone-badge[data-k=\"video\"] { --c: #ffc15c; }\n.vs-dropzone-badge[data-k=\"audio\"] { --c: #6fe0e0; } .vs-dropzone-badge[data-k=\"doc\"] { --c: #a9c6ff; }\n.vs-dropzone-body { min-width: 0; display: grid; gap: .32em; }\n.vs-dropzone-name { font-size: .95em; font-weight: 500; letter-spacing: -.005em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.vs-dropzone-meta { display: flex; gap: .5em; align-items: center; min-width: 0; font: 400 .72em/1.2 var(--vs-dropzone-mono); color: var(--vs-dropzone-mute); white-space: nowrap; }\n.vs-dropzone-meta i { font-style: normal; opacity: .5; }\n.vs-dropzone-state { overflow: hidden; text-overflow: ellipsis; }\n.vs-dropzone-bar { position: relative; height: 3px; border-radius: 3px; background: rgba(200,230,235,.08); overflow: hidden; margin-top: .15em; transition: opacity .5s cubic-bezier(.2,0,0,1) .25s; }\n.vs-dropzone-fill { position: absolute; inset: 0; border-radius: inherit; transform-origin: 0 50%; transform: scaleX(0); background: var(--vs-dropzone-accent); box-shadow: 0 0 10px color-mix(in srgb, var(--vs-dropzone-accent) 60%, transparent); overflow: hidden; }\n.vs-dropzone-fill::after { content: \"\"; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,.75), transparent); transform: translateX(-100%); animation: vs-dropzone-sheen 1.3s cubic-bezier(.45,0,.55,1) infinite; }\n@keyframes vs-dropzone-sheen { to { transform: translateX(100%); } }\n.vs-dropzone-side { display: flex; align-items: center; gap: .2em; }\n.vs-dropzone-status { position: relative; width: 3.1em; height: 1.7em; display: grid; place-items: center end; }\n.vs-dropzone-pct { font: 400 .78em/1 var(--vs-dropzone-mono); font-variant-numeric: tabular-nums; color: var(--vs-dropzone-ink); transition: opacity .12s cubic-bezier(.2,0,0,1), transform .3s cubic-bezier(.2,0,0,1); }\n.vs-dropzone-tick, .vs-dropzone-bang { position: absolute; right: .1em; top: 50%; width: 1.5em; height: 1.5em; margin-top: -.75em; opacity: 0; transform: scale(.4) rotate(-40deg); transition: opacity .2s .08s, transform .55s cubic-bezier(.34,1.7,.5,1) .08s; }\n.vs-dropzone-tick circle { fill: var(--vs-dropzone-accent); }\n.vs-dropzone-tick path { fill: none; stroke: var(--vs-dropzone-on-accent); stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset .38s cubic-bezier(.65,0,.35,1) .18s; }\n.vs-dropzone-bang circle { fill: color-mix(in srgb, var(--vs-dropzone-error) 18%, transparent); stroke: var(--vs-dropzone-error); stroke-width: 1.2; }\n.vs-dropzone-bang path { stroke: var(--vs-dropzone-error); stroke-width: 2.4; stroke-linecap: round; }\n.vs-dropzone-row.is-done .vs-dropzone-pct, .vs-dropzone-row.is-error .vs-dropzone-pct { opacity: 0; transform: translateY(-.4em); }\n.vs-dropzone-row.is-done .vs-dropzone-tick, .vs-dropzone-row.is-error .vs-dropzone-bang { opacity: 1; transform: none; }\n.vs-dropzone-row.is-done .vs-dropzone-tick path { stroke-dashoffset: 0; }\n.vs-dropzone-row.is-done .vs-dropzone-bar { opacity: 0; }\n.vs-dropzone-row.is-done .vs-dropzone-wash { opacity: 0; transition-delay: .5s; }\n.vs-dropzone-row.is-done .vs-dropzone-fill::after, .vs-dropzone-row.is-error .vs-dropzone-fill::after { animation: none; }\n.vs-dropzone-row.is-done .vs-dropzone-thumb::after { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-dropzone-accent) 55%, transparent); }\n.vs-dropzone-row.is-done .vs-dropzone-state { color: var(--vs-dropzone-accent-ink); }\n.vs-dropzone-row.is-error { background: color-mix(in srgb, var(--vs-dropzone-row), var(--vs-dropzone-error) 8%); box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--vs-dropzone-error) 45%, transparent), inset 0 1px 0 rgba(255,255,255,.04), 0 10px 24px -14px rgba(0,0,0,.7); }\n.vs-dropzone-row.is-error .vs-dropzone-state { color: var(--vs-dropzone-error); }\n.vs-dropzone-row.is-error .vs-dropzone-bar { display: none; }\n.vs-dropzone-row.is-error .vs-dropzone-thumb { filter: grayscale(.7) brightness(.8); }\n.vs-dropzone-x {\n  width: 1.9em; height: 1.9em; border: 0; margin: 0; padding: 0; font-size: inherit; border-radius: .55em; background: transparent; color: var(--vs-dropzone-mute);\n  display: grid; place-items: center; cursor: pointer; -webkit-tap-highlight-color: transparent;\n  transition: background-color .18s cubic-bezier(.2,0,0,1), color .18s, transform .3s cubic-bezier(.34,1.56,.64,1);\n}\n.vs-dropzone-x svg { width: .8em; height: .8em; }\n.vs-dropzone-x:active { transform: scale(.88); }\n.vs-dropzone-x:focus-visible { outline: 2px solid var(--vs-dropzone-accent); outline-offset: 1px; color: var(--vs-dropzone-ink); }\n@media (hover: hover) { .vs-dropzone-x:hover { background: rgba(200,230,235,.08); color: var(--vs-dropzone-ink); } }\n.vs-dropzone-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n@media (max-width: 480px) { .vs-dropzone { --vs-dropzone-height: 12em; } .vs-dropzone-row { gap: .7em; } }\n@media (prefers-reduced-motion: reduce) {\n  .vs-dropzone .vs-dropzone-edge rect { animation: none !important; }\n  .vs-dropzone-fill::after { animation: none; opacity: 0; }\n  .vs-dropzone-t1, .vs-dropzone-t2, .vs-dropzone-tick, .vs-dropzone-bang { transition-duration: .01s; }\n}\n@media (forced-colors: active) { .vs-dropzone-edge rect { stroke: CanvasText; } .vs-dropzone-fill { background: Highlight; } }\n</style>\n\n<div class=\"vs-dropzone\" data-accept=\"image/*,.pdf,.zip,.csv,.mov\" data-max-size=\"12MB\">\n  <div class=\"vs-dropzone-zone\" role=\"button\" tabindex=\"0\">\n    <span class=\"vs-dropzone-glow\" aria-hidden=\"true\"></span>\n    <svg class=\"vs-dropzone-edge\" aria-hidden=\"true\"><rect/></svg>\n    <svg class=\"vs-dropzone-edge vs-dropzone-lit\" aria-hidden=\"true\"><rect/></svg>\n    <span class=\"vs-dropzone-stack\" aria-hidden=\"true\">\n      <i class=\"vs-dropzone-card\"></i><i class=\"vs-dropzone-card\"></i>\n      <i class=\"vs-dropzone-card\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 16V5.5M7.5 10 12 5.5l4.5 4.5\"/><path d=\"M6 19h12\"/></svg></i>\n    </span>\n    <span class=\"vs-dropzone-title\">\n      <span class=\"vs-dropzone-t1\">Drop files here or <b>browse</b></span>\n      <span class=\"vs-dropzone-t2\" aria-hidden=\"true\">Release to upload</span>\n    </span>\n    <span class=\"vs-dropzone-hint\"></span>\n    <input class=\"vs-dropzone-input\" type=\"file\" multiple tabindex=\"-1\" aria-hidden=\"true\">\n  </div>\n  <ul class=\"vs-dropzone-list\" aria-label=\"Uploads\"></ul>\n  <p class=\"vs-dropzone-sr\" aria-live=\"polite\"></p>\n</div>\n\n\n<script>\n(() => {\n  if (window.__vsDropzone) return; window.__vsDropzone = true;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const KB = 1024, MB = KB * 1024;\n  const X_ICON = '<svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\" aria-hidden=\"true\"><path d=\"M4 4l8 8M12 4l-8 8\"/></svg>';\n  const TICK = '<svg class=\"vs-dropzone-tick\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"11\"/><path pathLength=\"1\" d=\"M7.2 12.4l3.2 3.2 6.4-6.8\"/></svg>';\n  const BANG = '<svg class=\"vs-dropzone-bang\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10.4\"/><path d=\"M12 7.2v5.6M12 16.4v.1\"/></svg>';\n  const parseSize = (s) => { const m = String(s || '').trim().match(/^([\\d.]+)\\s*(b|kb|mb|gb)?$/i); return m ? +m[1] * ({ b: 1, kb: KB, mb: MB, gb: MB * 1024 })[(m[2] || 'b').toLowerCase()] : Infinity; };\n  const fmt = (n) => n < KB ? n + ' B' : n < MB ? Math.round(n / KB) + ' KB' : (n / MB).toFixed(n < 100 * MB ? 1 : 0) + ' MB';\n  const fmtLimit = (n) => n >= MB ? +(n / MB).toFixed(1) + ' MB' : Math.round(n / KB) + ' KB';\n  const parseAccept = (s) => String(s || '').split(',').map((a) => a.trim().toLowerCase()).filter(Boolean);\n  const mimeOk = (type, rule) => rule.endsWith('/*') ? type.startsWith(rule.slice(0, -1)) : type === rule;\n  const accepts = (f, rules) => !rules.length || rules.some((a) => a[0] === '.' ? f.name.toLowerCase().endsWith(a) : mimeOk((f.type || '').toLowerCase(), a));\n  const kind = (f) => { const e = (f.name.split('.').pop() || '').toLowerCase(), t = f.type || '';\n    if (e === 'pdf') return 'pdf'; if (/^(zip|rar|7z|gz|tar)$/.test(e)) return 'zip'; if (/^(csv|xls|xlsx|numbers|tsv)$/.test(e)) return 'sheet';\n    if (t.startsWith('video/') || /^(mov|mp4|webm|mkv)$/.test(e)) return 'video'; if (t.startsWith('audio/') || /^(mp3|wav|flac|aac)$/.test(e)) return 'audio';\n    if (/^(doc|docx|txt|md|rtf|pages)$/.test(e)) return 'doc'; return 'file'; };\n  const hintFor = (rules, max) => {\n    const names = rules.map((a) => a === 'image/*' ? 'Images' : a === 'video/*' ? 'Video' : a === 'audio/*' ? 'Audio' : a[0] === '.' ? a.slice(1).toUpperCase() : (a.split('/')[1] || a).toUpperCase());\n    return (names.length ? names.join(', ') : 'Any file') + (isFinite(max) ? ' · up to ' + fmtLimit(max) : '');\n  };\n  let uid = 0;\n\n  function mount(root) {\n    if (root.__vsDz) return; root.__vsDz = true;\n    const zone = root.querySelector('.vs-dropzone-zone'), input = root.querySelector('.vs-dropzone-input');\n    const list = root.querySelector('.vs-dropzone-list'), hint = root.querySelector('.vs-dropzone-hint'), sr = root.querySelector('.vs-dropzone-sr');\n    const glow = root.querySelector('.vs-dropzone-glow'), lit = root.querySelector('.vs-dropzone-lit'), cards = [...root.querySelectorAll('.vs-dropzone-card')];\n    const id = 'vs-dropzone-' + (++uid);\n    // the glow lives in a rounded clip so it never spills past the zone's corners\n    const clip = document.createElement('span'); clip.className = 'vs-dropzone-glowclip'; clip.setAttribute('aria-hidden', 'true');\n    zone.insertBefore(clip, glow); clip.appendChild(glow);\n    if (hint) { hint.id = id + '-hint'; zone.setAttribute('aria-describedby', hint.id); }\n    const autoHint = hint && !hint.textContent.trim();\n    const writeHint = () => { if (autoHint) hint.textContent = hintFor(parseAccept(root.dataset.accept), parseSize(root.dataset.maxSize)); };\n    const syncInput = () => { input.accept = root.dataset.accept || ''; zone.setAttribute('aria-disabled', String(root.hasAttribute('data-disabled'))); };\n    writeHint(); syncInput();\n    new MutationObserver(() => { writeHint(); syncInput(); }).observe(root, { attributes: true, attributeFilter: ['data-accept', 'data-max-size', 'data-disabled'] });\n\n    /* ── one rAF loop per instance, alive only while something moves ── */\n    const tasks = new Set(); let raf = 0, last = 0, dirty = false;\n    const frame = (now) => {\n      const dt = Math.min(.05, (now - last) / 1000 || .016); last = now;\n      for (const t of tasks) if (!t(dt, now)) tasks.delete(t);\n      if (dirty) { dirty = false; paintZone(now); }\n      raf = tasks.size ? requestAnimationFrame(frame) : 0;\n    };\n    const run = (t) => { tasks.add(t); if (!raf) { last = performance.now(); raf = requestAnimationFrame(frame); } };\n    // semi-implicit Euler spring, sub-stepped; interruptible (keeps its velocity when retargeted)\n    const spring = (x, k, c, on, eps = .001) => {\n      const s = { x, v: 0, g: x, k, c, done: null };\n      s.step = (dt) => {\n        const n = Math.max(1, Math.ceil(dt * 240)), h = dt / n;\n        for (let i = 0; i < n; i++) { s.v += (-s.k * (s.x - s.g) - s.c * s.v) * h; s.x += s.v * h; }\n        const rest = Math.abs(s.v) < eps * 4 && Math.abs(s.x - s.g) < eps;\n        if (rest) { s.x = s.g; s.v = 0; } on(s.x);\n        if (rest && s.done) { const d = s.done; s.done = null; d(); }\n        return !rest;\n      };\n      s.to = (g, v) => { s.g = g; if (v != null) s.v = v; if (reduce.matches) { s.x = g; s.v = 0; on(g); if (s.done) { const d = s.done; s.done = null; d(); } return s; } run(s.step); return s; };\n      s.set = (v) => { s.x = s.g = v; s.v = 0; on(v); return s; };\n      return s;\n    };\n\n    /* ── the zone: lift, fan, glow position + strength ── */\n    const mark = () => { dirty = true; };\n    const Z = { lift: spring(0, 300, 22, mark), fan: spring(0, 240, 19, mark), glow: spring(0, 180, 26, mark),\n      gx: spring(0, 420, 38, mark, .1), gy: spring(0, 420, 38, mark, .1) };\n    let over = false, hovering = false, depth = 0, breathT = 0;\n    const paintZone = (now) => {\n      const L = Z.lift.x, F = Z.fan.x, b = over && !reduce.matches ? (Math.sin(now / 1000 * Math.PI * 1.25) * .5 + .5) : 0;\n      breathT = b;\n      zone.style.transform = L || b ? `translate3d(0,${(-4 * L).toFixed(2)}px,0) scale(${(1 + .012 * L + .005 * b * L).toFixed(4)})` : '';\n      zone.style.setProperty('--vs-dropzone-lift', Math.max(0, L).toFixed(3));\n      glow.style.opacity = Math.max(0, Math.min(1, Z.glow.x * (.88 + .12 * b))).toFixed(3);\n      glow.style.transform = `translate3d(${Z.gx.x.toFixed(1)}px,${Z.gy.x.toFixed(1)}px,0)`;\n      lit.style.setProperty('--x', Z.gx.x.toFixed(1) + 'px'); lit.style.setProperty('--y', Z.gy.x.toFixed(1) + 'px');\n      cards.forEach((c, i) => {\n        const d = i === 2 ? 0 : i === 0 ? -1 : 1;\n        const rot = d * (5 + 13 * F), tx = d * (4 + 17 * F), ty = (i === 2 ? -9 : -3) * F - (i === 2 ? 2 * b * F : 0);\n        c.style.transform = `translate3d(${tx.toFixed(2)}px,${ty.toFixed(2)}px,0) rotate(${rot.toFixed(2)}deg)`;\n      });\n    };\n    paintZone(0);\n    const breathe = () => { dirty = true; return over; };\n    const aim = (cx, cy, jump) => {\n      const r = zone.getBoundingClientRect(), sx = r.width / zone.offsetWidth || 1;\n      const x = (cx - r.left) / sx, y = (cy - r.top) / sx;\n      if (jump) { Z.gx.set(x); Z.gy.set(y); } else { Z.gx.to(x); Z.gy.to(y); }\n    };\n    const rest = () => { Z.lift.to(over ? 1 : 0); Z.fan.to(over ? 1 : hovering ? .32 : 0); Z.glow.to(over ? 1 : hovering ? .34 : 0); };\n    const disabled = () => root.hasAttribute('data-disabled');\n    const setOver = (on, e) => {\n      if (on === over) return; over = on;\n      root.classList.toggle('is-over', on); if (!on) root.classList.remove('is-reject');\n      if (on && e) aim(e.clientX, e.clientY, Z.glow.x < .05);\n      if (on) run(breathe);\n      rest();\n    };\n    zone.addEventListener('pointerenter', (e) => { if (e.pointerType !== 'mouse' || disabled()) return; hovering = true; aim(e.clientX, e.clientY, Z.glow.x < .05); rest(); });\n    zone.addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse' && !disabled()) aim(e.clientX, e.clientY); });\n    zone.addEventListener('pointerleave', () => { hovering = false; rest(); });\n    zone.addEventListener('pointerdown', (e) => { if (!disabled() && e.button === 0) Z.lift.to(Z.lift.g, -5); });\n\n    // drags: a depth counter survives the enter/leave pairs of child nodes\n    const hasFiles = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files');\n    zone.addEventListener('dragenter', (e) => { if (!hasFiles(e) || disabled()) return; e.preventDefault(); depth++; setOver(true, e); });\n    zone.addEventListener('dragover', (e) => {\n      if (!hasFiles(e) || disabled()) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy';\n      if (!over) { depth = Math.max(1, depth); setOver(true, e); }\n      aim(e.clientX, e.clientY);\n      // mime types are visible during a drag; flag it if none of them can be accepted\n      const rules = parseAccept(root.dataset.accept), items = [...(e.dataTransfer.items || [])].filter((i) => i.kind === 'file');\n      const bad = rules.length && !rules.some((r) => r[0] === '.') && items.length && items.every((i) => i.type && !rules.some((r) => mimeOk(i.type.toLowerCase(), r)));\n      root.classList.toggle('is-reject', !!bad);\n    });\n    zone.addEventListener('dragleave', (e) => { if (!hasFiles(e)) return; depth = Math.max(0, depth - 1); if (!depth) setOver(false); });\n    zone.addEventListener('drop', (e) => {\n      if (!hasFiles(e)) return; e.preventDefault(); depth = 0;\n      const files = [...(e.dataTransfer.files || [])];\n      setOver(false); Z.lift.to(0, -9); // a small press as the files land\n      if (!reduce.matches && !disabled() && files.length) {\n        const r = zone.getBoundingClientRect(), sx = r.width / zone.offsetWidth || 1, rp = document.createElement('span');\n        rp.className = 'vs-dropzone-ripple'; rp.setAttribute('aria-hidden', 'true');\n        rp.style.left = (e.clientX - r.left) / sx + 'px'; rp.style.top = (e.clientY - r.top) / sx + 'px'; clip.appendChild(rp);\n        rp.animate([{ transform: 'scale(.08)', opacity: .9 }, { transform: 'scale(1.6)', opacity: 0 }], { duration: 750, easing: 'cubic-bezier(.16,1,.3,1)' }).onfinish = () => rp.remove();\n      }\n      if (!disabled() && files.length) addFiles(files);\n    });\n    zone.addEventListener('click', () => { if (!disabled()) input.click(); });\n    zone.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && !e.repeat) { e.preventDefault(); if (!disabled()) { Z.lift.to(Z.lift.g, -5); input.click(); } } });\n    zone.addEventListener('paste', (e) => { const f = [...((e.clipboardData && e.clipboardData.files) || [])]; if (f.length && !disabled()) { e.preventDefault(); addFiles(f); } });\n    input.addEventListener('change', () => { const f = [...input.files]; input.value = ''; if (f.length) addFiles(f); });\n\n    /* ── rows ── */\n    const rows = [];\n    const say = (t) => { sr.textContent = ''; setTimeout(() => (sr.textContent = t), 60); };\n    const speed = () => parseFloat(getComputedStyle(root).getPropertyValue('--vs-dropzone-speed')) || 1;\n    function makeRow(file, err) {\n      const li = document.createElement('li'); li.className = 'vs-dropzone-row';\n      const isImg = /^image\\//.test(file.type) && !err;\n      const r = { file, el: li, err, state: err ? 'error' : 'uploading', p: 0, url: null, leaving: false };\n      li.innerHTML = `<span class=\"vs-dropzone-washclip\" aria-hidden=\"true\"><span class=\"vs-dropzone-wash\"></span></span><span class=\"vs-dropzone-thumb\" aria-hidden=\"true\"></span>\n        <span class=\"vs-dropzone-body\"><span class=\"vs-dropzone-name\"></span>\n        <span class=\"vs-dropzone-meta\"><span class=\"vs-dropzone-size\">${fmt(file.size)}</span><i>·</i><span class=\"vs-dropzone-state\"></span></span>\n        <span class=\"vs-dropzone-bar\" aria-hidden=\"true\"><span class=\"vs-dropzone-fill\"></span></span></span>\n        <span class=\"vs-dropzone-side\"><span class=\"vs-dropzone-status\"><span class=\"vs-dropzone-pct\" aria-hidden=\"true\">0%</span>${TICK}${BANG}</span>\n        <button class=\"vs-dropzone-x\" type=\"button\">${X_ICON}</button></span>`;\n      const $ = (s) => li.querySelector(s);\n      $('.vs-dropzone-name').textContent = file.name; li.title = file.name;\n      $('.vs-dropzone-x').setAttribute('aria-label', 'Remove ' + file.name);\n      r.state$ = $('.vs-dropzone-state'); r.pct = $('.vs-dropzone-pct'); r.fill = $('.vs-dropzone-fill'); r.wash = $('.vs-dropzone-wash'); r.track = $('.vs-dropzone-bar'); r.bw = 0;\n      const th = $('.vs-dropzone-thumb');\n      if (isImg) {\n        r.url = URL.createObjectURL(file); const img = new Image(); img.alt = ''; img.decoding = 'async';\n        img.onload = () => { th.classList.add('is-loaded'); const d = document.createElement('span'); d.textContent = img.naturalWidth + ' × ' + img.naturalHeight; const dot = document.createElement('i'); dot.textContent = '·'; $('.vs-dropzone-size').after(dot, d); };\n        img.src = r.url; th.appendChild(img);\n      } else {\n        const b = document.createElement('span'); b.className = 'vs-dropzone-badge'; b.dataset.k = kind(file);\n        b.textContent = ((file.name.includes('.') ? file.name.split('.').pop() : 'file') || 'file').slice(0, 4).toUpperCase(); th.appendChild(b);\n      }\n      r.state$.textContent = err || 'Waiting';\n      if (err) li.classList.add('is-error');\n      li.setAttribute('aria-label', file.name + ', ' + (err || 'uploading'));\n      // motion: y (FLIP + fly-in), o (opacity), s (scale), sh (error shake), bar (displayed progress)\n      let dirtyRow = false;\n      const paint = () => { if (dirtyRow) return; dirtyRow = true; run(flush); };\n      const flush = () => {\n        dirtyRow = false;\n        li.style.transform = `translate3d(${r.sh.x.toFixed(2)}px,${r.y.x.toFixed(2)}px,0) scale(${r.s.x.toFixed(4)})`;\n        li.style.opacity = Math.max(0, Math.min(1, r.o.x)).toFixed(3);\n        const b = Math.max(0, Math.min(1, r.bar.x));\n        r.fill.style.transform = `scaleX(${b.toFixed(4)})`; r.wash.style.transform = `translate3d(${(-(1 - b) * r.bw).toFixed(2)}px,0,0)`;\n        const pc = Math.round(b * 100) + '%'; if (r.pct.textContent !== pc) r.pct.textContent = pc;\n        return false;\n      };\n      r.y = spring(0, 330, 25, paint, .05); r.o = spring(1, 260, 32, paint); r.s = spring(1, 360, 24, paint);\n      r.sh = spring(0, 900, 16, paint, .05); r.bar = spring(0, 150, 22, paint);\n      $('.vs-dropzone-x').addEventListener('click', (e) => { e.stopPropagation(); remove(r, true); });\n      return r;\n    }\n    // the wash clip ends where the bar's track ends; the wash slides by the bar's own width\n    const fit = (r) => { const t = r.track; if (!t.offsetWidth) return; r.bw = t.offsetWidth; r.el.style.setProperty('--vs-dropzone-wash-w', (t.offsetLeft + t.offsetWidth) + 'px'); r.bar.to(r.bar.g); };\n    new ResizeObserver(() => rows.forEach(fit)).observe(list);\n    function upload(r) {\n      if (r.leaving || r.state !== 'uploading') return;\n      r.state$.textContent = 'Uploading';\n      const done = () => {\n        if (r.leaving) return; r.state = 'done'; r.bar.to(1); r.el.classList.add('is-done');\n        r.state$.textContent = 'Uploaded';\n        r.el.setAttribute('aria-label', r.file.name + ', uploaded');\n        if (!reduce.matches) r.el.animate([{ backgroundColor: 'color-mix(in srgb, var(--vs-dropzone-row), var(--vs-dropzone-accent) 12%)' }, {}], { duration: 900, easing: 'cubic-bezier(.2,0,0,1)' });\n        root.dispatchEvent(new CustomEvent('vs-dropzone-complete', { bubbles: true, detail: { file: r.file } }));\n        say(r.file.name + ' uploaded');\n      };\n      const fail = (msg) => { if (r.leaving) return; r.state = 'error'; r.el.classList.add('is-error'); r.state$.textContent = msg; r.sh.to(0, 260); say(r.file.name + ': ' + msg); };\n      if (typeof root.vsUpload === 'function') {\n        Promise.resolve(root.vsUpload(r.file, (p) => { r.p = Math.max(r.p, Math.min(1, p)); r.bar.to(r.p); }))\n          .then(done, (e) => fail((e && e.message) || 'Upload failed'));\n        return;\n      }\n      // simulated: a size-aware duration with an uneven rate, so bars never move in lockstep\n      const dur = Math.min(4.2, Math.max(1.3, 1.1 + r.file.size / (1.8 * MB))) / speed();\n      const a = 1.5 + Math.random() * 2.5, ph = Math.random() * 6.28; let t = 0;\n      run((dt) => {\n        if (r.leaving) return false;\n        t += dt; r.p = Math.min(1, r.p + dt / dur * (.35 + 1.3 * (.5 + .5 * Math.sin(t * a + ph))));\n        r.bar.to(r.p); if (r.p >= 1) { done(); return false; } return true;\n      });\n    }\n    function addFiles(files) {\n      files = [...(files || [])]; if (!files.length) return;\n      const rules = parseAccept(root.dataset.accept), max = parseSize(root.dataset.maxSize), cap = +root.dataset.maxFiles || Infinity;\n      let count = rows.filter((r) => !r.leaving && r.state !== 'error').length;\n      const made = files.map((f) => {\n        let err = null;\n        if (!accepts(f, rules)) err = 'Type not accepted';\n        else if (f.size > max) err = 'Over the ' + fmtLimit(max) + ' limit';\n        else if (count >= cap) err = 'Limit is ' + cap + ' file' + (cap === 1 ? '' : 's');\n        else count++;\n        return makeRow(f, err);\n      });\n      // FLIP: newest batch goes on top, the rows already there spring down to make room\n      const live = rows.filter((r) => !r.leaving), before = live.map((r) => r.el.getBoundingClientRect().top);\n      const frag = document.createDocumentFragment(); made.forEach((r) => frag.appendChild(r.el)); list.prepend(frag);\n      live.forEach((r, i) => { const d = before[i] - r.el.getBoundingClientRect().top; if (Math.abs(d) > .5) { r.y.x += d; r.y.to(0); } });\n      rows.unshift(...made); made.forEach(fit);\n      made.forEach((r, i) => {\n        r.o.set(0); r.y.set(-22 - i * 6); r.s.set(.95);\n        const go = () => { r.o.to(1); r.y.to(0, 60); r.s.to(1); if (r.err) setTimeout(() => r.sh.to(0, 300), 180); else setTimeout(() => upload(r), 160); };\n        reduce.matches ? go() : setTimeout(go, i * 60);\n      });\n      const bad = made.filter((r) => r.err), good = made.length - bad.length;\n      bad.forEach((r) => root.dispatchEvent(new CustomEvent('vs-dropzone-reject', { bubbles: true, detail: { file: r.file, reason: r.err } })));\n      root.dispatchEvent(new CustomEvent('vs-dropzone-add', { bubbles: true, detail: { files: made.filter((r) => !r.err).map((r) => r.file), rejected: bad.map((r) => ({ file: r.file, reason: r.err })) } }));\n      say((good ? good + (good === 1 ? ' file' : ' files') + ' added. ' : '') + bad.map((r) => r.file.name + ': ' + r.err).join('. '));\n    }\n    function remove(r, focusNext) {\n      if (r.leaving) return; r.leaving = true;\n      if (r.url) { URL.revokeObjectURL(r.url); r.url = null; }\n      const i = rows.indexOf(r); rows.splice(i, 1);\n      const live = rows.filter((x) => !x.leaving), before = live.map((x) => x.el.getBoundingClientRect().top);\n      // lift it out of the flow where it stands, then let the rest close the gap\n      const el = r.el; el.style.top = el.offsetTop + 'px'; el.style.left = el.offsetLeft + 'px'; el.style.width = el.offsetWidth + 'px';\n      el.style.position = 'absolute'; el.style.pointerEvents = 'none'; el.setAttribute('aria-hidden', 'true');\n      live.forEach((x, k) => { const d = before[k] - x.el.getBoundingClientRect().top; if (Math.abs(d) > .5) { x.y.x += d; x.y.to(0); } });\n      r.o.done = () => el.remove(); r.o.to(0); r.s.to(.9);\n      if (focusNext) { const nx = rows[i] || rows[i - 1]; (nx ? nx.el.querySelector('.vs-dropzone-x') : zone).focus({ preventScroll: true }); }\n      root.dispatchEvent(new CustomEvent('vs-dropzone-remove', { bubbles: true, detail: { file: r.file } }));\n      say('Removed ' + r.file.name);\n    }\n    function clear() {\n      const all = rows.splice(0);\n      all.forEach((r, k) => {\n        r.leaving = true; if (r.url) { URL.revokeObjectURL(r.url); r.url = null; }\n        const go = () => { r.o.done = () => r.el.remove(); r.o.to(0); r.s.to(.94); r.y.to(-10); };\n        reduce.matches ? go() : setTimeout(go, k * 55);\n      });\n    }\n    root.addFiles = addFiles; root.clear = clear;\n    Object.defineProperty(root, 'files', { get: () => rows.filter((r) => !r.leaving && r.state !== 'error').map((r) => r.file), configurable: true });\n  }\n\n  // files dragged anywhere over the page arm every zone; drops that miss a zone do nothing\n  let wdepth = 0;\n  const armed = (on) => document.querySelectorAll('.vs-dropzone').forEach((r) => r.classList.toggle('is-armed', on && !r.hasAttribute('data-disabled')));\n  const filesIn = (e) => e.dataTransfer && [...e.dataTransfer.types].includes('Files');\n  addEventListener('dragenter', (e) => { if (filesIn(e)) { wdepth++; armed(true); } });\n  addEventListener('dragleave', (e) => { if (filesIn(e) && --wdepth <= 0) { wdepth = 0; armed(false); } });\n  addEventListener('dragover', (e) => { if (filesIn(e) && !(e.target.closest && e.target.closest('.vs-dropzone-zone'))) { e.preventDefault(); e.dataTransfer.dropEffect = 'none'; } });\n  addEventListener('drop', (e) => { if (filesIn(e)) { wdepth = 0; armed(false); if (!(e.target.closest && e.target.closest('.vs-dropzone-zone'))) e.preventDefault(); } });\n  const init = () => document.querySelectorAll('.vs-dropzone').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsDropzone = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Forms",
    "tags": [
      "dropzone",
      "file upload",
      "drag and drop",
      "upload",
      "progress",
      "preview",
      "form",
      "input"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500&family=Space+Mono&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#d4ff3a",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/dropzone/index.html",
    "thumbnail": "https://vividsites.app/api/component/dropzone/thumb.jpg",
    "video": "https://vividsites.app/api/component/dropzone/thumb.mp4",
    "page": "https://vividsites.app/c/dropzone"
  }
}