№ 015

Hidden Pitch

A file upload with a penalty hidden inside — pull back the ball, score, and the upload starts.

♪ Plays sound — click inside to start
Version
1.0.0
Download
661 KB · 18 files
Added
Oct 26, 2026
Built with
HTML, CSS, JS
Needs
Google Fonts
Tags
file upload, game, football

Usage

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

  new HiddenPitch(element, {
    upload: HiddenPitch.xhr('/api/upload'),   // or any (file, onProgress, signal) => Promise
    assets: 'assets/',                        // folder with the kit images
    accept: ['.pdf', 'image/*'],              // optional
    maxSize: 25 * 1024 * 1024,                // optional, bytes
    onComplete(file, response) {},
  })

An ordinary file upload with a penalty hidden inside. Choose a file and the card unfolds into a tiny
pitch; the file folds into a football sitting in an elastic launcher. Pull it back and let go:
score and the upload starts while the referee checks VAR, then GOOOAL! once the transfer really
finishes. Miss and it's a red card — the file is safe, take another shot, or skip the game entirely.
The shot never decides the upload: it is only marked complete when the transfer succeeds.
Keyboard: focus the ball, arrows aim and set power, Enter shoots. The round button shoots too.

Source

/* Hidden Pitch — a file upload with a penalty inside. Laid out on a 760-unit grid (--u), so the whole
   object scales with the width of its container. */

.hp {
  --hp-ivory: #f5f1e9;
  --hp-tray: #ece5d8;
  --hp-edge: #e1d9c9;
  --hp-green: #557b57;
  --hp-forest: #293d30;
  --hp-sage: #dce5d7;
  --hp-ochre: #d4a85f;
  --hp-ochre-dk: #a77b3a;
  --hp-red: #b9483f;
  --hp-ink: #24302a;
  --hp-muted: #7a7a70;
  --hp-ui: "Inter", "Geist", system-ui, sans-serif;
  --hp-sport: "Barlow Condensed", "Inter", sans-serif;

  container-type: inline-size;
  position: relative;
  width: 100%;
  color: var(--hp-ink);
  font-family: var(--hp-ui);
  -webkit-tap-highlight-color: transparent;
}

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

.hp-card {
  --u: calc(100cqw / 760);
  --h: 300;
  --open: 0;
  position: relative;
  width: 100%;
  height: calc(var(--u) * var(--h));
  border-radius: calc(var(--u) * 28);
  background: var(--hp-ivory);
  overflow: hidden;
  box-shadow:
    inset 0 calc(var(--u) * 2) 0 #fffdf8,
    inset 0 calc(var(--u) * -12) 0 var(--hp-edge),   /* the slab's front edge */
    0 calc(var(--u) * 2) calc(var(--u) * 3) rgba(70, 58, 40, 0.12),
    0 calc(var(--u) * 26) calc(var(--u) * 50) rgba(70, 58, 40, 0.16);
  user-select: none;
}

/* ── stadium: unfolds out of the card, hinged at its bottom edge ── */

.hp-stadium {
  position: absolute;
  left: 0;
  top: 0;
  width: calc(var(--u) * 760);
  height: calc(var(--u) * 900);
  transform-origin: 50% 100%;
  transform: perspective(calc(var(--u) * 1600)) rotateX(calc((1 - var(--open)) * 82deg));
  opacity: min(1, calc(var(--open) * 3));
}

.hp-stadium::before {
  content: "";
  position: absolute;
  left: calc(var(--u) * 26);
  top: calc(var(--u) * 26);
  width: calc(var(--u) * 708);
  height: calc(var(--u) * 866);
  border-radius: calc(var(--u) * 22);
  background: var(--hp-tray);
  box-shadow: inset 0 calc(var(--u) * 3) calc(var(--u) * 6) rgba(70, 58, 40, 0.12);
}

.hp-stadium img,
.hp-frame,
.hp-net {
  position: absolute;
  display: block;
  pointer-events: none;
}

.hp-crowd-top { left: calc(var(--u) * 120); top: calc(var(--u) * 18); width: calc(var(--u) * 520); }
.hp-crowd-left { left: calc(var(--u) * 16); top: calc(var(--u) * 108); height: calc(var(--u) * 772); }
.hp-crowd-right { right: calc(var(--u) * 16); top: calc(var(--u) * 108); height: calc(var(--u) * 772); }

.hp-pitch {
  position: absolute;
  left: calc(var(--u) * 98);
  top: calc(var(--u) * 120);
  width: calc(var(--u) * 564);
  height: calc(var(--u) * 760);
  border-radius: calc(var(--u) * 4);
  overflow: hidden;
  background:
    repeating-linear-gradient(to bottom, rgba(255, 255, 255, 0.035) 0 calc(var(--u) * 54), rgba(0, 0, 0, 0.035) 0 calc(var(--u) * 108)),
    url("assets/pitch.jpg") center / calc(var(--u) * 380) auto,
    var(--hp-green);
  box-shadow: inset 0 calc(var(--u) * 4) calc(var(--u) * 10) rgba(0, 0, 0, 0.3);
}

.hp-lines {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  fill: none;
  stroke: rgba(255, 255, 255, 0.82);
  stroke-width: 3;
}

.hp-lines .hp-spot { fill: rgba(255, 255, 255, 0.82); stroke: none; }

.hp-net,
.hp-frame {
  left: calc(var(--u) * 272);
  top: calc(var(--u) * 72);
  width: calc(var(--u) * 216);
  transform-origin: 50% 100%;
}

.hp-frame { opacity: calc(var(--open) * var(--open) * var(--open)); }

.hp-ref {
  /* small beside the ball and goal; feet stay at (600, 481) */
  left: calc(var(--u) * 569);
  top: calc(var(--u) * 353);
  width: calc(var(--u) * 62);
  filter: drop-shadow(calc(var(--u) * -10) calc(var(--u) * 6) calc(var(--u) * 6) rgba(0, 0, 0, 0.28));
}

/* the VAR monitor rises at the touchline beside the referee (same place and scale as in the review image) */
.hp-monitor {
  --m: 0;
  left: calc(var(--u) * 622);
  top: calc(var(--u) * 330);
  width: calc(var(--u) * 62);
  transform-origin: 50% 100%;
  transform: translateY(calc(var(--u) * 14 * (1 - var(--m)))) scale(calc(0.6 + 0.4 * var(--m)));
  opacity: var(--m);
  filter: drop-shadow(calc(var(--u) * -8) calc(var(--u) * 6) calc(var(--u) * 6) rgba(0, 0, 0, 0.3));
}

/* the referee turned to watch the monitor, with the monitor itself */
.hp-ref-review {
  left: calc(var(--u) * 574);
  top: calc(var(--u) * 330);
  width: calc(var(--u) * 110);
  filter: drop-shadow(calc(var(--u) * -10) calc(var(--u) * 6) calc(var(--u) * 6) rgba(0, 0, 0, 0.28));
}

/* the amber light on top of the monitor: blinks while the review runs */
.hp-beacon {
  position: absolute;
  left: calc(var(--u) * 644);
  top: calc(var(--u) * 331);
  width: calc(var(--u) * 16);
  height: calc(var(--u) * 16);
  border-radius: 50%;
  background: radial-gradient(circle, rgba(255, 214, 120, 0.95) 0 22%, rgba(255, 160, 40, 0.55) 45%, rgba(255, 140, 20, 0) 70%);
  opacity: 0;
  pointer-events: none;
}

/* ── the launcher, ball and elastic ── */

.hp-overlay {
  position: absolute;
  left: 0;
  top: 0;
  width: calc(var(--u) * 760);
  height: calc(var(--u) * 1090);
  overflow: visible;
  pointer-events: none;
  opacity: var(--open);
}

.hp-band-edge,
.hp-band,
.hp-band-hi {
  fill: none;
  stroke-linecap: round;
}

.hp-band-edge { stroke: #a2752f; }
.hp-band { stroke: #dcae58; }
.hp-band-hi { stroke: #f4d48c; opacity: 0.8; }

.hp-aim circle { fill: #fff; }

.hp-post {
  position: absolute;
  top: calc(var(--u) * 768);
  width: calc(var(--u) * 40);
  opacity: var(--open);
  filter: drop-shadow(0 calc(var(--u) * 5) calc(var(--u) * 4) rgba(0, 0, 0, 0.35));
  pointer-events: none;
}

.hp-post-l { left: calc(var(--u) * 158); }
.hp-post-r { left: calc(var(--u) * 562); }

/* the leather pouch, behind the ball; turns with the pull around the ball's seat */
.hp-pouch {
  position: absolute;
  left: 0;
  top: 0;
  width: calc(var(--u) * 96);
  transform-origin: 50% 45%;
  opacity: var(--open);
  filter: drop-shadow(0 calc(var(--u) * 4) calc(var(--u) * 3) rgba(0, 0, 0, 0.3));
  pointer-events: none;
}

.hp-ball,
.hp-ball-shadow,
.hp-sheet {
  position: absolute;
  left: 0;
  top: 0;
  width: calc(var(--u) * 42);   /* 2 × the ball radius in hidden-pitch.js */
  height: calc(var(--u) * 42);
}

.hp-ball {
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  cursor: grab;
  touch-action: none;
}

.hp-ball img { display: block; width: 100%; height: 100%; pointer-events: none; }
.hp-ball:focus-visible { outline: calc(var(--u) * 3) solid var(--hp-ochre); outline-offset: calc(var(--u) * 4); }
[data-state="aiming"] .hp-ball { cursor: grabbing; }

.hp-ball-shadow {
  height: calc(var(--u) * 17);
  border-radius: 50%;
  background: radial-gradient(closest-side, rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0));
}

/* the file, folding itself into a ball */
.hp-sheet {
  --ballness: 0;
  width: calc(var(--u) * 46);
  height: calc(var(--u) * 54);
  margin: calc(var(--u) * -27) 0 0 calc(var(--u) * -23);
  border: calc(var(--u) * 3) solid var(--hp-forest);
  background: var(--hp-ivory);
}

.hp-sheet::after {
  content: "";
  position: absolute;
  inset: calc(var(--u) * -4);
  border-radius: 50%;
  background: url("assets/football.webp") center / cover;
  opacity: calc(var(--ballness) * var(--ballness));
}

/* ── VAR / GOAL / RED CARD ── */

.hp-banner {
  --in: 0;
  position: absolute;
  left: calc(var(--u) * 150);
  right: calc(var(--u) * 150);
  top: calc(var(--u) * 528);   /* below the referee and the VAR monitor, which are part of the joke */
  display: grid;
  justify-items: center;
  gap: calc(var(--u) * 8);
  padding: calc(var(--u) * 26) calc(var(--u) * 20) calc(var(--u) * 24);
  border-radius: calc(var(--u) * 18);
  background: rgba(245, 241, 233, 0.96);
  box-shadow: 0 calc(var(--u) * 14) calc(var(--u) * 30) rgba(20, 30, 22, 0.28);
  text-align: center;
  opacity: 0;
  transform: scale(0.92);
  pointer-events: none;
}

.hp-banner.is-on {
  opacity: var(--in);
  transform: scale(calc(0.92 + 0.08 * var(--in)));
}

.hp-banner b {
  font: 600 calc(var(--u) * 78) / 0.9 var(--hp-sport);
  letter-spacing: 0.02em;
  color: var(--hp-forest);
}

.hp-banner[data-kind="var"] b { font-size: calc(var(--u) * 64); letter-spacing: 0.06em; }
.hp-banner[data-kind="red"] b { color: var(--hp-red); }
.hp-banner[data-kind="upload"] b { font-size: calc(var(--u) * 56); }

.hp-banner span { font: 500 calc(var(--u) * 19) / 1.3 var(--hp-ui); color: var(--hp-ink); }

.hp-banner em {
  font: 600 calc(var(--u) * 12) / 1.3 var(--hp-ui);
  font-style: normal;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--hp-muted);
}

.hp-banner em:empty { display: none; }

/* the VAR review: a dark pill over the penalty area */
.hp-var {
  --in: 0;
  position: absolute;
  left: calc(var(--u) * 150);
  top: calc(var(--u) * 228);
  display: flex;
  align-items: center;
  gap: calc(var(--u) * 16);
  padding: calc(var(--u) * 14) calc(var(--u) * 22) calc(var(--u) * 14) calc(var(--u) * 16);
  border-radius: calc(var(--u) * 12);
  background: rgba(28, 41, 33, 0.95);
  box-shadow: 0 calc(var(--u) * 10) calc(var(--u) * 24) rgba(10, 20, 14, 0.35);
  color: var(--hp-ivory);
  opacity: var(--in);
  transform: translateY(calc(var(--u) * -8 * (1 - var(--in))));
  pointer-events: none;
}

.hp-var-badge {
  padding: calc(var(--u) * 4) calc(var(--u) * 8);
  border: calc(var(--u) * 2.5) solid var(--hp-ivory);
  border-radius: calc(var(--u) * 4);
  font: 600 calc(var(--u) * 26) / 1 var(--hp-sport);
  letter-spacing: 0.04em;
}

.hp-var-rule {
  width: calc(var(--u) * 1.5);
  height: calc(var(--u) * 44);
  background: rgba(245, 241, 233, 0.35);
}

.hp-var-text { display: grid; gap: calc(var(--u) * 4); }
.hp-var-text b { font: 600 calc(var(--u) * 26) / 1 var(--hp-sport); letter-spacing: 0.06em; }
.hp-var-text span { font: 500 calc(var(--u) * 16) / 1.2 var(--hp-ui); opacity: 0.9; }

.hp-spin {
  position: relative;
  width: calc(var(--u) * 30);
  height: calc(var(--u) * 30);
  margin-left: calc(var(--u) * 18);
}

.hp-spin i {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(var(--u) * 5.5);
  height: calc(var(--u) * 5.5);
  margin: calc(var(--u) * -2.75);
  border-radius: 50%;
  background: var(--hp-ivory);
}

.hp-spin i:nth-child(1) { transform: rotate(0deg) translateY(calc(var(--u) * -12)); }
.hp-spin i:nth-child(2) { transform: rotate(45deg) translateY(calc(var(--u) * -12)); }
.hp-spin i:nth-child(3) { transform: rotate(90deg) translateY(calc(var(--u) * -12)); }
.hp-spin i:nth-child(4) { transform: rotate(135deg) translateY(calc(var(--u) * -12)); }
.hp-spin i:nth-child(5) { transform: rotate(180deg) translateY(calc(var(--u) * -12)); }
.hp-spin i:nth-child(6) { transform: rotate(225deg) translateY(calc(var(--u) * -12)); }
.hp-spin i:nth-child(7) { transform: rotate(270deg) translateY(calc(var(--u) * -12)); }
.hp-spin i:nth-child(8) { transform: rotate(315deg) translateY(calc(var(--u) * -12)); }

/* ── the plain upload field (before a file) ── */

.hp-drop {
  position: absolute;
  left: calc(var(--u) * 26);
  right: calc(var(--u) * 26);
  top: calc(var(--u) * 26);
  height: calc(var(--u) * 236);
  display: grid;
  place-content: center;
  justify-items: center;
  gap: calc(var(--u) * 10);
  border: calc(var(--u) * 2) dashed #c9c1b1;
  border-radius: calc(var(--u) * 20);
  cursor: pointer;
  opacity: calc(1 - var(--open) * 3);
}

.has-file .hp-drop { pointer-events: none; }
[data-state="over"] .hp-drop { border-color: var(--hp-forest); border-style: solid; background: var(--hp-sage); }
[data-state="invalid"] .hp-drop { border-color: var(--hp-red); }

.hp-drop-icon {
  width: calc(var(--u) * 34);
  height: calc(var(--u) * 34);
  fill: none;
  stroke: var(--hp-forest);
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.hp-drop-title {
  margin: 0;
  font: 600 calc(var(--u) * 34) / 1 var(--hp-sport);
  letter-spacing: 0.02em;
}

.hp-drop-note {
  margin: 0;
  font: 500 calc(var(--u) * 15) / 1.4 var(--hp-ui);
  color: var(--hp-muted);
}

.hp-drop-error {
  margin: 0;
  min-height: 1.3em;
  font: 600 calc(var(--u) * 14) / 1.3 var(--hp-ui);
  color: var(--hp-red);
}

.hp-link {
  padding: calc(var(--u) * 6) calc(var(--u) * 2);
  border: 0;
  background: none;
  font: 600 calc(var(--u) * 15) / 1.4 var(--hp-ui);
  color: var(--hp-forest);
  text-decoration: underline;
  text-underline-offset: 0.25em;
  cursor: pointer;
}

.hp-link:disabled { opacity: 0.35; cursor: default; }
.hp-link:focus-visible,
.hp-go:focus-visible { outline: calc(var(--u) * 3) solid var(--hp-ochre); outline-offset: calc(var(--u) * 3); }

/* ── file bar ── */

.hp-bar {
  position: absolute;
  left: calc(var(--u) * 26);
  right: calc(var(--u) * 26);
  bottom: calc(var(--u) * 60);
  height: calc(var(--u) * 124);
  display: flex;
  align-items: center;
  gap: calc(var(--u) * 24);
  padding: 0 calc(var(--u) * 28) 0 calc(var(--u) * 34);
  border: calc(var(--u) * 1.5) solid var(--hp-edge);
  border-radius: calc(var(--u) * 22);
  background: #f1ece2;
  opacity: var(--open);
}

.hp-extra {
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(var(--u) * 18);
  margin: 0;
  display: flex;
  justify-content: center;
  gap: calc(var(--u) * 30);
  opacity: var(--open);
}

.hp-extra .hp-link { font-size: calc(var(--u) * 13); color: var(--hp-muted); }
.hp:not(.has-file) .hp-bar,
.hp:not(.has-file) .hp-extra { visibility: hidden; }

.hp-file-icon {
  width: calc(var(--u) * 40);
  flex: none;
  fill: none;
  stroke: var(--hp-forest);
  stroke-width: 1.6;
  stroke-linejoin: round;
}

.hp-file {
  flex: 1;
  min-width: 0;
  display: grid;
  gap: calc(var(--u) * 10);
}

.hp-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font: 500 calc(var(--u) * 28) / 1.1 var(--hp-ui);
  letter-spacing: -0.01em;
}

.hp-status {
  display: flex;
  align-items: center;
  gap: calc(var(--u) * 10);
  font: 600 calc(var(--u) * 12.5) / 1.2 var(--hp-ui);
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: #5b6158;
  white-space: nowrap;
  overflow: hidden;
}

.hp-status i {
  flex: none;
  width: calc(var(--u) * 11);
  height: calc(var(--u) * 11);
  border-radius: 50%;
  background: #6f9a4f;
}

[data-state="missed"] .hp-status i,
[data-state="failed"] .hp-status i { background: var(--hp-red); }
[data-state="var"] .hp-status i,
[data-state="uploading"] .hp-status i { background: var(--hp-ochre); }

.hp-bar-rule {
  width: calc(var(--u) * 1.5);
  height: calc(var(--u) * 70);
  background: var(--hp-edge);
  flex: none;
}

.hp-go {
  flex: none;
  width: calc(var(--u) * 84);
  height: calc(var(--u) * 84);
  min-width: 44px;
  min-height: 44px;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 50%;
  background: var(--hp-forest);
  box-shadow: 0 calc(var(--u) * 6) calc(var(--u) * 12) rgba(41, 61, 48, 0.3);
  cursor: pointer;
}

.hp-go svg {
  width: calc(var(--u) * 34);
  fill: none;
  stroke: var(--hp-ivory);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.hp-go:disabled { opacity: 0.5; cursor: default; }

.hp-progress {
  position: absolute;
  left: calc(var(--u) * 34);
  right: calc(var(--u) * 28);
  bottom: calc(var(--u) * 12);
  height: calc(var(--u) * 3);
  border-radius: calc(var(--u) * 2);
  overflow: hidden;
}

.hp-progress i {
  display: block;
  height: 100%;
  background: var(--hp-forest);
  transform-origin: 0 50%;
  transform: scaleX(0);
}

/* sound on / off, top right of the card */
.hp-mute {
  position: absolute;
  right: calc(var(--u) * 40);
  top: calc(var(--u) * 40);
  z-index: 3;
  width: max(44px, calc(var(--u) * 52));
  height: max(44px, calc(var(--u) * 52));
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 50%;
  background: rgba(245, 241, 233, 0.92);
  box-shadow: 0 calc(var(--u) * 4) calc(var(--u) * 10) rgba(41, 61, 48, 0.2);
  cursor: pointer;
  opacity: var(--open);
}

.hp:not(.has-file) .hp-mute { visibility: hidden; }

.hp-mute svg {
  width: calc(var(--u) * 26);
  fill: none;
  stroke: var(--hp-forest);
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.hp-mute .hp-mute-body { fill: var(--hp-forest); }
.hp-mute .hp-mute-off,
.hp-mute.is-muted .hp-mute-on { display: none; }
.hp-mute.is-muted .hp-mute-off { display: inline; }
.hp-mute:focus-visible { outline: calc(var(--u) * 3) solid var(--hp-ochre); outline-offset: calc(var(--u) * 3); }

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

.hp-live {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}
All 18 files in the zip
  • assets/crowd-left.webp
  • assets/crowd-right.webp
  • assets/crowd-top.webp
  • assets/football.webp
  • assets/goal-frame.webp
  • assets/goal-net.webp
  • assets/pitch.jpg
  • assets/post-left.webp
  • assets/post-right.webp
  • assets/pouch.webp
  • assets/referee-neutral.webp
  • assets/referee-red-card.webp
  • assets/referee-var.webp
  • assets/var-monitor.webp
  • assets/var-review.webp
  • hidden-pitch.css
  • hidden-pitch.js
  • index.html