类型:3D · 分类:CSS · 收录:2026-09-16 20:35:01 · 源码:会员可见
# Uplink Loader
A cinematic secure-uplink loader with stepped progress, illuminated telemetry ticks, neon readouts, technical corner markers, mirrored side rails, scanlines, and procedural grain.
> 来源:ThreeUI Community · https://threeui.com · MIT License(Meng To / Design+Code)
> 组件页:https://threeui.com/component/uplink-loader · 预览动画:https://threeui.com/previews/uplink-loader.webm
> 分类:CSS
/* ===== src/shaders/uplink-loader/UplinkLoader.tsx (tsx) ===== */
import { useState, type CSSProperties } from "react";
import uplinkLoaderSource from "./uplink-loader.html?raw";
export type UplinkLoaderProps = {
className?: string;
style?: CSSProperties;
};
export function UplinkLoader({ className = "", style }: UplinkLoaderProps) {
const [ready, setReady] = useState(false);
return (
<div
className={`uplink-loader${className ? ` ${className}` : ""}`}
data-state={ready ? "ready" : "loading"}
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "hidden",
background: "#030806",
...style,
}}
>
<iframe
title="SYS.LINK uplink progress loader"
srcDoc={uplinkLoaderSource}
sandbox="allow-scripts"
loading="eager"
onLoad={() => setReady(true)}
style={{
position: "absolute",
inset: 0,
display: "block",
width: "100%",
height: "100%",
border: 0,
background: "#030806",
}}
/>
</div>
);
}
/* ===== src/shaders/uplink-loader/uplink-loader.html (html) ===== */
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SYS.LINK — Uplink Sequence</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=Geist+Mono:wght@200..700&display=swap" rel="stylesheet">
<style>
:root{
/* design canvas: 1200 x 800 units, scaled to fit the viewport */
--s: 1;
--bg: #030806;
--green: #2fe07d;
--green-dim: rgba(55,225,130,.26);
--line: rgba(120,255,185,.46);
--ink-hi: #d9f4e6;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
background:var(--bg);
overflow:hidden;
font-family:"Geist Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
-webkit-font-smoothing:antialiased;
}
/* a cold pool of light behind the rig */
.pool{
position:fixed; inset:0; pointer-events:none;
background:radial-gradient(ellipse 60% 44% at 50% 52%,
rgba(24,150,92,.17) 0%, rgba(16,104,66,.08) 38%, rgba(10,58,38,.025) 66%, transparent 100%);
}
/* ── stage ── */
.scene{position:fixed; inset:0; overflow:hidden}
.stage{
position:absolute; left:50%; top:50%; width:1200px; height:800px;
transform:translate(-50%,-50%) scale(var(--s)); transform-origin:center;
}
.stage > *{position:absolute}
/* ── readout plate ── */
.plate{
left:530px; top:270px; width:140px; height:84px;
background:var(--line);
clip-path:polygon(14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%, 0 14px);
}
.plate::after{
content:""; position:absolute; inset:1.2px;
background:linear-gradient(180deg,#07251a,#041a12);
clip-path:polygon(13px 0, 100% 0, 100% calc(100% - 13px), calc(100% - 13px) 100%, 0 100%, 0 13px);
}
.plate.hit{animation:plateHit .5s ease-out}
@keyframes plateHit{0%{background:#c8ffe2}100%{background:var(--line)}}
.brk{width:9px;height:9px;pointer-events:none}
.brk::before,.brk::after{content:""; position:absolute; background:var(--green); opacity:.9}
.brk::before{width:9px;height:1.2px} .brk::after{width:1.2px;height:9px}
.brk.tr{left:661px; top:270px} .brk.tr::before{right:0;top:0} .brk.tr::after{right:0;top:0}
.brk.bl{left:530px; top:345px} .brk.bl::before{left:0;bottom:0} .brk.bl::after{left:0;bottom:0}
.readout{
left:530px; top:284.9px; width:140px; height:0;
text-align:center; line-height:1; white-space:pre;
color:var(--green);
text-shadow:0 0 4px rgba(70,235,145,.6), 0 0 14px rgba(48,210,125,.55), 0 0 36px rgba(32,175,105,.4);
animation:neon 4.3s steps(1,end) infinite;
}
.readout b{font-size:47px; font-weight:280; letter-spacing:2.4px}
.readout u{font-size:15px; font-weight:400; letter-spacing:0; text-decoration:none;
opacity:.62; position:relative; top:-1px; left:3px}
@keyframes neon{
0%,40%{opacity:1} 41%{opacity:.55} 42%{opacity:1}
67%{opacity:1} 68%{opacity:.72} 69.5%{opacity:1} 70.5%{opacity:.42} 71.5%{opacity:1}
}
/* ── progress bar ── */
.haze{left:298px; top:438px; width:0; height:0; pointer-events:none}
.haze i{
position:absolute; left:0; top:0;
width:calc(var(--lit-w,0px) + 250px); height:250px;
transform:translate(-125px,-128px);
background:radial-gradient(ellipse 50% 50% at center,
rgba(56,225,140,.26) 0%, rgba(46,200,126,.17) 22%,
rgba(38,170,108,.082) 45%, rgba(30,140,90,.024) 70%, transparent 100%);
filter:blur(6px);
transition:width .16s linear;
}
.haze i.pulse{animation:hazePulse .3s ease-out}
@keyframes hazePulse{0%{filter:blur(6px) brightness(1.3)}100%{filter:blur(6px) brightness(1)}}
.bar{left:298px; top:420px; width:604px; height:44px;
display:flex; justify-content:space-between; align-items:flex-end}
.tick{width:5.4px; height:32px; background:var(--green-dim); transform:skewX(12deg)}
.tick.mk{height:44px}
.tick.on{
background:#33cf76;
box-shadow:0 0 4px rgba(95,240,155,.6), 0 0 11px rgba(55,205,125,.32), 0 0 24px rgba(40,175,105,.16);
}
.tick.flash{animation:ignite .32s ease-out}
@keyframes ignite{
0%{background:#dcffec; box-shadow:0 0 11px rgba(200,255,225,1),0 0 28px rgba(85,230,150,.85),0 0 54px rgba(50,190,120,.45)}
100%{background:#33cf76; box-shadow:0 0 4px rgba(95,240,155,.6),0 0 11px rgba(55,205,125,.32),0 0 24px rgba(40,175,105,.16)}
}
.barlabel{left:298px; top:396.4px; font-size:20.4px; font-weight:500; letter-spacing:.9px;
line-height:1; color:var(--ink-hi);
text-shadow:0 0 3px rgba(200,250,225,.34), 0 0 11px rgba(120,235,175,.3), 0 0 26px rgba(60,195,130,.22)}
.status{
left:0; top:507px; width:1200px; text-align:center;
font-size:14px; font-weight:500; line-height:1; letter-spacing:1.42px; text-indent:1.42px;
color:#9fd0b6; white-space:pre;
text-shadow:0 0 3px rgba(185,240,215,.3), 0 0 10px rgba(110,220,160,.22);
}
.status .dots{letter-spacing:-1.9px; margin-left:-6px}
/* ── corner markers ── */
.marker{width:0;height:0}
.marker i{position:absolute; background:var(--green); opacity:.72}
.marker .h{width:9px;height:1.2px} .marker .v{width:1.2px;height:9px}
.marker .a-h{left:-17px;top:-17px} .marker .a-v{left:-17px;top:-17px}
.marker .b-h{left:8px;top:-17px} .marker .b-v{left:15.8px;top:-17px}
.marker .c-h{left:-17px;top:15.8px} .marker .c-v{left:-17px;top:8px}
.marker .d-h{left:8px;top:15.8px} .marker .d-v{left:15.8px;top:8px}
.marker .dia{
left:-4px; top:-4px; width:8px; height:8px; opacity:1;
background:var(--green); transform:rotate(45deg);
box-shadow:0 0 6px rgba(70,235,145,.7);
animation:diaPulse 3.2s ease-in-out infinite;
}
@keyframes diaPulse{0%,100%{opacity:.55; transform:rotate(45deg) scale(.8)}50%{opacity:1; transform:rotate(45deg) scale(1)}}
.m-tl{left:274px; top:340px} .m-tr{left:926px; top:340px}
.m-bl{left:274px; top:508px} .m-br{left:926px; top:508px}
.m-tr .dia{animation-delay:.5s} .m-bl .dia{animation-delay:1.1s} .m-br .dia{animation-delay:1.6s}
/* ── side rails ── */
.rail{left:0; top:0; width:1200px; height:800px}
.rail.right{transform:scaleX(-1)}
.rail > *{position:absolute}
.rail .wire{left:104px; top:433px; width:92px; height:1.4px; background:rgba(120,255,185,.24)}
.rail .cap{top:428px; height:5px; background:#2bd074; box-shadow:0 0 5px rgba(70,235,145,.6)}
.rail .cap.a{left:104px; width:10px}
.rail .cap.b{left:181px; width:15px; animation:capPulse 2.4s ease-in-out infinite}
@keyframes capPulse{0%,100%{opacity:1}50%{opacity:.5}}
.mod{left:204px; top:404px; width:36px; height:62px}
.mod > *{position:absolute; left:0}
.mod .hatch{top:0; width:36px; height:2px; background:#cfe8da}
.mod .ret{top:8px; left:3px; width:30px; height:30px; border-radius:50%;
border:1.3px solid rgba(206,235,220,.82)}
.mod .ret::before,.mod .ret::after{content:""; position:absolute; background:rgba(206,235,220,.45)}
.mod .ret::before{left:-3.5px; top:13.6px; width:35px; height:1px}
.mod .ret::after{left:13.6px; top:-3.5px; width:1px; height:35px}
.mod .dot{top:12.5px; left:15.5px; width:5px; height:5px; border-radius:50%; background:var(--green);
box-shadow:0 0 6px rgba(70,235,145,.9); animation:diaPulse 2.6s ease-in-out infinite}
.mod .slab{top:46px; width:36px; height:5px; background:#dcefe4;
clip-path:polygon(0 0,100% 0,100% 100%,5px 100%)}
.mod .led{top:55px; height:2.6px; background:#9dc3b0}
.mod .led:nth-of-type(1){left:0; width:3px}
.mod .led:nth-of-type(2){left:4.5px; width:4px}
.mod .led:nth-of-type(3){left:27px; width:4px}
.mod .led:nth-of-type(4){left:33px; width:3px}
/* ── overlays ── */
.scan{position:fixed; inset:0; pointer-events:none; opacity:.18;
background:repeating-linear-gradient(to bottom,
rgba(160,255,205,.05) 0 calc(1px * var(--s)),
transparent calc(1px * var(--s)) calc(3px * var(--s)))}
.grain{position:fixed; inset:-60px; pointer-events:none;
background-repeat:repeat; background-size:80px 80px;
animation:grainShift .6s steps(1,end) infinite}
.grain.mul{mix-blend-mode:overlay; opacity:.40}
.grain.add{opacity:.035; animation-duration:.72s}
@keyframes grainShift{
0%{transform:translate(0,0)} 20%{transform:translate(-22px,14px)}
40%{transform:translate(17px,-19px)} 60%{transform:translate(-13px,-9px)}
80%{transform:translate(9px,21px)} 100%{transform:translate(0,0)}
}
.stage.reset{animation:resetGlitch .42s ease-out}
@keyframes resetGlitch{
0%{filter:brightness(1.8) saturate(.5)} 18%{filter:brightness(.32)}
30%{filter:brightness(1.2)} 100%{filter:none}
}
@media (prefers-reduced-motion: reduce){
.grain,.readout,.marker .dia,.mod .dot,.rail .cap.b{animation:none !important}
}
</style>
</head>
<body>
<div class="pool"></div>
<div class="scene">
<div class="stage" id="stage">
<div class="haze"><i id="haze"></i></div>
<div class="plate" id="plate"></div>
<div class="brk tr"></div><div class="brk bl"></div>
<div class="readout"><b id="num">50</b><u>%</u></div>
<div class="barlabel">UPLINK</div>
<div class="bar" id="bar"></div>
<div class="status">ESTABLISHING SECURE UPLINK<span class="dots" id="dots">...</span></div>
<div class="marker m-tl"><i class="h a-h"></i><i class="v a-v"></i><i class="h b-h"></i><i class="v b-v"></i><i class="h c-h"></i><i class="v c-v"></i><i class="h d-h"></i><i class="v d-v"></i><i class="dia"></i></div>
<div class="marker m-tr"><i class="h a-h"></i><i class="v a-v"></i><i class="h b-h"></i><i class="v b-v"></i><i class="h c-h"></i><i class="v c-v"></i><i class="h d-h"></i><i class="v d-v"></i><i class="dia"></i></div>
<div class="marker m-bl"><i class="h a-h"></i><i class="v a-v"></i><i class="h b-h"></i><i class="v b-v"></i><i class="h c-h"></i><i class="v c-v"></i><i class="h d-h"></i><i class="v d-v"></i><i class="dia"></i></div>
<div class="marker m-br"><i class="h a-h"></i><i class="v a-v"></i><i class="h b-h"></i><i class="v b-v"></i><i class="h c-h"></i><i class="v c-v"></i><i class="h d-h"></i><i class="v d-v"></i><i class="dia"></i></div>
<div class="rail left">
<div class="wire"></div><div class="cap a"></div><div class="cap b"></div>
<div class="mod">
<div class="hatch"></div>
<div class="ret"></div>
<div class="dot"></div>
<div class="slab"></div>
<i class="led"></i><i class="led"></i><i class="led"></i><i class="led"></i>
</div>
</div>
<div class="rail right">
<div class="wire"></div><div class="cap a"></div><div class="cap b"></div>
<div class="mod">
<div class="hatch"></div>
<div class="ret"></div>
<div class="dot"></div>
<div class="slab"></div>
<i class="led"></i><i class="led"></i><i class="led"></i><i class="led"></i>
</div>
</div>
</div>
</div>
<div class="scan"></div>
<div class="grain mul" id="grain"></div>
<div class="grain add" id="grain2"></div>
<script>
(() => {
const TICKS = 56, MARK_EVERY = 8;
const root = document.documentElement;
const fit = () => root.style.setProperty('--s', Math.min(innerWidth / 1200, innerHeight / 800));
addEventListener('resize', fit, {passive:true});
fit();
/* ---- bar ---- */
const bar = document.getElementById('bar'), ticks = [];
for (let i = 0; i < TICKS; i++){
const t = document.createElement('i');
t.className = 'tick' + ((i + 1) % MARK_EVERY === 0 ? ' mk' : '');
bar.appendChild(t); ticks.push(t);
}
/* ---- grain ---- */
(function grain(){
const N = 160;
const make = fn => {
const c = document.createElement('canvas'); c.width = c.height = N;
const ctx = c.getContext('2d'), img = ctx.createImageData(N, N), d = img.data;
for (let i = 0; i < N * N; i++) fn(d, i * 4);
ctx.putImageData(img, 0, 0); return c.toDataURL();
};
const g = () => (Math.random() + Math.random() + Math.random() + Math.random()) / 4;
document.getElementById('grain').style.backgroundImage =
`url(${make((d,o) => { const v = 128 + (g() - .5) * 300;
d[o] = d[o+1] = d[o+2] = Math.max(0, Math.min(255, v)); d[o+3] = 255; })})`;
document.getElementById('grain2').style.backgroundImage =
`url(${make((d,o) => { const v = Math.random();
d[o] = d[o+1] = d[o+2] = 255;
d[o+3] = v < .86 ? 0 : Math.round((v - .86) / .14 * 190); })})`;
})();
/* ---- progress timeline ---- */
const KF = [
[0,0],[0.045,9],[0.115,9.6],[0.16,22],[0.205,23],[0.30,38],[0.345,39.5],
[0.40,53],[0.475,54],[0.545,68],[0.60,69],[0.665,81],[0.735,82],
[0.80,92],[0.855,93],[0.925,99],[0.985,99.4],[1.0,100]
];
const RUN = 8600, HOLD = 1600, BLANK = 420, LOOP = RUN + HOLD + BLANK;
const easeOut = x => 1 - Math.pow(1 - x, 2.1);
const valueAt = u => {
if (u >= 1) return 100;
for (let i = 0; i < KF.length - 1; i++){
const [t0,v0] = KF[i], [t1,v1] = KF[i+1];
if (u <= t1) return v0 + (v1 - v0) * easeOut((u - t0) / (t1 - t0));
}
return 100;
};
const PHASES = [
[0, 'INITIALIZING CORE SYSTEMS'],
[24, 'ESTABLISHING SECURE UPLINK'],
[52, 'SYNCHRONIZING NODE ARRAY'],
[78, 'DECRYPTING PAYLOAD STREAM'],
[100,'UPLINK ESTABLISHED']
];
const phaseFor = p => { let t = PHASES[0][1]; for (const [k,v] of PHASES) if (p >= k) t = v; return t; };
const num = document.getElementById('num');
const dots = document.getElementById('dots');
const haze = document.getElementById('haze');
const stage = document.getElementById('stage');
const plate = document.getElementById('plate');
const stat = document.querySelector('.status');
const FREEZE = new URLSearchParams(location.search).get('p');
const barW = 604, tickW = 5.4, gap = (barW - TICKS * tickW) / (TICKS - 1), pitch = tickW + gap;
let start = performance.now();
let lastLit = -1, lastPct = -1, lastDots = -1, lastPhase = '', cycle = 0;
function frame(now){
const e = (now - start) % LOOP;
const c = Math.floor((now - start) / LOOP);
if (c !== cycle){
cycle = c;
stage.classList.remove('reset'); void stage.offsetWidth; stage.classList.add('reset');
}
let pct, visible = true;
if (e < RUN) pct = valueAt(e / RUN);
else if (e < RUN + HOLD) pct = 100;
else { pct = 0; visible = (e - RUN - HOLD) % 220 < 130; }
if (FREEZE !== null){ pct = +FREEZE; visible = true; }
const shown = Math.round(pct);
if (shown !== lastPct){
num.textContent = String(shown);
lastPct = shown;
const ph = phaseFor(shown);
if (ph !== lastPhase){ lastPhase = ph; stat.firstChild.nodeValue = ph; }
}
num.parentElement.style.opacity = visible ? '' : '.22';
const lit = Math.round(pct / 100 * TICKS);
if (lit !== lastLit){
for (let i = 0; i < TICKS; i++){
const on = i < lit;
if (ticks[i].classList.contains('on') !== on) ticks[i].classList.toggle('on', on);
}
if (lit > lastLit && lastLit >= 0 && lit > 0){
const h = ticks[lit - 1];
h.classList.remove('flash'); void h.offsetWidth; h.classList.add('flash');
haze.classList.remove('pulse'); void haze.offsetWidth; haze.classList.add('pulse');
}
haze.style.setProperty('--lit-w', (lit > 0 ? (lit - 1) * pitch + tickW + gap / 2 : 0) + 'px');
if (lit === TICKS){ plate.classList.remove('hit'); void plate.offsetWidth; plate.classList.add('hit'); }
lastLit = lit;
}
const d = shown >= 100 ? 0 : (FREEZE !== null ? 3 : Math.floor(((now - start) / 380) % 4));
if (d !== lastDots){ dots.textContent = '...'.slice(0, d); lastDots = d; }
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
[/hidecontent]