№ 014

Punch Card

A file uploader built as a card reader — the punched card feeds through exactly as far as the upload has got.

Version
1.0.0
Download
13 KB · 3 files
Added
Oct 26, 2026
Built with
HTML, CSS, JS
Needs
Google Fonts
Tags
file upload, retro, progress

Usage

Copy punch-card.css and punch-card.js (and assets/, if there is one) into your project, then:

  new PunchCard(element, {
    upload: PunchCard.xhr('/api/upload'),   // or any (file, onProgress, signal) => Promise
    accept: ['.pdf', 'image/*'],            // optional: extensions and/or MIME types
    maxSize: 10 * 1024 * 1024,              // optional, bytes
    onComplete(file, response) {},
    onError(file, error) {},
  })

A file uploader built as a card reader. Drop a file (or browse) and it becomes a perforated data card,
which the machine takes in. The card feeds through the reader exactly as far as the upload has really
got: it waits when the transfer stalls and is only drawn fully inside once the upload succeeds.
onProgress(fraction) reports progress; call onProgress(null) when the size is unknown, and the reader
shows an indeterminate PROCESSING state instead of a percentage.
The perforations are a pattern drawn from the file's name, size and type: a fingerprint, not its contents.

Source

/* Punch Card — a file uploader built as a card reader. Laid out on a 460-unit grid (--u),
   so the machine scales as one piece with the width of its container. */

.pc {
  --pc-ivory: #eae3d3;
  --pc-ivory-lt: #f3ede1;
  --pc-ivory-dk: #d8ccb6;
  --pc-ivory-dk2: #c6b89e;
  --pc-paper: #f4e8d4;
  --pc-paper-line: #ddcbb2;
  --pc-charcoal: #252c30;
  --pc-recess: #3d4245;
  --pc-amber: #e6a04b;
  --pc-amber-lt: #f3c27a;
  --pc-amber-dk: #8a561f;
  --pc-sage: #a5b8a8;
  --pc-sage-dk: #8c9f90;
  --pc-shadow: rgba(48, 40, 30, 0.22);
  --pc-pixel: "DotGothic16", ui-monospace, monospace;
  --pc-display: "Silkscreen", "DotGothic16", ui-monospace, monospace;

  container-type: inline-size;
  position: relative;
  width: 100%;
  color: var(--pc-charcoal);
  user-select: none;
}

.pc *,
.pc *::before,
.pc *::after {
  box-sizing: border-box;
}

.pc-stage {
  --u: calc(100cqw / 460);
  --open: 0;
  position: relative;
  width: 100%;
  height: calc(var(--u) * 560);
}

/* one-step pixel corners */
.pc-head,
.pc-body,
.pc-lcd,
.pc-lcd-glass {
  --st: calc(var(--u) * 3);
  clip-path: polygon(
    var(--st) 0, calc(100% - var(--st)) 0, calc(100% - var(--st)) var(--st), 100% var(--st),
    100% calc(100% - var(--st)), calc(100% - var(--st)) calc(100% - var(--st)), calc(100% - var(--st)) 100%,
    var(--st) 100%, var(--st) calc(100% - var(--st)), 0 calc(100% - var(--st)), 0 var(--st), var(--st) var(--st)
  );
}

/* hard shadow under the whole machine, as its own layer (clip-path would cut a drop-shadow) */
.pc-stage::before {
  content: "";
  position: absolute;
  left: calc(var(--u) * 8);
  top: calc(var(--u) * 290);
  width: calc(var(--u) * 460);
  height: calc(var(--u) * 262);
  background: var(--pc-shadow);
}

/* ── reader head: the upper block with the intake slot ── */

.pc-head {
  position: absolute;
  left: calc(var(--u) * 18);
  right: calc(var(--u) * 18);
  top: calc(var(--u) * 186);
  height: calc(var(--u) * 110);
  background: var(--pc-charcoal);
}

.pc-head::before {
  content: "";
  position: absolute;
  inset: calc(var(--u) * 3);
  background: var(--pc-ivory);
  clip-path: inherit;
  box-shadow: inset 0 calc(var(--u) * 3) 0 var(--pc-ivory-lt), inset 0 calc(var(--u) * -10) 0 var(--pc-ivory-dk);
}

/* the dark recess the card drops into */
.pc-recess {
  position: absolute;
  left: calc(var(--u) * 30);
  right: calc(var(--u) * 30);
  top: calc(var(--u) * 16);
  height: calc(var(--u) * 58);
  background: var(--pc-recess);
  box-shadow: inset 0 calc(var(--u) * 6) 0 var(--pc-charcoal);
}

/* the upper lip lifts a few units when the reader opens */
.pc-lip {
  position: absolute;
  left: calc(var(--u) * -6);
  right: calc(var(--u) * -6);
  top: calc(var(--u) * -6);
  height: calc(var(--u) * 8);
  background: var(--pc-ivory-dk2);
  border-bottom: calc(var(--u) * 2) solid var(--pc-charcoal);
  transform: translateY(calc(var(--u) * -3 * var(--open)));
}

/* the card travels in this window; it vanishes at the roller line */
.pc-feed {
  position: absolute;
  inset: 0 0 auto;
  height: calc(var(--u) * 260);
  overflow: hidden;
  clip-path: inset(-50% 0 0 0);
}

.pc-card {
  position: absolute;
  left: calc(var(--u) * 80);
  top: 0;
  width: calc(var(--u) * 300);
  height: calc(var(--u) * 250);
  background: var(--pc-charcoal);
  /* punch-card corner cut at the top left, in pixel steps */
  clip-path: polygon(
    calc(var(--u) * 14) 0, 100% 0, 100% 100%, 0 100%, 0 calc(var(--u) * 14),
    calc(var(--u) * 4) calc(var(--u) * 14), calc(var(--u) * 4) calc(var(--u) * 9), calc(var(--u) * 9) calc(var(--u) * 9),
    calc(var(--u) * 9) calc(var(--u) * 4), calc(var(--u) * 14) calc(var(--u) * 4)
  );
}

.pc-card[hidden] { display: none; }

.pc-card-face {
  position: absolute;
  inset: calc(var(--u) * 2);
  background: var(--pc-paper);
  clip-path: inherit;
  box-shadow: inset calc(var(--u) * -3) 0 0 #e7d8c0;
}

.pc-card-id,
.pc-card-name {
  position: absolute;
  top: calc(var(--u) * 10);
  font: 400 calc(var(--u) * 9.5) / 1 var(--pc-pixel);
  letter-spacing: 0.14em;
  white-space: nowrap;
}

.pc-card-id { left: calc(var(--u) * 18); }

.pc-card-name {
  right: calc(var(--u) * 14);
  max-width: calc(var(--u) * 120);
  overflow: hidden;
  text-overflow: ellipsis;
  color: #6b6152;
}

.pc-field-rule {
  position: absolute;
  top: calc(var(--u) * 28);
  bottom: calc(var(--u) * 6);
  width: calc(var(--u) * 1);
  background: var(--pc-paper-line);
}

.pc-holes {
  position: absolute;
  left: calc(var(--u) * 16);
  top: calc(var(--u) * 32);
  width: calc(var(--u) * 256.6);
}

.pc-hole { fill: none; }
.pc-hole.is-on { fill: var(--pc-charcoal); }
.pc-hole.is-hidden { visibility: hidden; }

/* small machines: half the rows, so the pattern stays a pattern */
@container (max-width: 300px) {
  .pc-hole.is-odd { visibility: hidden; }
}

/* rollers across the slot, with the amber scanning head in front */
.pc-rollers {
  position: absolute;
  left: calc(var(--u) * 44);
  right: calc(var(--u) * 44);
  top: calc(var(--u) * 250);
  height: calc(var(--u) * 18);
  background:
    linear-gradient(var(--pc-charcoal) 0 0) left / calc(var(--u) * 16) 100% no-repeat,
    linear-gradient(var(--pc-charcoal) 0 0) right / calc(var(--u) * 16) 100% no-repeat,
    linear-gradient(#5b6164 0 calc(var(--u) * 3), #34393c 0);
  box-shadow: 0 calc(var(--u) * 2) 0 var(--pc-charcoal);
}

.pc-scanner {
  position: absolute;
  left: calc(var(--u) * 56);
  right: calc(var(--u) * 56);
  top: calc(var(--u) * 3);
  height: calc(var(--u) * 15);
  border: calc(var(--u) * 2) solid var(--pc-charcoal);
  background: linear-gradient(#7c6a52 0 calc(var(--u) * 3), #5e5141 0 calc(var(--u) * 8), #4a4034 0);
}

/* awake: the scanning head turns amber (a solid colour, not a glow) */
[data-state="over"] .pc-scanner,
[data-state="checking"] .pc-scanner,
[data-state="uploading"] .pc-scanner,
[data-state="pulling"] .pc-scanner,
[data-state="failed"] .pc-scanner {
  background: linear-gradient(var(--pc-amber-lt) 0 calc(var(--u) * 3), var(--pc-amber) 0 calc(var(--u) * 8), var(--pc-amber-dk) 0);
}

/* ── body ── */

.pc-body {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(var(--u) * 282);
  height: calc(var(--u) * 262);
  background: var(--pc-charcoal);
}

.pc-body::before {
  content: "";
  position: absolute;
  inset: calc(var(--u) * 3) calc(var(--u) * 3) calc(var(--u) * 3);
  background: var(--pc-ivory);
  clip-path: inherit;
  box-shadow:
    inset calc(var(--u) * 3) calc(var(--u) * 3) 0 var(--pc-ivory-lt),
    inset 0 calc(var(--u) * -8) 0 var(--pc-ivory-dk);
}

.pc-label {
  position: absolute;
  left: calc(var(--u) * 26);
  right: calc(var(--u) * 26);
  top: calc(var(--u) * 18);
  display: flex;
  align-items: center;
  gap: calc(var(--u) * 10);
  font: 400 calc(var(--u) * 9) / 1 var(--pc-pixel);
  letter-spacing: 0.16em;
  white-space: nowrap;
}

.pc-rule {
  flex: 1;
  height: calc(var(--u) * 1.5);
  background: #8a8174;
}

/* status light: dark at rest, amber while reading, sage once a file is stored */
.pc-led {
  width: calc(var(--u) * 8);
  height: calc(var(--u) * 8);
  border: calc(var(--u) * 1.5) solid var(--pc-charcoal);
  background: #6f675a;
}

[data-state="checking"] .pc-led,
[data-state="uploading"] .pc-led,
[data-state="pulling"] .pc-led { background: var(--pc-amber); }
[data-state="stored"] .pc-led { background: var(--pc-sage); }

.pc-lcd {
  position: absolute;
  left: calc(var(--u) * 28);
  right: calc(var(--u) * 28);
  top: calc(var(--u) * 38);
  height: calc(var(--u) * 100);
  background: var(--pc-charcoal);
}

.pc-lcd-glass {
  --st: calc(var(--u) * 2);
  position: absolute;
  inset: calc(var(--u) * 5);
  background: var(--pc-sage);
  box-shadow: inset calc(var(--u) * 3) calc(var(--u) * 3) 0 var(--pc-sage-dk);
}

.pc-lcd-title,
.pc-lcd-pct {
  position: absolute;
  top: calc(var(--u) * 14);
  font: 700 calc(var(--u) * 19) / 1 var(--pc-display);
  letter-spacing: 0.02em;
  white-space: nowrap;
}

.pc-lcd-title { left: calc(var(--u) * 16); }

.pc-lcd-pct {
  right: calc(var(--u) * 16);
  top: calc(var(--u) * 11);
  font: 400 calc(var(--u) * 23) / 1 var(--pc-pixel);   /* Silkscreen's % reads as a Z */
  font-variant-numeric: tabular-nums;
}

.pc-lcd-sub {
  position: absolute;
  left: calc(var(--u) * 16);
  right: calc(var(--u) * 16);
  top: calc(var(--u) * 42);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font: 400 calc(var(--u) * 8.5) / 1 var(--pc-pixel);
  letter-spacing: 0.14em;
  opacity: 0.85;
}

.pc-bar {
  position: absolute;
  left: calc(var(--u) * 16);
  right: calc(var(--u) * 16);
  bottom: calc(var(--u) * 12);
  height: calc(var(--u) * 18);
  display: flex;
  gap: calc(var(--u) * 2);
  padding: calc(var(--u) * 2.5);
  border: calc(var(--u) * 2) solid var(--pc-charcoal);
}

.pc-bar i {
  flex: 1;
  background: rgba(37, 44, 48, 0.1);
}

.pc-bar i.is-on { background: var(--pc-charcoal); }

/* drop strip: the uploader's affordances stay plainly visible */
.pc-drop {
  position: absolute;
  left: calc(var(--u) * 24);
  right: calc(var(--u) * 24);
  top: calc(var(--u) * 154);
  height: calc(var(--u) * 80);
  display: flex;
  align-items: center;
  gap: calc(var(--u) * 14);
  padding: 0 calc(var(--u) * 16) 0 calc(var(--u) * 26);
  border: calc(var(--u) * 1.5) solid var(--pc-ivory-dk2);
  cursor: pointer;
}

.pc-drop::before {
  content: "";
  position: absolute;
  inset: calc(var(--u) * 6);
  border: calc(var(--u) * 1.2) dashed #b9ab92;
  pointer-events: none;
}

[data-state="over"] .pc-drop { background: #e2d8c3; }
[data-state="over"] .pc-drop::before { border-color: var(--pc-charcoal); border-style: solid; }

.pc-drop-icon {
  width: calc(var(--u) * 18);
  fill: var(--pc-charcoal);
  flex: none;
}

.pc-drop-icon svg { display: block; width: 100%; }

.pc-drop-text {
  flex: 1;
  min-width: 0;
  font: 400 calc(var(--u) * 10.5) / 1 var(--pc-pixel);
  letter-spacing: 0.2em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.pc-drop-rule {
  width: calc(var(--u) * 1.5);
  height: calc(var(--u) * 34);
  background: #a99c86;
  flex: none;
}

.pc-actions {
  position: relative;
  display: flex;
  gap: calc(var(--u) * 8);
}

.pc-btn {
  --st: calc(var(--u) * 2);
  position: relative;
  min-width: calc(var(--u) * 84);
  min-height: max(44px, calc(var(--u) * 36));
  padding: 0 calc(var(--u) * 12);
  border: calc(var(--u) * 2) solid var(--pc-charcoal);
  background: var(--pc-ivory-lt);
  box-shadow: inset 0 calc(var(--u) * -4) 0 var(--pc-ivory-dk);
  font: 400 calc(var(--u) * 11) / 1 var(--pc-pixel);
  letter-spacing: 0.16em;
  color: var(--pc-charcoal);
  cursor: pointer;
}

.pc-btn.is-primary {
  background: var(--pc-sage);
  box-shadow: inset 0 calc(var(--u) * -4) 0 var(--pc-sage-dk);
}

.pc-btn:active {
  box-shadow: none;
  transform: translateY(calc(var(--u) * 1));
}

.pc-btn:focus-visible {
  outline: calc(var(--u) * 2) solid var(--pc-amber);
  outline-offset: calc(var(--u) * 2);
}

.pc-input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.pc-foot {
  position: absolute;
  top: calc(var(--u) * 542);
  width: calc(var(--u) * 54);
  height: calc(var(--u) * 12);
  background: var(--pc-charcoal);
}

.pc-foot-l { left: calc(var(--u) * 24); }
.pc-foot-r { right: calc(var(--u) * 24); }

.pc-live {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}