№ 019
Drop Loader
A download card whose progress is a string of glass beads: the front bead stretches a liquid bridge to the next, and cancelling bursts the string into droplets.
- Version
- 1.0.0
- Download
- 167 KB · 10 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- progress, download, glass, liquid, loader, web audio
Usage
Copy drop-loader.css and drop-loader.js (and assets/, if there is one) into your project, then:
const dl = new DropLoader(element, {
file: { name: 'Lake_Moments.mp4', size: '1.8 GB', thumb: 'url' }, // thumb optional (a lake is drawn)
beads: 14,
sound: true, // water drops while loading, glass shattering on cancel (Web Audio, synthesised)
assets: 'assets/', // folder of the glass images, or a map { 'emerald-1.webp': url, ... }
onPause() {}, onResume() {}, onCancel() {}, onRestart() {}, onOpen() {},
})
dl.set(0.67) // 0..1; reaching 1 completes
dl.set(0.67, { instant: true }) // jump without animating
dl.pause(); dl.resume(); dl.cancel(); dl.restart(); dl.setSound(false)
dl.state // 'active' | 'paused' | 'cancelled' | 'complete'
A download card whose progress is a string of glass beads. Filled beads are emerald, ripening from
citron at the front. The front bead stretches a liquid bridge across to the next empty one; when the
liquid reaches it, it fills with a small plop and springs into line, and the next bridge starts.
Cancelling bursts the string into a spray of droplets; restarting pulls them back together.
Everything moves from one clock (step(now)), never from timers, so the card can be replayed frame by frame.Source
/* Drop Loader — a download card whose progress is a string of glass beads. */
.dl {
--dl-ink: #16261b;
--dl-muted: #5c655d;
--dl-line: rgba(22, 38, 27, 0.14);
--dl-serif: "Playfair Display", "Bodoni Moda", Georgia, serif;
--dl-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
position: relative;
container-type: inline-size;
box-sizing: border-box;
width: 100%;
max-width: 680px;
padding: clamp(20px, 6cqw, 48px) clamp(18px, 5.5cqw, 44px) clamp(18px, 5cqw, 38px);
border-radius: clamp(18px, 4.5cqw, 30px);
color: var(--dl-ink);
font-family: var(--dl-sans);
/* a pane of frosted glass: mostly see-through, brighter along the top-left edge where the light catches it */
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.42) 0%, rgba(255, 255, 255, 0.1) 38%, rgba(255, 255, 255, 0.04) 62%, rgba(255, 255, 255, 0.2) 100%),
rgba(232, 238, 228, 0.16);
border: 1px solid rgba(255, 255, 255, 0.55);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.95), /* lit top edge */
inset 1px 0 0 rgba(255, 255, 255, 0.55), /* lit left edge */
inset 0 -1px 0 rgba(255, 255, 255, 0.25),
inset 0 0 32px rgba(255, 255, 255, 0.22), /* the glass's own thickness, glowing at the rim */
0 1px 2px rgba(30, 45, 30, 0.08),
0 26px 60px -24px rgba(30, 45, 30, 0.38);
-webkit-backdrop-filter: blur(14px) saturate(1.5) brightness(1.05);
backdrop-filter: blur(14px) saturate(1.5) brightness(1.05);
isolation: isolate;
}
/* a soft diagonal reflection across the pane, behind the content */
.dl::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
background:
linear-gradient(118deg, transparent 18%, rgba(255, 255, 255, 0.4) 26%, rgba(255, 255, 255, 0.08) 34%, transparent 40%),
linear-gradient(118deg, transparent 58%, rgba(255, 255, 255, 0.14) 63%, transparent 68%);
}
/* fine frost on the glass */
.dl::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
opacity: 0.35;
mix-blend-mode: soft-light;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
}
.dl *, .dl *::before, .dl *::after { box-sizing: border-box; }
/* ── heading ── */
.dl-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.dl-titles { flex: 1; min-width: 0; }
.dl-title {
margin: 0;
font: 500 clamp(24px, 8.6cqw, 64px) / 1.02 var(--dl-serif);
letter-spacing: -0.03em;
text-wrap: balance;
}
.dl-sub {
margin: clamp(6px, 2cqw, 14px) 0 0;
font-size: clamp(9px, 1.95cqw, 14px);
font-weight: 500;
letter-spacing: 0.32em;
text-transform: uppercase;
color: var(--dl-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dl-pct {
flex: none;
font: 500 clamp(28px, 13cqw, 100px) / 0.9 var(--dl-serif);
letter-spacing: -0.04em;
font-variant-numeric: lining-nums tabular-nums;
transition: opacity 240ms ease;
}
.dl-sign { font-size: 0.62em; letter-spacing: 0; margin-left: 0.02em; }
/* cancelled or complete: the title says it, and gets the full width */
.dl[data-state="cancelled"] .dl-pct,
.dl[data-state="complete"] .dl-pct { display: none; }
/* ── the string of beads ── */
.dl-track {
position: relative;
height: calc(var(--d, 30px) * 1.4);
margin: clamp(16px, 6cqw, 40px) 0 clamp(16px, 5.5cqw, 36px);
}
.dl-thread {
position: absolute;
left: 0;
top: 0;
height: 2px;
margin-top: -1px;
border-radius: 2px;
background: rgba(160, 166, 160, 0.55);
}
.dl-thread-fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: inherit;
background: linear-gradient(90deg, #1f6b33, #5c9a2c 70%, #a9b827);
}
.dl-beads,
.dl-spray {
position: absolute;
inset: 0;
pointer-events: none;
}
.dl-bead {
position: absolute;
left: 0;
top: 0;
width: var(--d, 30px);
height: var(--d, 30px);
border-radius: 50%;
will-change: transform;
/* soft contact shadow, down and to the right */
box-shadow: calc(var(--d, 30px) * 0.04) calc(var(--d, 30px) * 0.09) calc(var(--d, 30px) * 0.16) rgba(30, 50, 30, 0.16);
}
.dl-bead img,
.dl-bridge img,
.dl-drop {
display: block;
user-select: none;
-webkit-user-drag: none;
}
.dl-bead img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.dl-bridge {
position: absolute;
left: 0;
top: 0;
opacity: 0;
will-change: transform;
}
.dl-bridge img { width: 100%; height: 100%; }
.dl-pour {
position: absolute;
left: 0;
top: 0;
width: var(--d, 30px);
height: var(--d, 30px);
opacity: 0;
}
.dl-drop {
position: absolute;
left: 0;
top: 0;
will-change: transform;
}
/* ── the file ── */
.dl-file {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: clamp(10px, 3.6cqw, 24px);
}
.dl-thumb {
width: clamp(44px, 13.5cqw, 104px);
aspect-ratio: 1;
overflow: hidden;
border-radius: clamp(8px, 2.6cqw, 16px);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.8), 0 6px 16px -6px rgba(30, 50, 30, 0.35);
}
.dl-thumb svg,
.dl-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
.dl-name {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: clamp(12px, 3cqw, 24px);
font-weight: 500;
}
.dl-meta {
display: flex;
gap: 0.7em;
margin: 0.35em 0 0;
font-size: clamp(10px, 2.2cqw, 17px);
letter-spacing: 0.08em;
color: var(--dl-muted);
white-space: nowrap;
overflow: hidden;
}
.dl-meta > :last-child { overflow: hidden; text-overflow: ellipsis; }
.dl-actions { display: flex; gap: clamp(8px, 2cqw, 14px); }
.dl-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.75em;
height: clamp(34px, 8cqw, 60px);
padding: 0 clamp(12px, 3cqw, 26px);
border: 1px solid rgba(22, 38, 27, 0.2);
border-radius: 999px;
background: rgba(255, 255, 255, 0.32);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
color: var(--dl-ink);
font: 500 clamp(12px, 2.5cqw, 18px) / 1 var(--dl-sans);
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease, transform 120ms ease;
}
.dl-btn svg { width: 1.25em; height: 1.25em; flex: none; }
.dl-btn:hover { background: rgba(255, 255, 255, 0.62); border-color: rgba(22, 38, 27, 0.32); }
.dl-btn:active { transform: scale(0.97); }
.dl-btn:focus-visible { outline: 2px solid var(--dl-ink); outline-offset: 3px; }
.dl-btn[hidden] { display: none; }
.dl-live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* narrow cards: round icon buttons, as on a phone */
@container (max-width: 540px) {
.dl-btn { width: clamp(34px, 10.5cqw, 48px); height: clamp(34px, 10.5cqw, 48px); padding: 0; }
.dl-btn .dl-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.dl-btn svg { width: 42%; height: 42%; }
}
@media (prefers-reduced-motion: reduce) {
.dl-btn, .dl-pct { transition: none; }
} /* Drop Loader — usage:
* const dl = new DropLoader(element, {
* file: { name: 'Lake_Moments.mp4', size: '1.8 GB', thumb: 'url' }, // thumb optional (a lake is drawn)
* beads: 14,
* sound: true, // water drops while loading, glass shattering on cancel (Web Audio, synthesised)
* assets: 'assets/', // folder of the glass images, or a map { 'emerald-1.webp': url, ... }
* onPause() {}, onResume() {}, onCancel() {}, onRestart() {}, onOpen() {},
* })
* dl.set(0.67) // 0..1; reaching 1 completes
* dl.set(0.67, { instant: true }) // jump without animating
* dl.pause(); dl.resume(); dl.cancel(); dl.restart(); dl.setSound(false)
* dl.state // 'active' | 'paused' | 'cancelled' | 'complete'
*
* A download card whose progress is a string of glass beads. Filled beads are emerald, ripening from
* citron at the front. The front bead stretches a liquid bridge across to the next empty one; when the
* liquid reaches it, it fills with a small plop and springs into line, and the next bridge starts.
* Cancelling bursts the string into a spray of droplets; restarting pulls them back together.
*
* Everything moves from one clock (step(now)), never from timers, so the card can be replayed frame by frame.
*/
(function () {
const SCRIPT_BASE = document.currentScript ? new URL('assets/', document.currentScript.src).href : 'assets/';
const PITCH = 1.16; // bead spacing, × bead diameter
const GAP = 0.5; // extra room the bridge stretches across, right after the front bead (× diameter)
const BRIDGE = { left: 0.165, right: 0.829, bulb: 0.99 }; // bulb centres and bulb height, as shares of the bridge image
const RIPEN = 6.5; // beads behind the front still ripening from citron through lime to emerald
const REACH = 0.55; // share of each bead's time the liquid takes to reach the next bead; then it fills it
const SPRING = { k: 320, c: 15 }; // bead positions: a slightly bouncy settle when a bead snaps into line
const PLOP = { k: 420, c: 13 }; // bead scale after it fills
const FOLLOW = 5.5; // how quickly the shown value catches up with the real one (1/s)
const SNAP_FADE = 0.14; // s: the old bridge fading as its bead fills
const BURST_SETTLE = 7; // droplet drag after a cancel (1/s): they spray out and settle within ~0.7 s
const REFORM = 0.55; // s: droplets rushing back on restart
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const smooth = (t) => t * t * (3 - 2 * t);
const easeOut = (t) => 1 - (1 - t) ** 3;
const easeInOut = (t) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);
let uid = 0;
// mulberry32: the burst is the same every time for the same bead count
const rng = (seed) => () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const ICON = {
pause: '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7 4.5v11M13 4.5v11" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>',
play: '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M6.5 4.3v11.4a.6.6 0 0 0 .9.5l9-5.7a.6.6 0 0 0 0-1l-9-5.7a.6.6 0 0 0-.9.5z" fill="currentColor"/></svg>',
cancel: '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5L5 15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
restart: '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4.5 10a5.5 5.5 0 1 0 1.7-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M5.2 2.8l.9 3.4 3.4-.9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
open: '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M11 4h5v5M16 4l-7 7M14 12v3.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5H8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>',
};
// A lake at dusk, for when no thumbnail is given.
const LAKE = (id) => `<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<defs>
<linearGradient id="${id}s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cfdde6"/><stop offset=".55" stop-color="#eef0e6"/><stop offset="1" stop-color="#f6efdc"/></linearGradient>
<linearGradient id="${id}w" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9fb6b8"/><stop offset="1" stop-color="#4f6b66"/></linearGradient>
</defs>
<rect width="100" height="100" fill="url(#${id}s)"/>
<path d="M0 50 L18 34 L30 42 L46 26 L62 40 L74 32 L100 48 V62 H0Z" fill="#9aae9f"/>
<path d="M0 56 L14 46 L28 52 L44 40 L58 50 L72 44 L86 50 L100 46 V64 H0Z" fill="#6f8a74"/>
<path d="M0 60 L10 54 L22 58 L36 52 L52 59 L66 54 L82 60 L100 56 V66 H0Z" fill="#4c6a52"/>
<rect y="64" width="100" height="36" fill="url(#${id}w)"/>
<path d="M0 64 L10 70 L22 67 L36 72 L52 66 L66 71 L82 66 L100 70 V64Z" fill="#3f5a45" opacity=".55"/>
<path d="M8 78h22M40 84h30M62 76h18M14 90h26" stroke="#dfe8e4" stroke-width=".8" stroke-linecap="round" opacity=".55"/>
<path d="M78 66 l4-14 l4 14z M86 66 l3-10 l3 10z M70 66 l3-9 l3 9z" fill="#2f4a36"/>
<path d="M2 66 l4-12 l4 12z M9 66 l3-8 l3 8z" fill="#2f4a36"/>
</svg>`;
/* ── sound: glass and water ── */
class GlassSound {
// Created lazily, and only after the page has had a user gesture, so it never trips autoplay rules.
ensure() {
if (!this.ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC || (navigator.userActivation && !navigator.userActivation.hasBeenActive)) return null;
const ctx = (this.ctx = new AC());
// A soft limiter instead of a compressor: Web Audio's compressor adds its own make-up gain and is
// too slow for a glass crack, so peaks got through. This curve is ~unity-gain-times-1.9 when quiet
// and bends smoothly toward a ceiling of 0.92, so nothing can ever clip.
const limit = ctx.createWaveShaper();
const curve = new Float32Array(2048);
for (let i = 0; i < curve.length; i++) { const x = (i / (curve.length - 1)) * 2 - 1; curve[i] = (0.92 * Math.tanh(2 * x)) / Math.tanh(2); }
limit.curve = curve;
limit.connect(ctx.destination);
this.out = ctx.createGain();
this.out.gain.value = 0.65;
this.out.connect(limit);
}
if (this.ctx.state === 'suspended') this.ctx.resume();
return this.ctx;
}
// a small glass partial set: bright, short, slightly inharmonic
ping(t, f, gain, decay) {
const ctx = this.ctx;
for (const [r, a, d] of [[1, 1, 1], [2.32, 0.35, 0.55], [4.25, 0.12, 0.3]]) {
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'sine';
o.frequency.value = f * r;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain * a, t + 0.004);
g.gain.exponentialRampToValueAtTime(0.0001, t + decay * d);
o.connect(g).connect(this.out);
o.start(t);
o.stop(t + decay * d + 0.05);
}
}
// filtered noise with a decaying envelope (needs a shared noise buffer)
noise(t, type, freq, q, gain, decay) {
const ctx = this.ctx;
if (!this.buf) {
this.buf = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
const d = this.buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
}
const src = ctx.createBufferSource(), f = ctx.createBiquadFilter(), g = ctx.createGain();
src.buffer = this.buf;
f.type = type;
f.frequency.value = freq;
f.Q.value = q;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.002);
g.gain.exponentialRampToValueAtTime(0.0001, t + decay);
src.connect(f).connect(g).connect(this.out);
src.start(t, Math.random() * 0.8);
src.stop(t + decay + 0.02);
}
// A water drop: the "plip" of a drop is a tiny air bubble ringing at a pitch that rises fast as it
// shrinks. A sine swept up over ~40 ms, with a whisper of splash on top.
bloop(t, f, gain, size = 1) {
const ctx = this.ctx, o = ctx.createOscillator(), g = ctx.createGain();
const len = 0.045 * size;
o.type = 'sine';
o.frequency.setValueAtTime(f, t);
o.frequency.exponentialRampToValueAtTime(f * 2.6, t + len);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.004);
g.gain.setValueAtTime(gain * 0.8, t + len * 0.5);
g.gain.exponentialRampToValueAtTime(0.0001, t + len * 1.6);
o.connect(g).connect(this.out);
o.start(t);
o.stop(t + len * 1.6 + 0.03);
this.noise(t, 'bandpass', 3200, 1.2, gain * 0.18, 0.018);
}
// a bead fills: a full water drop landing in it, then its glass rings softly.
// The drop's pitch climbs a pentatonic scale along the string.
fill(i) {
const ctx = this.ensure();
if (!ctx) return;
const t = ctx.currentTime + 0.005;
const scale = [0, 2, 4, 7, 9];
const semi = scale[i % 5] + 12 * Math.floor(i / 5);
const f = 392 * 2 ** (semi / 12);
this.bloop(t, f, 0.42, 1.4);
this.bloop(t + 0.07, f * 1.5, 0.12, 0.8); // the little rebound drop
this.ping(t + 0.02, f * 2, 0.035, 0.7);
}
// while the liquid flows: a small, quiet drip
drip() {
const ctx = this.ensure();
if (!ctx) return;
const t = ctx.currentTime + 0.005;
this.bloop(t, 700 + Math.random() * 500, 0.15 + Math.random() * 0.07, 0.7 + Math.random() * 0.4);
}
// Glass shattering: a hard crack, a fizz of fracturing glass, then shards ringing and
// tinkling as they scatter and land, thinning out over almost a second.
shatter() {
const ctx = this.ensure();
if (!ctx) return;
const t = ctx.currentTime + 0.005;
this.noise(t, 'highpass', 1800, 0.7, 0.9, 0.045); // the crack
this.noise(t, 'bandpass', 900, 0.8, 0.35, 0.05); // its body
this.noise(t + 0.004, 'bandpass', 5200, 0.9, 0.32, 0.32); // fizz of the glass giving way
this.noise(t + 0.01, 'highpass', 7000, 0.7, 0.14, 0.5);
const o = ctx.createOscillator(), g = ctx.createGain(); // a little weight under it
o.frequency.setValueAtTime(220, t);
o.frequency.exponentialRampToValueAtTime(90, t + 0.08);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.3, t + 0.003);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.12);
o.connect(g).connect(this.out);
o.start(t);
o.stop(t + 0.15);
// shards: bright inharmonic rings, dense at first, then the odd one landing
for (let i = 0; i < 34; i++) {
const at = t + 0.01 + Math.random() ** 2.2 * 0.95;
const f = 2200 + Math.random() * 5200;
const late = (at - t) / 0.95;
this.ping(at, f, (0.05 + Math.random() * 0.06) * (1 - late * 0.6), 0.08 + Math.random() * 0.22);
if (Math.random() < 0.5) this.noise(at, 'highpass', 4000, 0.7, 0.08 * (1 - late * 0.5), 0.006); // tick of a shard landing
}
}
// done: three notes up, ringing
chime() {
const ctx = this.ensure();
if (!ctx) return;
const t = ctx.currentTime + 0.01;
[1046.5, 1318.5, 1568].forEach((f, i) => this.ping(t + i * 0.09, f, 0.08, 1.6));
}
// pause / resume: one soft tone down or up
blip(up) {
const ctx = this.ensure();
if (!ctx) return;
const t = ctx.currentTime + 0.005;
this.ping(t, up ? 880 : 660, 0.05, 0.35);
}
// restart: droplets rushing back, a quick rising run
gather() {
const ctx = this.ensure();
if (!ctx) return;
const t = ctx.currentTime + 0.005;
for (let i = 0; i < 8; i++) this.bloop(t + i * 0.045, 420 + i * 90, 0.07);
}
}
/* ── the component ── */
class DropLoader {
constructor(root, opts = {}) {
this.root = root;
this.opts = opts;
this.n = opts.beads || 14;
this.file = Object.assign({ name: 'Lake_Moments.mp4', size: '1.8 GB' }, opts.file);
this.assetMap = opts.assets && typeof opts.assets === 'object' ? opts.assets : null;
this.base = typeof opts.assets === 'string' ? new URL(opts.assets, location.href).href.replace(/\/?$/, '/') : SCRIPT_BASE;
this.soundOn = opts.sound !== false;
this.sound = new GlassSound();
this.id = 'dl' + ++uid;
this.state = 'active';
this.target = 0; // the real progress, 0..1
this.shown = 0; // what the beads show, easing toward target
this.k = 0; // beads fully filled
this.particles = [];
this.last = null;
this.raf = 0;
this.build();
this.layout();
// Wake the audio on the first click or key anywhere: a download often starts (and drips, and
// fills beads) before anyone has touched the page, and browsers only allow sound after that.
const unlock = () => {
if (this.soundOn && this.sound.ensure()) {
document.removeEventListener('pointerdown', unlock, true);
document.removeEventListener('keydown', unlock, true);
}
};
document.addEventListener('pointerdown', unlock, true);
document.addEventListener('keydown', unlock, true);
this.beads.forEach((b, i) => { b.x = b.tx = this.targetX(i); });
this.paint();
new ResizeObserver(() => { this.layout(); this.wake(); }).observe(this.track);
}
url(file) { return this.assetMap ? this.assetMap[file] : this.base + file; }
// the component's clock; a frame-by-frame replay (a reel) can supply its own
clock() { return performance.now(); }
build() {
const r = this.root;
r.classList.add('dl');
r.setAttribute('role', 'group');
r.setAttribute('aria-label', `Download: ${this.file.name}`);
r.innerHTML = `
<div class="dl-head">
<div class="dl-titles">
<h2 class="dl-title"></h2>
<p class="dl-sub"></p>
</div>
<div class="dl-pct" aria-hidden="true"><span class="dl-num">0</span><span class="dl-sign">%</span></div>
</div>
<div class="dl-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-label="Download progress">
<div class="dl-thread"><i class="dl-thread-fill"></i></div>
<div class="dl-beads"></div>
<div class="dl-spray"></div>
</div>
<div class="dl-file">
<div class="dl-thumb">${this.file.thumb ? `<img src="${this.file.thumb}" alt="">` : LAKE(this.id)}</div>
<div class="dl-info">
<p class="dl-name"></p>
<p class="dl-meta"><span class="dl-size"></span><span class="dl-dot" aria-hidden="true">•</span><span class="dl-status"></span></p>
</div>
<div class="dl-actions">
<button type="button" class="dl-btn dl-primary"></button>
<button type="button" class="dl-btn dl-cancel">${ICON.cancel}<span class="dl-label">Cancel</span></button>
</div>
</div>
<p class="dl-live" aria-live="polite"></p>`;
const q = (s) => r.querySelector(s);
Object.assign(this, {
titleEl: q('.dl-title'), subEl: q('.dl-sub'), numEl: q('.dl-num'), pctEl: q('.dl-pct'),
track: q('.dl-track'), threadFill: q('.dl-thread-fill'), beadLayer: q('.dl-beads'), spray: q('.dl-spray'),
statusEl: q('.dl-status'), primary: q('.dl-primary'), cancelBtn: q('.dl-cancel'), live: q('.dl-live'),
});
q('.dl-name').textContent = this.file.name;
q('.dl-size').textContent = this.file.size;
const img = (file, cls) => {
const el = document.createElement('img');
el.src = this.url(file);
el.alt = '';
el.draggable = false;
if (cls) el.className = cls;
return el;
};
this.img = img;
// each bead stacks its three looks: clear (empty), emerald, citron; opacity picks the blend
this.beads = Array.from({ length: this.n }, () => {
const el = document.createElement('div');
el.className = 'dl-bead';
const clear = img('clear-1.webp'), emerald = img('emerald-1.webp'), citron = img('citron-1.webp');
el.append(clear, emerald, citron);
this.beadLayer.appendChild(el);
return { el, clear, emerald, citron, x: 0, v: 0, tx: 0, s: 1, sv: 0, fill: 0 };
});
// two bridges, so the old one can fade while the next one starts
this.bridges = [0, 1].map(() => {
const el = document.createElement('div');
el.className = 'dl-bridge';
el.appendChild(img('bridge.webp'));
this.beadLayer.appendChild(el);
return { el, seg: -1, fade: 0 };
});
this.bi = 0; // which bridge is the live one
// the liquid arriving in the next bead, drawn over the bridge's clear bulb
this.pour = img('citron-1.webp', 'dl-pour');
this.beadLayer.appendChild(this.pour);
this.primary.addEventListener('click', () => {
const act = this.primary.dataset.act;
if (act === 'pause') { this.pause(); this.opts.onPause?.(); }
else if (act === 'resume') { this.resume(); this.opts.onResume?.(); }
else if (act === 'restart') { this.restart(); this.opts.onRestart?.(); }
else if (act === 'open') this.opts.onOpen?.();
});
this.cancelBtn.addEventListener('click', () => { this.cancel(); this.opts.onCancel?.(); });
this.renderText();
}
layout() {
const w = this.track.clientWidth || 500;
// n beads at PITCH, plus the bridge's gap, fill the width exactly
this.d = w / ((this.n - 1) * PITCH + 1 + GAP);
this.track.style.setProperty('--d', this.d + 'px');
this.cy = (this.d * 1.4) / 2;
}
// where bead i belongs: a gap after the front bead while a bridge is live, none once complete
targetX(i) {
const { d } = this;
const gapAfter = this.state === 'complete' ? Infinity : Math.max(1, this.k);
const spread = this.state === 'complete' ? (this.track.clientWidth - d) / (this.n - 1) / d : PITCH;
return d / 2 + i * spread * d + (i >= gapAfter ? GAP * d : 0);
}
/* ── public API ── */
set(p, { instant = false } = {}) {
if (this.state === 'cancelled' || this.state === 'complete') return; // restart() first
this.target = clamp(p, 0, 1);
if (instant) {
this.shown = this.target;
this.k = Math.floor(this.shown * this.n + 1e-9);
if (this.target >= 1) this.setState('complete', true);
this.beads.forEach((b, i) => { b.x = b.tx = this.targetX(i); b.v = 0; });
this.paint();
}
this.renderText();
this.wake();
}
pause() {
if (this.state !== 'active') return;
this.setState('paused');
if (this.soundOn) this.sound.blip(false);
}
resume() {
if (this.state !== 'paused') return;
this.setState('active');
if (this.soundOn) this.sound.blip(true);
}
cancel() {
if (this.state === 'cancelled' || this.state === 'complete') return;
this.setState('cancelled');
this.burst();
if (this.soundOn) this.sound.shatter();
}
restart() {
this.target = 0;
this.reformFrom = this.state === 'cancelled' ? this.clock() : null;
if (this.reformFrom !== null && this.soundOn) this.sound.gather();
this.shown = 0;
this.k = 0;
this.bridges.forEach((b) => { b.seg = -1; b.fade = 0; });
this.rippleFrom = null;
this.setState('active');
this.beads.forEach((b, i) => { b.tx = this.targetX(i); });
this.wake();
}
setSound(on) { this.soundOn = !!on; }
/* ── state and text ── */
setState(s, quiet) {
const was = this.state;
this.state = s;
this.root.dataset.state = s;
this.renderText();
if (!quiet && was !== s) {
this.live.textContent = { paused: 'Download paused', active: was === 'paused' ? 'Download resumed' : 'Download started', cancelled: 'Download cancelled', complete: 'Download complete' }[s];
if (!reducedMotion.matches) this.titleEl.animate([{ opacity: 0, transform: 'translateY(4px)' }, { opacity: 1, transform: 'none' }], { duration: 260, easing: 'cubic-bezier(.2,.8,.3,1)' });
}
this.wake();
}
renderText() {
const p = this.target, s = this.state;
const title = s === 'paused' ? 'Download paused' : s === 'cancelled' ? 'Download cancelled' : s === 'complete' ? 'Download complete'
: p < 0.1 ? 'Starting download' : p < 0.5 ? 'On its way' : p < 0.95 ? 'Almost there' : 'Just a moment';
const sub = { active: 'Downloading your file', paused: 'Resume anytime', cancelled: 'Nothing was saved', complete: 'Saved to your device' }[s];
const status = { active: 'Downloading…', paused: 'Paused', cancelled: 'Cancelled', complete: 'Complete' }[s];
if (this.titleEl.textContent !== title) this.titleEl.textContent = title;
this.subEl.textContent = sub;
this.statusEl.textContent = status;
const act = { active: 'pause', paused: 'resume', cancelled: 'restart', complete: 'open' }[s];
if (this.primary.dataset.act !== act) {
this.primary.dataset.act = act;
const label = { pause: 'Pause', resume: 'Resume', restart: 'Restart', open: 'Open' }[act];
this.primary.innerHTML = `${ICON[{ pause: 'pause', resume: 'play', restart: 'restart', open: 'open' }[act]]}<span class="dl-label">${label}</span>`;
this.primary.setAttribute('aria-label', `${label} download`);
}
this.cancelBtn.hidden = s === 'cancelled' || s === 'complete';
this.cancelBtn.setAttribute('aria-label', 'Cancel download');
const pct = Math.round(p * 100);
this.track.setAttribute('aria-valuenow', s === 'cancelled' ? 0 : pct);
this.track.setAttribute('aria-valuetext', s === 'cancelled' ? 'Cancelled' : `${pct} percent, ${status.replace('…', '').toLowerCase()}`);
}
/* ── the loop ── */
wake() {
if (this.raf) return;
this.last = null;
const loop = () => {
const now = this.clock();
const dt = this.last === null ? 1 / 60 : Math.min(0.05, (now - this.last) / 1000);
this.last = now;
const busy = this.step(now, dt);
this.paint(now);
this.raf = busy ? requestAnimationFrame(loop) : 0;
};
this.raf = requestAnimationFrame(loop);
}
// Advance everything by dt seconds. Returns whether anything is still moving.
step(now, dt) {
const still = reducedMotion.matches;
const n = this.n;
let busy = false;
// the shown value follows the real one (never ahead of it)
if (this.state === 'active' || this.state === 'complete') {
const next = still ? this.target : this.shown + (this.target - this.shown) * (1 - Math.exp(-FOLLOW * dt));
this.shown = Math.abs(this.target - next) < 0.0004 ? this.target : next;
if (this.shown !== this.target) busy = true;
}
// while liquid is flowing toward the next bead, it drips now and then
if (this.state === 'active' && this.soundOn && this.target - this.shown > 0.002 && this.shown < 1) {
this.dripIn = (this.dripIn ?? 0.3) - dt;
if (this.dripIn <= 0) { this.sound.drip(); this.dripIn = 0.32 + Math.random() * 0.55; }
}
// a bead filled: plop, sound, and let the old bridge fade out
const k = this.state === 'cancelled' ? this.k : Math.min(n, Math.floor(this.shown * n + 1e-9));
// progress went back (a restarted or corrected download): beads empty quietly, no bridge hand-off
if (k < this.k) {
this.k = k;
this.bridges.forEach((br) => { br.seg = -1; br.fade = 0; });
}
if (k > this.k) {
for (let i = this.k; i < k; i++) {
const b = this.beads[i];
if (!still) b.sv = -3.2;
if (this.soundOn) this.sound.fill(i);
}
// the bridge that delivered the liquid fades out; the next segment gets the other one
const live = this.bridges[this.bi];
if (live.seg >= 0) {
live.fade = still ? 0 : 1;
this.bi ^= 1;
Object.assign(this.bridges[this.bi], { seg: -1, fade: 0 });
}
this.k = k;
if (k === n && this.state !== 'complete') {
this.setState('complete');
this.rippleFrom = now;
if (this.soundOn) this.sound.chime();
}
}
// bead positions spring toward their places; scale springs back to 1 after a plop
for (let i = 0; i < n; i++) {
const b = this.beads[i];
b.tx = this.targetX(i);
if (still) { b.x = b.tx; b.v = 0; b.s = 1; b.sv = 0; continue; }
b.v += (-SPRING.k * (b.x - b.tx) - SPRING.c * b.v) * dt;
b.x += b.v * dt;
b.sv += (-PLOP.k * (b.s - 1) - PLOP.c * b.sv) * dt;
b.s += b.sv * dt;
if (Math.abs(b.x - b.tx) > 0.05 || Math.abs(b.v) > 0.5 || Math.abs(b.s - 1) > 0.002 || Math.abs(b.sv) > 0.02) busy = true;
}
for (const br of this.bridges) if (br.fade > 0) { br.fade = Math.max(0, br.fade - dt / SNAP_FADE); busy = true; }
// the burst: droplets spray out and settle; on restart they rush home
if (this.particles.length) {
const decay = Math.exp(-BURST_SETTLE * dt);
for (const p of this.particles) {
p.vx *= decay; p.vy *= decay; p.vr *= decay;
p.x += p.vx * dt; p.y += p.vy * dt; p.r += p.vr * dt;
if (Math.abs(p.vx) + Math.abs(p.vy) > 2) busy = true;
}
if (this.reformFrom != null) {
if ((now - this.reformFrom) / 1000 < REFORM) busy = true;
else { this.particles = []; this.spray.textContent = ''; this.reformFrom = null; }
}
}
// a live bridge breathes; the complete ripple runs; the paused bridge rests
if (this.state === 'active' && k < n && this.shown > 0) busy = true;
if (this.state === 'paused' && !still) busy = true;
if (this.rippleFrom && now - this.rippleFrom < 1400) busy = true;
return busy;
}
paint(now = this.clock()) {
const { d, cy, n } = this;
const still = reducedMotion.matches;
const v = this.shown * n; // in beads
const k = this.k;
const f = this.state === 'complete' ? 0 : clamp(v - k, 0, 1);
const cancelled = this.state === 'cancelled';
const t = now / 1000;
this.numEl.textContent = Math.round(this.shown * 100);
// ripple after completing: each bead lifts in turn
const ripple = (i) => {
if (!this.rippleFrom || still) return 1;
const age = (now - this.rippleFrom) / 1000 - i * 0.045;
return age > 0 && age < 0.5 ? 1 + 0.12 * Math.sin((age / 0.5) * Math.PI) : 1;
};
const bridgeLive = !cancelled && this.state !== 'complete' && k >= 1 && k < n && f > 0;
this.beads.forEach((b, i) => {
// filled amount: whole beads, plus the first bead fading in before any bridge exists
const fill = i < k ? 1 : i === 0 && k === 0 && !cancelled ? smooth(f) : 0;
// ripening: the newest beads are citron, older ones emerald
const ripe = clamp(1 - (v - 1 - i) / RIPEN, 0, 1); // 1 at the front, 0 once set to emerald
const citron = smooth(clamp((ripe - 0.6) / 0.4, 0, 1));
const reform = this.reformFrom != null ? easeOut(clamp((now - this.reformFrom) / 1000 / REFORM, 0, 1)) : 1;
const gone = cancelled ? 0 : reform;
b.clear.style.opacity = 1 - fill;
b.emerald.style.opacity = fill;
// emerald turns lime as it ripens, citron glass comes in at the very front
const lime = fill ? `hue-rotate(${(-52 * ripe).toFixed(1)}deg) brightness(${(1 + 0.5 * ripe).toFixed(3)}) saturate(${(1 + 0.2 * ripe).toFixed(3)})` : '';
if (b.lime !== lime) { b.emerald.style.filter = lime; b.lime = lime; }
b.citron.style.opacity = fill * citron;
const s = b.s * ripple(i) * (i === 0 && k === 0 && !cancelled ? 0.86 + 0.14 * fill : 1) * (0.2 + 0.8 * gone);
b.el.style.opacity = gone;
b.el.style.transform = `translate3d(${(b.x - d / 2).toFixed(2)}px, ${(cy - d / 2).toFixed(2)}px, 0) scale(${s.toFixed(4)})`;
});
// the bridge from the front bead to the next one: liquid wipes out to the right as f grows
const draw = (br, seg, amount, alpha) => {
if (seg < 1 || seg >= n || alpha <= 0) { br.el.style.opacity = 0; return; }
const a = this.beads[seg - 1].x, b = this.beads[seg].x;
const w = (b - a) / (BRIDGE.right - BRIDGE.left);
const h = d / BRIDGE.bulb;
const breathe = still ? 1 : this.state === 'paused' ? 1 + 0.025 * Math.sin(t * 2.2) : 1 + 0.018 * Math.sin(t * 5.1);
const start = BRIDGE.left + d / 2 / w; // right edge of the left bulb
const reveal = start + (1.02 - start) * easeInOut(clamp(amount / REACH, 0, 1));
br.el.style.opacity = alpha;
br.el.style.width = w.toFixed(2) + 'px';
br.el.style.height = h.toFixed(2) + 'px';
br.el.style.transform = `translate3d(${(a - BRIDGE.left * w).toFixed(2)}px, ${(cy - h / 2).toFixed(2)}px, 0) scale(${(2 - breathe).toFixed(4)}, ${breathe.toFixed(4)})`;
// a feathered front edge, so the liquid's tip looks rounded rather than cut
const edge = (reveal * 100).toFixed(2), soft = (12 * d / w).toFixed(2);
br.el.style.webkitMaskImage = br.el.style.maskImage = `linear-gradient(90deg, #000 calc(${edge}% - ${soft}%), transparent ${edge}%)`;
};
const cur = this.bridges[this.bi], old = this.bridges[this.bi ^ 1];
if (bridgeLive) cur.seg = k;
draw(cur, k, f, bridgeLive ? smooth(clamp(f / 0.08, 0, 1)) : 0);
draw(old, old.seg, 1, old.fade);
// once the liquid has reached the next bead, it fills it from the middle out
const pour = bridgeLive ? smooth(clamp((f - REACH) / (1 - REACH), 0, 1)) : 0;
this.pour.style.opacity = pour.toFixed(3);
if (pour > 0) {
const s = 0.55 + 0.45 * pour;
this.pour.style.transform = `translate3d(${(this.beads[k].x - d / 2).toFixed(2)}px, ${(cy - d / 2).toFixed(2)}px, 0) scale(${s.toFixed(4)})`;
}
// the thread behind the beads: green up to the front of the liquid
const first = this.beads[0].x, lastX = this.beads[n - 1].x;
const head = k >= n ? lastX : k === 0 ? first : this.beads[k - 1].x + (this.beads[k].x - this.beads[k - 1].x) * easeInOut(f);
this.threadFill.parentElement.style.transform = `translate3d(${first}px, ${cy}px, 0)`;
this.threadFill.parentElement.style.width = lastX - first + 'px';
this.threadFill.style.width = cancelled ? '0px' : Math.max(0, head - first) + 'px';
// droplets
if (this.particles.length) {
const back = this.reformFrom != null ? easeInOut(clamp((now - this.reformFrom) / 1000 / REFORM, 0, 1)) : 0;
for (const p of this.particles) {
const hx = this.beads[p.home].x, x = p.x + (hx - p.x) * back, y = p.y + (cy - p.y) * back;
p.el.style.opacity = (p.alpha * (1 - back * 0.8)).toFixed(3);
p.el.style.transform = `translate3d(${(x - p.size / 2).toFixed(2)}px, ${(y - p.size / 2).toFixed(2)}px, 0) rotate(${p.r.toFixed(1)}deg) scale(${(1 - back * 0.5).toFixed(3)})`;
}
}
}
// Break the string into droplets: each filled bead leaves a core drop near its place and a few
// smaller ones; empty beads mist into tiny clear drops. All of it settles into a spray.
burst() {
const { d, cy } = this;
const rand = rng(this.n * 7919 + this.k);
this.spray.textContent = '';
this.particles = [];
this.reformFrom = null;
const still = reducedMotion.matches;
const v = this.shown * this.n;
this.beads.forEach((b, i) => {
const filled = i < this.k || (i === this.k && v - this.k > 0.4);
const citron = clamp(1 - (v - 1 - i) / RIPEN, 0, 1) ** 2;
const count = filled ? 5 : 3;
for (let j = 0; j < count; j++) {
const core = j === 0;
const size = d * (filled ? (core ? 0.42 + rand() * 0.14 : 0.1 + rand() ** 1.5 * 0.26) : 0.07 + rand() * 0.16);
const file = filled ? (rand() < citron ? (rand() < 0.5 ? 'citron-1.webp' : 'citron-2.webp') : rand() < 0.5 ? 'emerald-1.webp' : 'emerald-2.webp') : rand() < 0.5 ? 'clear-1.webp' : 'clear-2.webp';
const el = this.img(file, 'dl-drop');
el.style.width = el.style.height = size + 'px';
this.spray.appendChild(el);
const ang = rand() * Math.PI * 2;
const reach = core ? d * 0.3 * rand() : d * (0.45 + rand() * 1.5);
const sx = Math.cos(ang) * reach, sy = Math.sin(ang) * reach * 0.8;
const p = {
el, size, home: i, alpha: filled ? 0.95 : 0.8,
x: b.x + (still ? sx : 0), y: cy + (still ? sy : 0),
vx: still ? 0 : sx * BURST_SETTLE, vy: still ? 0 : sy * BURST_SETTLE,
r: rand() * 360, vr: still ? 0 : (rand() - 0.5) * 400,
};
this.particles.push(p);
}
});
this.wake();
}
}
window.DropLoader = DropLoader;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Drop Loader</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Playfair+Display:wght@500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="drop-loader.css">
<style>
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; }
body {
display: grid;
place-items: center;
grid-template-columns: minmax(0, 680px);
justify-content: center;
padding: 48px 16px;
overflow-x: hidden;
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
color: #16261b;
/* morning light on a pale stone table, sage behind */
background:
radial-gradient(ellipse 60% 40% at 50% 30%, rgba(255, 255, 250, 0.9), transparent 70%),
radial-gradient(ellipse 90% 60% at 50% 110%, #e6e2d6, transparent 70%),
linear-gradient(180deg, #dfe4d8, #e9ebe2 45%, #ebe7dc);
}
/* the scene behind the glass: sage leaves, shafts of sunlight, pale stones, all out of focus */
body::before {
content: "";
position: fixed;
inset: -10%;
pointer-events: none;
filter: blur(22px);
background:
radial-gradient(ellipse 11% 26% at 14% 22%, rgba(92, 128, 78, 0.75), transparent 70%),
radial-gradient(ellipse 8% 18% at 24% 40%, rgba(120, 150, 98, 0.6), transparent 70%),
radial-gradient(ellipse 9% 22% at 6% 58%, rgba(84, 118, 72, 0.7), transparent 70%),
radial-gradient(ellipse 12% 20% at 78% 30%, rgba(150, 172, 120, 0.55), transparent 70%),
radial-gradient(ellipse 7% 16% at 88% 46%, rgba(98, 132, 82, 0.6), transparent 70%),
radial-gradient(ellipse 16% 9% at 36% 70%, rgba(214, 206, 188, 0.95), transparent 70%),
radial-gradient(ellipse 12% 8% at 70% 74%, rgba(206, 198, 180, 0.9), transparent 70%),
radial-gradient(ellipse 22% 14% at 55% 46%, rgba(255, 246, 214, 0.65), transparent 70%),
linear-gradient(115deg, transparent 26%, rgba(255, 255, 240, 0.6) 33%, transparent 41%, transparent 55%, rgba(255, 255, 240, 0.5) 61%, transparent 69%);
}
/* paper-fine grain */
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.12;
mix-blend-mode: multiply;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.4 0 0 0 0 0.42 0 0 0 0 0.36 0 0 0 0.9 0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
}
main {
position: relative;
z-index: 1;
display: grid;
justify-items: center;
gap: 34px;
width: 100%;
min-width: 0;
}
#card { width: 100%; }
/* demo only: drives a simulated download */
.demo {
display: grid;
gap: 10px;
justify-items: center;
}
.demo-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
}
.demo-row span {
width: 100%;
text-align: center;
margin-bottom: 2px;
font-size: 10px;
letter-spacing: 0.24em;
color: #6b7369;
}
.demo button {
padding: 7px 10px;
border: 1px solid rgba(22, 38, 27, 0.2);
border-radius: 4px;
background: rgba(255, 255, 255, 0.35);
color: #26342a;
font: 500 10.5px/1 "Inter", ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
}
.demo button:hover { background: rgba(255, 255, 255, 0.75); }
.demo button:focus-visible { outline: 2px solid #16261b; outline-offset: 2px; }
</style>
</head>
<body>
<main>
<div id="card"></div>
<div class="demo">
<div class="demo-row">
<span>SIMULATE</span>
<button type="button" data-act="run">Run</button>
<button type="button" data-act="complete">Finish</button>
<button type="button" data-act="sound" aria-pressed="true">Sound on</button>
</div>
<div class="demo-row">
<span>JUMP TO</span>
<button type="button" data-jump="0.1">10%</button>
<button type="button" data-jump="0.35">35%</button>
<button type="button" data-jump="0.67">67%</button>
<button type="button" data-jump="0.93">93%</button>
</div>
</div>
</main>
<script src="drop-loader.js"></script>
<script>
// A stand-in for a real download, so the demo runs on its own. The card's own buttons
// (pause, resume, cancel, restart) tell the simulation what to do.
let timer = null;
let p = 0;
const stop = () => { clearInterval(timer); timer = null; };
const step = () => {
if (Math.random() < 0.18) return; // the odd stall
p = Math.min(1, p + 0.006 + Math.random() * 0.03);
card.set(p);
if (p >= 1) stop();
};
const start = () => { stop(); timer = setInterval(step, 320); };
const card = new DropLoader(document.getElementById('card'), {
file: { name: 'Lake_Moments.mp4', size: '1.8 GB' },
onPause: stop,
onResume: start,
onCancel: stop,
onRestart: () => { p = 0; start(); },
onOpen: () => {},
});
document.querySelector('.demo').addEventListener('click', (e) => {
const b = e.target.closest('button');
if (!b) return;
if (b.dataset.jump) {
if (card.state !== 'active' && card.state !== 'paused') card.restart();
if (card.state === 'paused') card.resume();
p = +b.dataset.jump;
card.set(p);
start();
return;
}
const act = b.dataset.act;
if (act === 'run') {
if (card.state !== 'active') { p = 0; card.restart(); }
start();
} else if (act === 'complete') {
if (card.state !== 'active' && card.state !== 'paused') card.restart();
if (card.state === 'paused') card.resume();
stop();
p = 1;
card.set(1);
} else if (act === 'sound') {
const on = b.getAttribute('aria-pressed') !== 'true';
card.setSound(on);
b.setAttribute('aria-pressed', on);
b.textContent = on ? 'Sound on' : 'Sound off';
}
});
// ?p=67 shows a still state (&state=paused|cancelled); otherwise the demo runs once by itself.
const q = new URLSearchParams(location.search);
if (q.has('p')) {
p = Math.min(1, +q.get('p') / 100);
card.set(p, { instant: true });
if (q.get('state') === 'paused') card.pause();
if (q.get('state') === 'cancelled') card.cancel();
} else {
setTimeout(start, 600);
}
</script>
</body>
</html> All 10 files in the zip
-
assets/bridge.webp -
assets/citron-1.webp -
assets/citron-2.webp -
assets/clear-1.webp -
assets/clear-2.webp -
assets/emerald-1.webp -
assets/emerald-2.webp -
drop-loader.css -
drop-loader.js -
index.html