Two parts: status squares that breathe in place, each on its own phase, and a scanline panel with sparse vertical travelers drifting across a ruled field. The squares carry real state, the panel is texture.
Deployment or availability indicators near a project, or a media panel that needs a CRT feel without an image. Use the accent square for one live state per viewport, white for in progress, hollow for archived.
Do not use the accent square for more than one live state per viewport. Do not add a colored dot to every list row, the dot is for real state only. Keep traveler count low, five is enough.
<!-- Requires GSAP: https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js -->
<style>
:root{
--black:#050505;--white:#FFFFFF;--grey:#A3A3A3;--grey-dim:#6B6B6B;--accent:#22D3EE;
--line:rgba(255,255,255,.16);--line-faint:rgba(255,255,255,.07);
--font-display:'Space Grotesk',system-ui,sans-serif;--font-mono:'JetBrains Mono',ui-monospace,monospace;
}
*{margin:0;padding:0;box-sizing:border-box;border-radius:0!important}
body{background:var(--black);color:var(--white);font-family:var(--font-display);min-height:100vh;padding:64px 48px}
.status-squares{display:flex;gap:24px;flex-wrap:wrap;max-width:640px;margin:0 auto 24px}
.status-squares span{font-family:var(--font-mono);font-size:12px;letter-spacing:.06em;color:var(--grey)}
.status-squares i{display:inline-block;width:9px;height:9px;margin-right:8px}
.scan-panel{
max-width:640px;height:220px;margin:0 auto;border:1px solid var(--line);position:relative;overflow:hidden;
background:repeating-linear-gradient(0deg,transparent,transparent 5px,var(--line-faint) 5px,var(--line-faint) 6px);
display:flex;align-items:center;justify-content:center;
}
.scan-panel .cap{font-family:var(--font-mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--grey);position:relative;z-index:1}
.traveler{position:absolute;top:0;bottom:0;left:0;width:1px;background:rgba(255,255,255,.12);pointer-events:none}
</style>
<div class="status-squares">
<span><i class="sq" style="background:var(--accent)"></i>LIVE / DEPLOYED</span>
<span><i class="sq" style="background:var(--white)"></i>IN PROGRESS</span>
<span><i class="sq" style="border:1px solid var(--grey-dim)"></i>ARCHIVED</span>
</div>
<div class="scan-panel" id="panel">
<span class="cap">IMG: B&W · +10 CONTRAST · SCANLINE OVERLAY</span>
</div>
<script>
const mm = gsap.matchMedia();
mm.add({
full: "(prefers-reduced-motion: no-preference)",
reduce: "(prefers-reduced-motion: reduce)"
}, (ctx) => {
if (!ctx.conditions.full) return;
// breathing squares - slow opacity pulse, each square offset
gsap.to(".sq", {
opacity: 0.3,
duration: 1.6,
ease: "sine.inOut",
repeat: -1,
yoyo: true,
stagger: 0.5
});
// sparse vertical lines drifting right → left across the panel.
// repeatRefresh re-rolls the random duration each pass = random separation.
const panel = document.getElementById("panel");
for (let i = 0; i < 5; i++) {
const l = document.createElement("i");
l.className = "traveler";
panel.appendChild(l);
gsap.fromTo(l,
{ x: () => panel.clientWidth + 2 },
{
x: -2,
duration: () => gsap.utils.random(5, 14),
ease: "none",
repeat: -1,
repeatRefresh: true,
delay: gsap.utils.random(0, 6)
}
);
/* ponytail: start x reads clientWidth per repeat, so travelers self-correct
after resize within one pass - no resize listener needed */
}
});
</script>