Drop-zone primitive for gf.cx portfolio surfaces. No server, no upload pipeline — files are base64'd into localStorage and exported as JSON for a companion CLI helper to persist to disk. Depth-1 by design: lift to R2 direct-upload or GitHub-API commit when MVP outgrows the ~5 MB per-file localStorage cap.
Embed
<link rel="stylesheet" href="https://assets.gf.cx/dropzone/dropzone.css">
<script src="https://assets.gf.cx/dropzone/dropzone.js" defer></script>
<div data-dropzone
data-field="40-payouts"
data-context="claim-046414618"
data-accept="image/*,application/pdf"
data-multiple="true"
data-max-mb="5"></div>
Live demo
Drop a file (image or PDF up to 5 MB) onto the zone below. It persists in your browser's localStorage under dropzone:demo:specimen — clear it by reloading or via the trash button rendered after a successful drop.
Attributes
| Attribute | Default | Purpose |
|---|---|---|
data-field | untitled | Form-field name · written into export payload + becomes localStorage key suffix |
data-context | default | Namespace (claim ID, contractor slug, etc.) so two pages can have a replacement_receipt field without colliding |
data-accept | (any) | Comma-separated MIME patterns: image/*,application/pdf,.csv |
data-multiple | false | Accept multiple files |
data-max-mb | 5 | Per-file size cap; over → reject |
Storage shape
Each dropzone writes to localStorage["dropzone:<context>:<field>"] as a JSON array of file records:
[
{
"name": "receipt-2026-05-21.pdf",
"type": "application/pdf",
"size": 1240892,
"dataUrl": "data:application/pdf;base64,JVBERi0xLjQK...",
"ts": "2026-05-21T14:30:00.000Z"
}
]
Export payload
The 📋 Copy JSON for sync button (visible once at least one file has been captured) writes this object to the clipboard:
{
"schema": "dropzone-export.v1",
"context": "claim-046414618",
"field": "40-payouts",
"captured_at": "2026-05-21T14:30:00.000Z",
"files": [
{
"name": "...",
"type": "...",
"size": 1234,
"dataUrl": "data:...;base64,..."
}
]
}
The companion CLI helper at ~/bin/pa_dropzone_apply.py reads this JSON (clipboard or stdin) and writes files to canonical paths.
Events
Each dropzone fires a bubbling dropzone:change event when files are added/removed. The detail payload contains field, context, and files (sans the heavy dataUrl). Host pages can listen to react to drops:
document.addEventListener('dropzone:change', (e) => {
const { field, context, files } = e.detail;
console.log(`${context}/${field}: ${files.length} files`);
});
Styling tokens
The CSS inherits via custom properties. Host pages can override:
| Token | Role |
|---|---|
--accent | Primary border + active state |
--accent-alert | Reject / over-size border |
--line-strong | Idle border |
--ink-soft | Helper-text colour |
Depth
This is Depth 1 — pure client-side, base64 data URLs persisted in localStorage. The practical per-file cap is ~5 MB (image-heavy claims fit fine; high-res scans of larger documents do not).
Upgrade paths when MVP outgrows the cap:
- Depth 2 — R2 direct-upload via a Worker presigned URL
- Depth 3 — GitHub-API direct commit (works for designs/docs that belong in git)
Both sketched in devreports-content/parked_sketch_drag_drop_file_field_pattern_2026-05-21.md.
Cross-references
- patterns.gf.cx — the portable-patterns surface that catalogs why this primitive exists
- cards.css — sibling primitive for the card / card-grid pattern
~/bin/pa_dropzone_apply.py— companion CLI helper (writes captured files to disk)