Live preview — fully interactive
The prompt
Build a full-viewport hero section with an interactive canvas particle field and a staggered headline entrance. ## Structure - A fixed-position <canvas> element behind a centered hero container (100vh) - Hero content: small uppercase eyebrow label, two-line H1, one-sentence subtitle, two buttons (primary filled, ghost outlined) ## Particle system (vanilla JS canvas) - ~110 particles (scale count with viewport width), each a small violet dot (rgba(124,108,255,0.7), radius 0.6-2.2px) - Particles drift slowly (velocity ±0.35px/frame) with 0.985 damping - Cursor repulsion: within 120px radius, push particles away with force proportional to proximity - Draw connective lines between particles closer than 110px, opacity fading with distance (max 0.14) - Respawn particles that leave the viewport; handle window resize ## Entrance animation (GSAP) - Each headline line is wrapped in an overflow-hidden mask; inner spans start at translateY(110%) - Animate spans to y:0 with power4.out, 1.1s, stagger 0.12s, 0.2s delay - Fade in subtitle at 0.8s, CTA buttons at 1.05s ## Design tokens - Background #08080d, text #f2f2f7, muted #8a8a99, accent #7c6cff - Headline: clamp(40px, 7vw, 88px), weight 700, letter-spacing -0.03em, line-height 1.05 - Buttons: 14px 28px padding, 10px radius, 15px/600 type ## Constraints - Single self-contained HTML file, GSAP 3.12 via cdnjs CDN, no other dependencies - Must run at 60fps on a laptop; keep particle count adaptive
The code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #08080d; color: #f2f2f7; font-family: 'Inter', system-ui, sans-serif; overflow: hidden; }
#field { position: fixed; inset: 0; }
.hero { position: relative; z-index: 2; height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 0 24px; }
.eyebrow { font-size: 13px; letter-spacing: 0.2em; text-transform: uppercase; color: #7c6cff; margin-bottom: 20px; }
h1 { font-size: clamp(40px, 7vw, 88px); line-height: 1.05; font-weight: 700; letter-spacing: -0.03em; }
h1 .line { display: block; overflow: hidden; }
h1 .line span { display: inline-block; transform: translateY(110%); }
.sub { margin-top: 24px; max-width: 520px; color: #8a8a99; font-size: 17px; line-height: 1.6; opacity: 0; }
.cta { margin-top: 36px; display: flex; gap: 14px; opacity: 0; }
.btn { padding: 14px 28px; border-radius: 10px; font-size: 15px; font-weight: 600; text-decoration: none; }
.btn-primary { background: #7c6cff; color: #fff; }
.btn-ghost { border: 1px solid #23232e; color: #f2f2f7; }
</style>
</head>
<body>
<canvas id="field"></canvas>
<section class="hero">
<p class="eyebrow">VibeFX Library</p>
<h1>
<span class="line"><span>Ship animated sites</span></span>
<span class="line"><span>in minutes, not days</span></span>
</h1>
<p class="sub">Copy one prompt. Paste it into your AI builder. Get this exact hero — particles, motion and all.</p>
<div class="cta">
<a class="btn btn-primary" href="#">Browse the library</a>
<a class="btn btn-ghost" href="#">See how it works</a>
</div>
</section>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script>
// ---- Particle field ----
const canvas = document.getElementById('field');
const ctx = canvas.getContext('2d');
let W, H, particles = [];
const mouse = { x: -9999, y: -9999 };
const COUNT = Math.min(110, Math.floor(window.innerWidth / 12));
function resize() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight;
}
resize();
window.addEventListener('resize', resize);
window.addEventListener('mousemove', (e) => { mouse.x = e.clientX; mouse.y = e.clientY; });
window.addEventListener('mouseleave', () => { mouse.x = -9999; mouse.y = -9999; });
class P {
constructor() { this.reset(); }
reset() {
this.x = Math.random() * W; this.y = Math.random() * H;
this.vx = (Math.random() - 0.5) * 0.35; this.vy = (Math.random() - 0.5) * 0.35;
this.r = Math.random() * 1.6 + 0.6;
}
step() {
const dx = this.x - mouse.x, dy = this.y - mouse.y;
const d2 = dx * dx + dy * dy;
if (d2 < 120 * 120) {
const d = Math.sqrt(d2) || 1;
const f = (120 - d) / 120 * 0.6;
this.vx += (dx / d) * f; this.vy += (dy / d) * f;
}
this.vx *= 0.985; this.vy *= 0.985;
this.x += this.vx; this.y += this.vy;
if (this.x < -20 || this.x > W + 20 || this.y < -20 || this.y > H + 20) this.reset();
}
draw() {
ctx.beginPath();
ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(124,108,255,0.7)';
ctx.fill();
}
}
for (let i = 0; i < COUNT; i++) particles.push(new P());
function tick() {
ctx.clearRect(0, 0, W, H);
for (const p of particles) { p.step(); p.draw(); }
// connective lines
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const a = particles[i], b = particles[j];
const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 < 110 * 110) {
ctx.strokeStyle = 'rgba(124,108,255,' + (0.14 * (1 - Math.sqrt(d2) / 110)) + ')';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
}
}
requestAnimationFrame(tick);
}
tick();
// ---- Entrance ----
gsap.to('h1 .line span', { y: 0, duration: 1.1, ease: 'power4.out', stagger: 0.12, delay: 0.2 });
gsap.to('.sub', { opacity: 1, y: 0, duration: 0.9, delay: 0.8 });
gsap.to('.cta', { opacity: 1, duration: 0.9, delay: 1.05 });
</script>
</body>
</html>About this effect
A full-viewport hero section built on a lightweight canvas particle system. Particles drift slowly, connect with lines when close, and repel from the visitor's cursor. The headline enters with a masked line-by-line GSAP stagger. Perfect for AI, SaaS and developer-tool landing pages.