Aduok Code

A Basketball File Upload, Built from a Scaled Full-Page Court, a Slingshot Pull with a Live Aim Arrow, and Hand-Written Physics That Turns a Scored Basket into an Upload

Introduction

This upload screen is a single HTML file with no library and no image. A dashed Drop files here card sits at the top of the page with a basketball hoop hanging from its lower edge. A PDF waits in the lower left. Pull it back, and an arrow shows where it will fly. Let go, and it sails through the air, bounces off the rim if you are off, and drops through the net if you are on. A scored basket pops a +1, swishes the net, raises the Uploaded counter, and adds a card with a progress bar that fills to a green check.

The page rests on five ideas: a world that is sized in its own units and scaled to fit any screen, a hoop drawn from SVG and a net that is generated in a loop, a slingshot pull handled with pointer events, a preview that runs the same maths as the real flight, and a tiny physics loop whose only job is to decide whether the file crossed the rim in the right place. Dropping real files on the page and the Browse files button feed the same upload list.

What you will learn

How to scale a whole scene to any screen with one number — how to draw a net with a loop — how to turn a pointer drag into a launch velocity — how to preview a shot with the same integration the real shot uses — how to bounce a ball off two rim points — how to detect a basket by a line crossing — and how to hook a game event up to a normal upload list and to real dropped files.

The upload is a front-end demo

The progress bar is animated by the page and nothing is sent to a server. To make it real, replace the body of the function that adds a card with your own upload call and drive the bar from its progress events.

01
Part One
A Court That Fills the Page

Step 1 — A World in Its Own Units, Scaled by One Number

There is no card around the page. The stage is positioned to fill the whole viewport, and inside it sits a world element that holds the hoop, the file, the dots, and the arrow. All the positions and all the physics are written in world units, so the code never needs to know the real screen size. One scale factor, hs, converts world units to pixels. It is the smaller of the width divided by 430 and the height divided by 680, kept between 0.75 and 1.3, so a phone and a wide monitor both get a court that fits.

CSS
html, body { height: 100%; }
body { position: relative; min-height: 560px; overflow: hidden; }

.stage { position: absolute; inset: 0; overflow: hidden; }
.world { position: absolute; left: 0; top: 0; transform-origin: 0 0; z-index: 5; }

The world is then made as large as the screen divided by hs, and scaled back up by hs with a transform. The result fills the screen exactly, while every number inside it stays in the same range. The hoop group is placed at the horizontal centre and just below the header, which is measured so the hoop never slides under the title on a small screen. The layout function runs once at the start, whenever the stage or the header changes size, and again when the font finishes loading.

JavaScript
function layout() {
  var W = stage.clientWidth, H = stage.clientHeight;
  hs = clamp(Math.min(W / 430, H / 680), 0.75, 1.3);
  WW = W / hs; HH = H / hs;

  world.style.width  = WW + 'px';
  world.style.height = HH + 'px';
  world.style.transform = 'scale(' + hs + ')';

  ox = WW / 2 - 200;                          // centre the 400-wide hoop group
  oy = (head.offsetHeight + 8) / hs - 10;     // sit just under the header
  hoopg.style.left = ox + 'px';
  hoopg.style.top  = oy + 'px';

  RIM_Y = 166 + oy;  RIM_L = 150 + ox;  RIM_R = 250 + ox;  CX = 200 + ox;
  FLOOR = HH - 18;
  MAXPULL = clamp(HH * 0.26, 120, 210);
  HOME.x = clamp(WW * 0.24, 76, 300);
  HOME.y = Math.max(RIM_Y + 170, HH - MAXPULL - 30);
}

new ResizeObserver(layout).observe(stage);

Notice that the hoop is designed once, in a box that is 400 units wide and 260 tall, and then only moved. The rim height, the rim ends, and the centre are all written as the design number plus the offset, for example the rim is at 166 plus oy. That keeps the picture and the physics in agreement by construction.

Write the game in its own units. Let one number worry about the screen.

02
Part Two
Drawing the Hoop and the Net

Step 2 — A Drop Zone, a Board, a Rim, and a Net from a Loop

The hoop group has three layers. At the back is the dashed drop-zone card. In front of it is an SVG with the backboard outline, the net, a small bracket, and the rim, drawn as a thick orange ellipse. The third piece is the +1 label. The SVG sits above the card but below the file, with one exception that comes up in Part 6: after a basket, the file is lowered underneath the SVG so it looks like it is inside the net.

HTML
<div class="hoopg" id="hoopg">
  <div class="zone" id="zone"> ... Drop files here ... </div>

  <svg class="hoop" viewBox="0 0 400 260" aria-hidden="true">
    <rect class="board" x="150" y="96" width="100" height="56" rx="2"/>
    <g class="net" id="net"><path class="meshline" id="mesh"/></g>
    <rect class="bracket" x="191" y="157" width="18" height="9" rx="1.5"/>
    <ellipse class="rim" cx="200" cy="166" rx="52" ry="6"/>
  </svg>

  <div class="pop" id="pop">+1</div>
</div>

The net is one path made of straight lines, and it is written by a short loop instead of by hand. Seven points run across the rim from 152 to 248, and seven points run across the bottom of the net from 174 to 226, so the net narrows as it falls. A line joins each top point to the bottom point under it, and each pair of neighbours is joined by two crossing diagonals. The crossing lines make the diamond pattern of a real net.

JavaScript
var d = '', top = [], bot = [], i;
for (i = 0; i < 7; i++) { top.push(152 + i * 16); bot.push(174 + i * (52 / 6)); }

for (i = 0; i < 7; i++) d += 'M' + top[i] + ' 168 L' + bot[i].toFixed(1) + ' 236 ';

for (i = 0; i < 6; i++) {
  d += 'M' + top[i]     + ' 168 L' + bot[i + 1].toFixed(1) + ' 236 ';
  d += 'M' + top[i + 1] + ' 168 L' + bot[i].toFixed(1)     + ' 236 ';
}
document.getElementById('mesh').setAttribute('d', d);
CSS
.net { transform-origin: 200px 166px; }
.net.swish { animation: swish .55s ease-out; }

@keyframes swish {
  0%   { transform: scale(1, 1); }
  30%  { transform: scale(.9, 1.14); }
  60%  { transform: scale(1.04, .96); }
  100% { transform: scale(1, 1); }
}

The swish is a three-step squash. The origin of the transform is the rim, so the net stretches downward from the ring and shrinks a little in width, then overshoots the other way, and settles. It is replayed by removing the class, reading a layout value to force a restart, and adding the class again.

03
Part Three
Pulling the File Back

Step 3 — A Slingshot Made of Pointer Events

The file is a small inline SVG of a sheet of paper with a folded corner, three text lines, and a red tag that shows the extension of the file name. It is moved every frame with a translate and a rotate on a single element, and it has a gentle bobbing animation while it waits. A pseudo-element 20 units larger on every side makes the grab area generous, which matters on a phone.

Pressing on the file starts a drag and captures the pointer, so the moves keep arriving even when the finger leaves the file. On every move, the pointer position is converted to world units, the pull vector from the home spot is measured, and its length is clamped to MAXPULL. The file simply follows the clamped point, tilted a little toward the side it is pulled. Letting go launches the file with a velocity equal to the pull vector reversed and multiplied by K. A pull shorter than 16 units is treated as a cancel and the file goes home.

JavaScript
fileEl.addEventListener('pointermove', function (e) {
  if (!drag || e.pointerId !== drag.id) return;

  var r = world.getBoundingClientRect();
  var px = (e.clientX - r.left) / hs, py = (e.clientY - r.top) / hs;

  var dx = px - HOME.x, dy = py - HOME.y, d = Math.hypot(dx, dy);
  if (d > MAXPULL) { dx *= MAXPULL / d; dy *= MAXPULL / d; }
  dy = Math.min(dy, HH - 24 - HOME.y);          // never drag below the screen

  s.x = HOME.x + dx;  s.y = HOME.y + dy;
  s.rot = clamp(dx * 0.15, -20, 20);
  render();
  drawPreview(-dx * K, -dy * K);                // the shot it would make right now
});

function release(e) {
  var dx = s.x - HOME.x, dy = s.y - HOME.y;
  if (Math.hypot(dx, dy) < 16) { resetHome(false); return; }
  launch(-dx * K, -dy * K);
}

The pull range and the power are not fixed numbers. MAXPULL grows with the height of the world, between 120 and 210 units, so a tall phone gets a long drag. K is then chosen so that the ideal shot needs about three quarters of the full pull. The code works out the velocity that would carry the file from home to a point 90 units above the rim, in the middle of the hoop, and multiplies its size by 1.35 before dividing by MAXPULL. That leaves room for a harder or a softer pull on either side of the perfect one.

JavaScript
var apex = RIM_Y - 90;
var vyN  = Math.sqrt(2 * G * (HOME.y - apex));                 // speed up to the apex
var T    = vyN / G + Math.sqrt(2 * (RIM_Y - apex) / G);        // time up plus time down
var vxN  = (CX - HOME.x) / T;                                  // sideways speed for that time

K = Math.hypot(vxN, vyN) * 1.35 / MAXPULL;
04
Part Four
The Aim Arrow and Dotted Path

Step 4 — A Preview That Runs the Same Maths

While you drag, three things are drawn. A dashed band runs from the home spot to the file, like the stretched strap of a slingshot. A large arrow sits in front of the file and points the way it will fly, and it grows as you pull harder. A trail of up to 24 dots follows the predicted path. The dots come from the same step used in the real flight: gravity is added to the vertical speed, the position moves, and a dot is placed every fifth step, with each one a little fainter than the last.

JavaScript
function drawPreview(vx, vy) {
  var x = s.x, y = s.y, u = vx, v = vy, k = 0, onTarget = false;

  for (var n = 0; n < 300; n++) {
    u += accel(x, y, v) * DT;
    v += G * DT;
    var py = y;
    x += u * DT;  y += v * DT;

    // the same test the real shot uses, with a slightly tighter window
    if (py < RIM_Y && y >= RIM_Y && Math.abs(x - CX) <= 35) onTarget = true;

    if (n % 5 === 4 && k < dots.length) {
      place(dots[k], x, y, 0.95 - k * 0.03);
      k++;
    }
    if (v > 0 && y > RIM_Y + 50) break;         // only stop once it is FALLING past the rim
    if (x < 0 || x > WW) break;
  }
  showAim(vx, vy, onTarget);                    // arrow turns green when onTarget
}

The arrow turns green when the preview crosses the rim line inside the middle of the hoop, so the player learns the aim without reading any text. The stopping test deserves a note. The first version of this loop stopped as soon as the path was below the rim, but the file starts below the rim, so the loop ended on its first step and no dots were ever drawn. The fix is to stop only when the file is moving downward and has passed the rim, which is what the v greater than zero check does.

Why preview with the real step?

A preview that uses its own formula drifts from the real flight the first time somebody changes gravity, the assist, or the scoring window. Here the dots, the green test, and the flight all call the same acceleration function and the same gravity, so one edit changes all three.

05
Part Five
The Flight, the Rim, and the Miss

Step 5 — A Small Loop with Gravity, Two Rim Points, and a Floor

The flight runs inside requestAnimationFrame. Each frame the time step is measured and capped at 0.033 seconds, so a slow frame cannot make the file jump through the hoop. Vertical speed gets gravity added first, and then the position moves by the new speed. The file also spins at a rate that depends on its sideways speed, and the spin is switched off for people who prefer reduced motion.

JavaScript
// soft pull toward the middle of the hoop while the file falls through it
function accel(x, y, vy) {
  if (vy > 0 && y > RIM_Y - 54 && y < RIM_Y + 6) {
    var dx = CX - x;
    if (Math.abs(dx) < 60) return dx * 5;
  }
  return 0;
}

s.vx += accel(s.x, s.y, s.vy) * dt;
s.vy += G * dt;
var py = s.y;
s.x += s.vx * dt;  s.y += s.vy * dt;  s.rot += s.w * dt;
collide();

The assist is the small accel function. When the file is falling and within 60 units of the hoop centre, it is nudged toward the centre in proportion to how far off it is. That is only enough to forgive a pull that is a pixel or two out, and since the preview uses the same function, it never contradicts what the arrow showed.

Collisions are only against the two ends of the rim. Each end is a small circle of radius 4, the file counts as a circle of radius 11, and when they overlap the file is pushed out along the line between the centres and its velocity is reflected with a bounce of 0.55. A file that lands on the rim end rolls off to one side, and a file that comes down the middle touches neither.

JavaScript
function push(nx, ny, pen) {
  s.x += nx * pen;  s.y += ny * pen;
  var vn = s.vx * nx + s.vy * ny;               // speed into the surface
  if (vn < 0) {
    s.vx -= 1.55 * vn * nx;                     // 1 + restitution 0.55
    s.vy -= 1.55 * vn * ny;
    s.w *= -0.6;                                // knock the spin around
  }
}

function hit(px, py, r) {
  var dx = s.x - px, dy = s.y - py, d = Math.hypot(dx, dy), m = R + r;
  if (d < m && d > 0.001) push(dx / d, dy / d, m - d);
}

function collide() { hit(RIM_L, RIM_Y, 4); hit(RIM_R, RIM_Y, 4); }
The backboard is only decoration

An early version made the backboard solid. The board sits straight above the rim, so every shot that fell onto the rim from above had to pass through it, and it bounced away before it could score, even though the arrow was green. A rising shot also clipped the board on the way up. The board is drawn face-on and sits behind the file, so now it does not collide at all and only the two rim points do.

Everything else is a miss. The walls return half of the speed, the floor bounces at 0.45 and drags the sideways speed, and when the file has settled on the floor or three and a half seconds have passed, the page says Off the rim and glides the file home over half a second with an ease-out curve, spinning it back to upright by the shortest way round.

06
Part Six
A Basket Becomes an Upload

Step 6 — A Line Crossing Decides Everything

A basket is not a collision. It is a line crossing. On every frame the code remembers the previous height, and if the file was above the rim line and is now at or below it, moving down, with its centre within 46 units of the hoop centre, it counts. A shot that comes up through the net from below cannot score because it was never above the line, and a shot that rattles around the rim and drops in later still scores the moment it crosses.

JavaScript
if (py < RIM_Y && s.y >= RIM_Y && s.vy > 0 && Math.abs(s.x - CX) <= 46) score();

function score() {
  s.scored = true;
  s.vx *= 0.2;
  s.vy = Math.max(s.vy * 0.55, 120);
  fileEl.style.zIndex = '2';                    // drop UNDER the hoop svg (z-index 3)
  zone.classList.add('hot');
  swish();  popPlus();

  var f = take();                               // next queued file, or the demo file
  addUpload(f);
  say('Swish. ' + f.name + ' is uploading.');
}

Scoring does five things at once. It slows the file down so it drops gently, lowers its stacking order below the hoop SVG so the net lines and the rim are drawn over it, tints the drop zone orange, plays the swish and the +1, and takes the next file from the queue to add to the list. After that a simpler loop takes over: the file eases to the centre line, falls to a resting height under the net, waits 650 milliseconds, fades out over 0.3 seconds, and reappears at home with a small scale-in and the next file name underneath it.

JavaScript
function addUpload(f) {
  total++;  bumpCount();

  var li = document.createElement('li');
  li.className = 'item';
  li.innerHTML = '<div class="ficon">' + fileSvg(extOf(f.name)) + '</div>' +
    '<div class="meta"><b></b><span></span></div>' +
    '<div class="prog"><span class="state">Uploading… 0%</span>' +
    '<div class="bar"><i></i></div></div>' +
    '<div class="check"> ... </div>';

  li.querySelector('b').textContent = f.name;              // text, never markup
  li.querySelector('.meta span').textContent = fmt(f.size);
  list.prepend(li);
  while (list.children.length > 2) list.removeChild(list.lastChild);

  var bar = li.querySelector('.bar i'), st = li.querySelector('.state');
  var dur = 1100 + Math.random() * 900, t0 = performance.now();

  (function tick(now) {
    var p = Math.min(1, (now - t0) / dur), e = 1 - Math.pow(1 - p, 2);
    bar.style.transform = 'scaleX(' + e + ')';
    st.textContent = p < 1 ? 'Uploading… ' + Math.round(e * 100) + '%' : 'Uploaded';
    if (p < 1) requestAnimationFrame(tick); else li.classList.add('done');
  })(t0);
}

The card is built from a fixed template, and the file name is set afterwards with textContent, so a file called with angle brackets in its name cannot inject markup. The bar is a full-width element scaled from the left with scaleX, which animates on the compositor and never has to lay out again. Only the latest two cards are kept on screen, and the badge in the corner keeps the true total. When the bar finishes, the done class fades in the green check and turns the label green.

07
Part Seven
Real Files, Keyboard, and Themes

Step 7 — Dropping, Browsing, Auto Shot, and Light and Dark

The page accepts real files in two ways. Anything dragged over the page lights up the drop zone, and dropping adds a card for each file with its true name and size, plus a +1 and a swish. The Browse files button opens a hidden file input and puts the chosen files in a queue instead. The next shots then carry those files, so the name under the waiting paper changes to the first queued file, and the demo file, dummy_file.pdf at 2.4 MB, is only used when the queue is empty. A page-level listener cancels the default drop so a file that misses the stage does not make the browser navigate away.

JavaScript
function hasFiles(e) {
  var t = e.dataTransfer && e.dataTransfer.types;
  return !!t && Array.prototype.indexOf.call(t, 'Files') >= 0;
}

stage.addEventListener('dragover', function (e) {
  if (!hasFiles(e)) return;
  e.preventDefault();  zone.classList.add('hot');
});

stage.addEventListener('drop', function (e) {
  if (!hasFiles(e)) return;
  e.preventDefault();
  var fs = Array.prototype.slice.call(e.dataTransfer.files);
  fs.forEach(addUpload);
  swish();  popPlus();
});

window.addEventListener('dragover', function (e) { if (hasFiles(e)) e.preventDefault(); });
window.addEventListener('drop',     function (e) { if (hasFiles(e)) e.preventDefault(); });

Pulling a paper back is not possible with the keyboard, so the file is a focusable button with a visible outline. Pressing Enter or Space, or choosing Take the shot for me, fires a shot with the exact velocity that reaches a point 62 units above the rim and falls into the middle. The status line under the court is an aria-live region, so a screen reader hears Swish and Off the rim as they happen.

CSS
:root { --panel: #edf0f5; --card: #ffffff; --ink: #131a23; --orange: #e2723a; /* ... */ }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --panel: #151a21; --card: #1d242d; --ink: #edf0f4; --orange: #ef8450; /* ... */
    color-scheme: dark;
  }
}
:root[data-theme="dark"] { --panel: #151a21; /* same dark values */ color-scheme: dark; }

@media (prefers-reduced-motion: reduce) {
  .file.idle .inner, .net.swish, .pop.go, .item { animation: none; }
}

Every colour is a token, defined for light on the root, redefined for dark when the system asks for it, and redefined again when a page theme is set by hand, so the page works in all three states. The paper, the net, the check, and the shadows all read from the same tokens. With reduced motion on, the bobbing, the swish, the pop, and the card entrance are switched off, and the file spins no more, while the shot itself still works.

Tuning Reference

ValueExampleEffect
hsclamp(min(W/430, H/680), 0.75, 1.3)Scale from world units to screen pixels
G1000Gravity in world units per second squared; higher makes shots snappier and flatter
MAXPULLclamp(HH * 0.26, 120, 210)Longest drag, and so the size of the area you can pull in
Kideal speed * 1.35 / MAXPULLLaunch speed per unit of pull; the 1.35 sets how much of the pull a perfect shot uses
R11Radius of the file as a ball for rim bounces
Rim point radius / bounce4 / 0.55Size and springiness of the two rim ends
Assistdx * 5 within 60 unitsHow strongly a falling file is drawn to the hoop centre
Score window46 (preview uses 35)How far from the centre a crossing may be and still count
Floor bounce / friction0.45 / 0.78How a missed file settles
Upload time1100 to 2000 msLength of the progress bar animation

Full Source Code

The whole project is a single self-contained HTML file with its markup, styles, and script together and no build step. The only external request is the Google Fonts stylesheet for the Figtree typeface, and the page falls back to the system font if it does not load. It needs a browser with pointer events and ResizeObserver, which means any current Chrome, Edge, Safari, or Firefox.

HTML — basketball-style-file-upload.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/basketball-style-file-upload.html
Want to try it yourself?Open the live basketball-style-file-upload.html file