glass-loader / index.html
ryzenfr's picture
Create a **single, self‑contained HTML file** (HTML + CSS + vanilla JavaScript) that implements a modern, interactive loading screen.
9b6bd9c verified
Raw
History Blame Contribute Delete
15.3 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Interactive Glass Loading Screen</title>
<style>
/* ======= Theme variables =======
- Tweak --gradient-start/--gradient-end to recolor the progress gradient.
- Tweak --glass-bg to change the glass overlay tint.
- --base-speed is the initial percent-per-second (default: 20 => 5s total).
*/
:root{
--gradient-start: #ff3b3b; /* start color (red-ish) */
--gradient-end: #22c55e; /* end color (green-ish) */
--glass-bg: rgba(15,15,22,0.62);
--glass-border: rgba(255,255,255,0.15);
--text: #e9eefc;
--muted: #b8c0d9;
--base-speed: 20; /* % per second */
--radius: 14px;
}
/* ======= Reset ======= */
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, "Helvetica Neue", Arial, "Apple Color Emoji","Segoe UI Emoji";
color: var(--text);
background: radial-gradient(1200px 800px at 70% 20%, #111424 0%, #0a0d18 45%, #080a14 100%);
overflow: hidden;
}
/* ======= Loader Overlay ======= */
.overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
background: var(--glass-bg);
backdrop-filter: blur(5px) saturate(140%);
-webkit-backdrop-filter: blur(5px) saturate(140%);
transition: opacity .45s ease, visibility .45s ease;
}
/* Fallback for browsers without backdrop-filter support */
@supports not ((backdrop-filter: blur(5px)) or (-webkit-backdrop-filter: blur(5px))) {
.overlay { background: rgba(8,10,20,0.9); }
}
.overlay.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
.card {
width: min(92vw, 560px);
border-radius: var(--radius);
border: 1px solid var(--glass-border);
box-shadow: 0 20px 60px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.04);
background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.02));
padding: 24px;
position: relative;
overflow: hidden;
}
/* ======= Progress Area ======= */
.progress {
position: relative;
width: min(84vw, 480px);
height: 16px;
border-radius: 999px;
background: rgba(255,255,255,0.12);
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
}
.progress .bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--gradient-start), var(--gradient-end));
position: relative;
border-radius: inherit;
transition: width .12s linear;
}
/* Moving diagonal sheen over the bar */
.progress .bar::after {
content: "";
position: absolute;
inset: 0;
background: repeating-linear-gradient(45deg,
rgba(255,255,255,0.12) 0px, rgba(255,255,255,0.12) 6px,
transparent 6px, transparent 12px);
mix-blend-mode: overlay;
animation: moveSheen 1.6s linear infinite;
pointer-events: none;
}
@keyframes moveSheen {
from { background-position: 0 0; }
to { background-position: 48px 0; }
}
.status {
margin-top: 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 14px;
user-select: none;
}
.loading-dots::after {
content: "";
display: inline-block;
width: 1ch;
text-align: left;
animation: dots 1.2s steps(4,end) infinite;
}
@keyframes dots {
0% { content: ""; }
25% { content: "."; }
50% { content: ".."; }
75% { content: "..."; }
100% { content: ""; }
}
.skip {
margin-top: 18px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.08);
color: var(--text);
padding: 10px 16px;
font-size: 14px;
border-radius: 10px;
cursor: pointer;
transition: transform .15s ease, opacity .15s ease, background .2s ease;
backdrop-filter: blur(6px);
}
.skip:hover {
opacity: 0.95;
transform: translateY(-1px);
background: rgba(255,255,255,0.12);
}
.skip:active { transform: translateY(0) scale(0.98); }
.skip:focus-visible { outline: 2px solid #8ab4ff; outline-offset: 2px; }
/* ======= Particle Canvas ======= */
#particleCanvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
filter: saturate(120%);
}
/* ======= Reduced motion: disable animations ======= */
@media (prefers-reduced-motion: reduce) {
.progress .bar::after { animation: none; }
.loading-dots::after { animation: none; }
.skip { transition: none; }
.overlay { transition: none; }
}
</style>
</head>
<body>
<!-- Full-screen loading overlay with glassmorphism -->
<div class="overlay" id="loader" aria-live="polite">
<canvas id="particleCanvas"></canvas>
<div class="card" id="card">
<div
id="progress"
class="progress"
role="progressbar"
aria-label="Loading progress"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0">
<div class="bar" id="bar"></div>
</div>
<div class="status">
<div id="statusText" class="loading-dots">Loading</div>
<div id="percentText">0%</div>
</div>
<button id="skipBtn" class="skip" type="button" aria-label="Skip loading">Skip</button>
<!-- Screen-reader live region for percentage announcements -->
<div id="srPercent" style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;">0 percent</div>
</div>
</div>
<script>
/* ======= Interactive Glass Loader (Vanilla JS) =======
- Click anywhere (except Skip) or press Space to double loading speed.
- Particles follow the pointer (disabled if prefers-reduced-motion).
- Skip jumps to 100% and ends the sequence with confetti fade-out.
- Progress color smoothly shifts from red to green as it advances.
- All animations respect prefers-reduced-motion.
Customization notes:
- Change --gradient-start/--gradient-end in :root for different palette.
- Adjust --base-speed to control base percent-per-second.
- To disable particles entirely, set NO_PARTICLES = true below.
*/
(() => {
const overlay = document.getElementById('loader');
const card = document.getElementById('card');
const progress = document.getElementById('progress');
const bar = document.getElementById('bar');
const percentText = document.getElementById('percentText');
const statusText = document.getElementById('statusText');
const skipBtn = document.getElementById('skipBtn');
const srPercent = document.getElementById('srPercent');
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const NO_PARTICLES = prefersReduced; // disable particles if user prefers reduced motion
// Speed handling (percent per second)
const root = document.documentElement;
const BASE_SPEED = parseFloat(getComputedStyle(root).getPropertyValue('--base-speed')) || 20; // % per second
let speedMultiplier = 1;
let progressValue = 0;
// Time & RAF
let lastTime = 0;
let rafId = 0;
let running = true;
// Pointer / particles
const canvas = document.getElementById('particleCanvas');
const ctx = canvas.getContext('2d', { alpha: true });
let pointer = { x: window.innerWidth/2, y: window.innerHeight/2, active: false };
let particles = [];
let emitAccumulator = 0;
const EMIT_RATE = 80; // particles per second when pointer is active
const MAX_PARTICLES = 320;
let dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); // limit DPR for perf
// Confetti
let confetti = [];
let confettiDone = false;
// Utilities
const clamp = (n, min, max) => Math.min(max, Math.max(min, n));
const lerp = (a, b, t) => a + (b - a) * t;
function hexToRgb(hex) {
hex = hex.trim();
if (hex.startsWith('hsl')) return { r:0, g:0, b:0 }; // not used, but safe
if (hex.startsWith('#')) hex = hex.slice(1);
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
const num = parseInt(hex, 16);
return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
}
function rgbToHex(r, g, b) {
return '#' + [r,g,b].map(v => v.toString(16).padStart(2,'0')).join('');
}
function mixHex(c1, c2, t) {
const a = hexToRgb(c1), b = hexToRgb(c2);
return rgbToHex(
Math.round(lerp(a.r, b.r, t)),
Math.round(lerp(a.g, b.g, t)),
Math.round(lerp(a.b, b.b, t))
);
}
// Color mapping: red -> green as progress moves 0 -> 100
function progressColor(t) {
// start red-ish, end green-ish (adjust if you change CSS variables)
return mixHex('#ff3b3b', '#22c55e', t);
}
// Resize canvas to CSS pixels with DPR scaling
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // scale once
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas, { passive: true });
// Update progress bar and labels
function updateUI() {
const pct = Math.floor(progressValue);
bar.style.width = pct + '%';
progress.setAttribute('aria-valuenow', String(pct));
percentText.textContent = pct + '%';
srPercent.textContent = pct + ' percent';
// Hue shift by current percent
const t = clamp(progressValue / 100, 0, 1);
const col = progressColor(t);
bar.style.background = `linear-gradient(90deg, ${col}, ${mixHex(col, '#22c55e', 0.35)})`;
}
// Particles
function spawnParticle(x, y) {
if (NO_PARTICLES) return;
if (particles.length > MAX_PARTICLES) particles.shift();
const hue = Math.floor(lerp(180, 320, Math.random())); // purple-ish hues
particles.push({
x, y,
vx: (Math.random() - 0.5) * 90,
vy: (Math.random() - 0.8) * 120,
life: 0,
maxLife: 0.8 + Math.random() * 0.6,
size: 2 + Math.random() * 2,
hue
});
}
// Confetti burst at center of overlay
function burstConfetti() {
const rect = overlay.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const count = prefersReduced ? 30 : 140;
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 200 + Math.random() * 420;
const hue = Math.floor(lerp(0, 360, Math.random()));
confetti.push({
x: cx, y: cy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 0,
maxLife: 1.2 + Math.random() * 0.8,
size: 2 + Math.random() * 3,
hue,
rotation: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 12
});
}
}
// Animation loop
function frame(ts) {
if (!running) return;
if (!lastTime) lastTime = ts;
const dt = Math.min(0.05, (ts - lastTime) / 1000); // seconds, clamp to 50ms
lastTime = ts;
// Progress update
progressValue = clamp(progressValue + BASE_SPEED * speedMultiplier * dt, 0, 100);
updateUI();
// Emit particles when pointer is active
if (!NO_PARTICLES && pointer.active) {
emitAccumulator += dt * EMIT_RATE;
while (emitAccumulator >= 1) {
emitAccumulator -= 1;
spawnParticle(pointer.x, pointer.y);
}
}
// Clear canvas (CSS pixels; transform handles DPR)
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Update & draw particles
if (!NO_PARTICLES) {
const drag = 0.98;
const gravity = 280;
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life += dt;
if (p.life >= p.maxLife) { particles.splice(i, 1); continue; }
p.vx *= drag;
p.vy = p.vy * drag + gravity * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
const alpha = 1 - (p.life / p.maxLife);
ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${alpha.toFixed(3)})`;
ctx.beginPath();
ctx.arc(p.x - overlay.getBoundingClientRect().left,
p.y - overlay.getBoundingClientRect().top,
p.size, 0, Math.PI * 2);
ctx.fill();
}
}
// Draw & update confetti
if (progressValue >= 100 && !confettiDone) {
burstConfetti();
confettiDone = true;
}
if (confetti.length) {
const drag = 0.985, gravity = 380;
for (let i = confetti.length - 1; i >= 0; i--) {
const c = confetti[i];
c.life += dt;
if (c.life >= c.maxLife) { confetti.splice(i, 1); continue; }
c.vx *= drag;
c.vy = c.vy * drag + gravity * dt;
c.x += c.vx * dt;
c.y += c.vy * dt;
c.rotation += c.vr * dt;
const alpha = 1 - (c.life / c.maxLife);
ctx.save();
ctx.translate(c.x - overlay.getBoundingClientRect().left,
c.y - overlay.getBoundingClientRect().top);
ctx.rotate(c.rotation);
ctx.fillStyle = `hsla(${c.hue}, 85%, 60%, ${alpha.toFixed(3)})`;
ctx.fillRect(-c.size/2, -c.size/2, c.size * (1.8 + Math.abs(Math.sin(c.rotation))*0.6), c.size);
ctx.restore();
}
}
// Finish
if (progressValue >= 100) {
// Small delay to let confetti start, then fade overlay
setTimeout(() => {
overlay.classList.add('hidden');
running = false;
if (rafId) cancelAnimationFrame(rafId);
// Cleanup canvas to free memory
setTimeout(() => { try { ctx.clearRect(0,0,canvas.width,canvas.height); } catch(e){} }, 600);
}, 450);
return; // stop scheduling new frames after finish
}
rafId = requestAnimationFrame(frame);
}
// Interactions
function speedUp() {
speedMultiplier = Math.min(speedMultiplier * 2, 64); // cap
// Optional tiny feedback flash on the bar
bar.style.transition = 'none';
bar.offsetHeight; // reflow
bar.style.transition = 'width .12s linear';
}
overlay.addEventListener('pointermove', (e) => {
pointer.x = e.clientX;
pointer.y = e.clientY;
pointer.active = true;
}, { passive: true });
overlay.addEventListener('pointerleave', () => { pointer.active = false; }, { passive: true });
// Double speed on click (not on skip)
overlay.addEventListener('click', (e) => {
if (e.target === skipBtn) return; // skip has its own handler
speedUp();
});
// Keyboard: Space to double speed
window.addEventListener('keydown', (e) => {
if (e.code === 'Space' || e.key === ' ') {
e.preventDefault();
speedUp();
}
});
// Skip handler
skipBtn.addEventListener('click', () => {
progressValue = 100;
updateUI();
// Do immediate burst + fade
confettiDone = false;
burstConfetti();
setTimeout(() => {
overlay.classList.add('hidden');
running = false;
if (rafId) cancelAnimationFrame(rafId);
}, 250);
});
// Start
updateUI();
rafId = requestAnimationFrame(frame);
})();
</script>
<script src="https://huggingface.co/deepsite/deepsite-badge.js"></script>
</body>
</html>