- brief.pdf
Upload
import { useEffect, useId, useRef, useState } from 'react';
type Row = { id: number; name: string; size: string; pct: number };
const css = `
.card-upload { box-sizing: border-box; width: 240px; padding: 14px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-upload__zone { position: relative; display: grid; place-items: center; height: 74px; border-radius: 12px; color: light-dark(#50535b, #c6c8cd); font-size: 11.5px; text-align: center; cursor: pointer; transition: background-color 0.2s; }
.card-upload__zone:hover, .card-upload__zone--over { background: light-dark(#ebedf0, #232529); }
.card-upload__zone:has(:focus-visible) { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.card-upload__edge { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.card-upload__edge rect { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.5; stroke-dasharray: 6 4; }
/* Marching ants while hovered or while a file is dragged over. */
.card-upload__zone:hover .card-upload__edge rect, .card-upload__zone--over .card-upload__edge rect { stroke: light-dark(#42454c, #e5e6e9); animation: card-upload-march 0.6s linear infinite; }
@keyframes card-upload-march {
to { stroke-dashoffset: -10; }
}
.card-upload__zone svg.card-upload__icon { display: block; width: 20px; height: 20px; margin: 0 auto 4px; color: light-dark(#42454c, #e5e6e9); transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1); }
.card-upload__zone--over svg.card-upload__icon { transform: translateY(-3px) scale(1.15); }
.card-upload__zone u { color: light-dark(#373a40, #f1f2f3); text-underline-offset: 2px; }
.card-upload__input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.card-upload__rows { display: grid; gap: 8px; margin: 12px 0 0; padding: 0; list-style: none; }
.card-upload__row { display: grid; grid-template-columns: auto 1fr auto; align-items: center; column-gap: 8px; row-gap: 5px; font-size: 11px; animation: card-upload-in 0.35s cubic-bezier(0.3, 1.3, 0.5, 1); }
@keyframes card-upload-in {
from { opacity: 0; transform: translateY(-6px); }
}
.card-upload__doc { grid-row: span 2; width: 22px; height: 26px; border-radius: 4px; background: light-dark(#dbdde1, #2e3035); }
.card-upload__name { overflow: hidden; color: light-dark(#373a40, #f1f2f3); white-space: nowrap; text-overflow: ellipsis; }
.card-upload__meta { color: light-dark(#5f626a, #a7abb2); font-variant-numeric: tabular-nums; }
.card-upload__bar { grid-column: 2 / 4; height: 3px; border-radius: 2px; background: light-dark(#ebedf0, #232529); overflow: hidden; }
.card-upload__bar span { display: block; height: 100%; background: light-dark(#373a40, #f1f2f3); transition: width 0.15s linear; }
.card-upload__tick { width: 13px; height: 13px; color: light-dark(#373a40, #f1f2f3); }
.card-upload__tick path { stroke-dasharray: 1; animation: card-upload-tick 0.35s ease-out backwards; }
@keyframes card-upload-tick {
from { stroke-dashoffset: 1; }
}
@media (prefers-reduced-motion: reduce) {
.card-upload__zone:hover .card-upload__edge rect, .card-upload__zone--over .card-upload__edge rect, .card-upload__row, .card-upload__tick path { animation: none; }
.card-upload__icon { transition: none; }
}
`;
const size = (b: number) => (b > 1e6 ? `${(b / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(b / 1e3))} KB`);
export function UploadCard() {
const [rows, setRows] = useState<Row[]>([{ id: 0, name: 'brief.pdf', size: '1.2 MB', pct: 100 }]);
const [over, setOver] = useState(false);
const id = useId();
const raf = useRef(0);
useEffect(() => () => cancelAnimationFrame(raf.current), []);
// There is no server here: the progress is simulated, quick at first and slowing towards the end.
const add = (f: File) => {
const row = { id: Date.now(), name: f.name, size: size(f.size), pct: 0 };
setRows((r) => [row, ...r].slice(0, 2));
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return setRows((r) => r.map((o) => (o.id === row.id ? { ...o, pct: 100 } : o)));
const t0 = performance.now();
const step = (now: number) => {
const t = Math.min(1, (now - t0) / 1600);
const pct = Math.round(100 * (1 - (1 - t) ** 2.2));
setRows((r) => r.map((o) => (o.id === row.id ? { ...o, pct } : o)));
if (t < 1) raf.current = requestAnimationFrame(step);
};
raf.current = requestAnimationFrame(step);
};
return (
<>
<style>
{css}
</style>
<article className="card-upload">
<label
className={`card-upload__zone${over ? ' card-upload__zone--over' : ''}`}
htmlFor={id}
onDragEnter={(e) => {
e.preventDefault();
setOver(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
const f = e.dataTransfer.files[0];
if (f) add(f);
}}
>
<svg className="card-upload__edge" aria-hidden="true">
<rect x={0.75} y={0.75} width={210.5} height={72.5} rx={11} />
</svg>
<span>
<svg className="card-upload__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 16V4M7 9l5-5 5 5M5 20h14" />
</svg>
Drop a file or <u>browse</u>
</span>
<input
id={id}
className="card-upload__input"
type="file"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) add(f);
e.target.value = '';
}}
/>
</label>
<ul className="card-upload__rows" aria-live="polite">
{rows.map((r) => (
<li key={r.id} className="card-upload__row">
<span className="card-upload__doc" aria-hidden="true" />
<span className="card-upload__name">{r.name}</span>
{r.pct < 100 ? (
<span className="card-upload__meta">{r.pct}%</span>
) : (
<svg className="card-upload__tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" role="img" aria-label="Uploaded">
<path d="M5 12.5l4.5 4.5L19 7.5" pathLength={1} />
</svg>
)}
<span className="card-upload__bar" aria-hidden="true">
<span style={{ width: `${r.pct}%` }} />
</span>
</li>
))}
</ul>
</article>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardUpload(el) on the mounted element instead.
<article class="card-upload">
<label class="card-upload__zone" for="card-upload-file">
<svg class="card-upload__edge" aria-hidden="true">
<rect x="0.75" y="0.75" width="210.5" height="72.5" rx="11"></rect>
</svg>
<span>
<svg class="card-upload__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 16V4M7 9l5-5 5 5M5 20h14"></path>
</svg>
Drop a file or
<u>browse</u>
</span>
<input id="card-upload-file" class="card-upload__input" type="file"/>
</label>
<ul class="card-upload__rows" aria-live="polite">
<li class="card-upload__row">
<span class="card-upload__doc" aria-hidden="true"></span>
<span class="card-upload__name">brief.pdf</span>
<svg class="card-upload__tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Uploaded">
<path d="M5 12.5l4.5 4.5L19 7.5" pathLength="1"></path>
</svg>
<span class="card-upload__bar" aria-hidden="true">
<span style="width:100%"></span>
</span>
</li>
</ul>
</article>.card-upload { box-sizing: border-box; width: 240px; padding: 14px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-upload__zone { position: relative; display: grid; place-items: center; height: 74px; border-radius: 12px; color: light-dark(#50535b, #c6c8cd); font-size: 11.5px; text-align: center; cursor: pointer; transition: background-color 0.2s; }
.card-upload__zone:hover, .card-upload__zone--over { background: light-dark(#ebedf0, #232529); }
.card-upload__zone:has(:focus-visible) { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.card-upload__edge { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.card-upload__edge rect { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.5; stroke-dasharray: 6 4; }
/* Marching ants while hovered or while a file is dragged over. */
.card-upload__zone:hover .card-upload__edge rect, .card-upload__zone--over .card-upload__edge rect { stroke: light-dark(#42454c, #e5e6e9); animation: card-upload-march 0.6s linear infinite; }
@keyframes card-upload-march {
to { stroke-dashoffset: -10; }
}
.card-upload__zone svg.card-upload__icon { display: block; width: 20px; height: 20px; margin: 0 auto 4px; color: light-dark(#42454c, #e5e6e9); transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1); }
.card-upload__zone--over svg.card-upload__icon { transform: translateY(-3px) scale(1.15); }
.card-upload__zone u { color: light-dark(#373a40, #f1f2f3); text-underline-offset: 2px; }
.card-upload__input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.card-upload__rows { display: grid; gap: 8px; margin: 12px 0 0; padding: 0; list-style: none; }
.card-upload__row { display: grid; grid-template-columns: auto 1fr auto; align-items: center; column-gap: 8px; row-gap: 5px; font-size: 11px; animation: card-upload-in 0.35s cubic-bezier(0.3, 1.3, 0.5, 1); }
@keyframes card-upload-in {
from { opacity: 0; transform: translateY(-6px); }
}
.card-upload__doc { grid-row: span 2; width: 22px; height: 26px; border-radius: 4px; background: light-dark(#dbdde1, #2e3035); }
.card-upload__name { overflow: hidden; color: light-dark(#373a40, #f1f2f3); white-space: nowrap; text-overflow: ellipsis; }
.card-upload__meta { color: light-dark(#5f626a, #a7abb2); font-variant-numeric: tabular-nums; }
.card-upload__bar { grid-column: 2 / 4; height: 3px; border-radius: 2px; background: light-dark(#ebedf0, #232529); overflow: hidden; }
.card-upload__bar span { display: block; height: 100%; background: light-dark(#373a40, #f1f2f3); transition: width 0.15s linear; }
.card-upload__tick { width: 13px; height: 13px; color: light-dark(#373a40, #f1f2f3); }
.card-upload__tick path { stroke-dasharray: 1; animation: card-upload-tick 0.35s ease-out backwards; }
@keyframes card-upload-tick {
from { stroke-dashoffset: 1; }
}
@media (prefers-reduced-motion: reduce) {
.card-upload__zone:hover .card-upload__edge rect, .card-upload__zone--over .card-upload__edge rect, .card-upload__row, .card-upload__tick path { animation: none; }
.card-upload__icon { transition: none; }
}function initCardUpload(root) {
var zone = root.querySelector('.card-upload__zone');
var input = root.querySelector('.card-upload__input');
var list = root.querySelector('.card-upload__rows');
var TICK =
'<svg class="card-upload__tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Uploaded">' +
'<path d="M5 12.5l4.5 4.5L19 7.5" pathLength="1"></path></svg>';
var frames = [];
// There is no server here: the progress is simulated, quick at first and slowing towards the end.
function add(file) {
var row = document.createElement('li');
row.className = 'card-upload__row';
row.innerHTML =
'<span class="card-upload__doc" aria-hidden="true"></span><span class="card-upload__name"></span>' +
'<span class="card-upload__meta">0%</span><span class="card-upload__bar" aria-hidden="true"><span style="width:0%"></span></span>';
row.querySelector('.card-upload__name').textContent = file.name;
list.insertBefore(row, list.firstChild);
while (list.children.length > 2) list.lastElementChild.remove();
var meta = row.querySelector('.card-upload__meta');
var bar = row.querySelector('.card-upload__bar span');
function show(pct) {
bar.style.width = pct + '%';
if (pct < 100) meta.textContent = pct + '%';
else meta.outerHTML = TICK;
}
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return show(100);
var t0 = performance.now();
var slot = frames.length;
function step(now) {
var t = Math.min(1, (now - t0) / 1600);
show(Math.round(100 * (1 - Math.pow(1 - t, 2.2))));
if (t < 1) frames[slot] = requestAnimationFrame(step);
}
frames[slot] = requestAnimationFrame(step);
}
function onEnter(e) {
e.preventDefault();
zone.classList.add('card-upload__zone--over');
}
function onOver(e) {
e.preventDefault();
}
function onLeave() {
zone.classList.remove('card-upload__zone--over');
}
function onDrop(e) {
e.preventDefault();
onLeave();
var file = e.dataTransfer.files[0];
if (file) add(file);
}
function onChange() {
var file = input.files && input.files[0];
if (file) add(file);
input.value = '';
}
zone.addEventListener('dragenter', onEnter);
zone.addEventListener('dragover', onOver);
zone.addEventListener('dragleave', onLeave);
zone.addEventListener('drop', onDrop);
input.addEventListener('change', onChange);
return function () {
frames.forEach(cancelAnimationFrame);
zone.removeEventListener('dragenter', onEnter);
zone.removeEventListener('dragover', onOver);
zone.removeEventListener('dragleave', onLeave);
zone.removeEventListener('drop', onDrop);
input.removeEventListener('change', onChange);
};
}
document.querySelectorAll('.card-upload').forEach(initCardUpload);Goal: Create a "Upload" card.
Visual behavior: A drop zone card: its dashed border marches while you hover or drag a file over it, and a dropped or chosen file gets a row that fills with progress, then ticks.
Technique:
- The border is an SVG rect with a dash pattern; a keyframe moves its dash offset by one dash and gap, so it marches. The icon lifts while a file is over the zone.
- A new row drops in at the top of the list (which keeps two) and its bar fills in requestAnimationFrame on an ease out curve; there is no server in the demo.
- At 100% the figure gives way to a tick that draws in by its dash offset.
Accessibility: The zone is a <label> for a real file input, so clicking it or using the keyboard opens the file picker, with the focus ring on the zone. The rows are a polite live region and the tick is labelled Uploaded. Under prefers-reduced-motion the border holds still and uploads complete at once.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Upload" card.
Visual behavior: A drop zone card: its dashed border marches while you hover or drag a file over it, and a dropped or chosen file gets a row that fills with progress, then ticks.
Technique:
- The border is an SVG rect with a dash pattern; a keyframe moves its dash offset by one dash and gap, so it marches. The icon lifts while a file is over the zone.
- A new row drops in at the top of the list (which keeps two) and its bar fills in requestAnimationFrame on an ease out curve; there is no server in the demo.
- At 100% the figure gives way to a tick that draws in by its dash offset.
Accessibility: The zone is a <label> for a real file input, so clicking it or using the keyboard opens the file picker, with the focus ring on the zone. The rows are a polite live region and the tick is labelled Uploaded. Under prefers-reduced-motion the border holds still and uploads complete at once.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A drop zone card: its dashed border marches while you hover or drag a file over it, and a dropped or chosen file gets a row that fills with progress, then ticks.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- hover, loop, state change, card
- Techniques
- stroke dashoffset, keyframes, raf
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The zone is a <label> for a real file input, so clicking it or using the keyboard opens the file picker, with the focus ring on the zone. The rows are a polite live region and the tick is labelled Uploaded. Under prefers-reduced-motion the border holds still and uploads complete at once.