Dot Wave Hero — Annular Falloff
Dustin Adkison · 1 hour ago · v3
Code written by a VibePen user, not by VibePen. It runs sandboxed on an isolated origin with no network access. Nobody at VibePen has reviewed it — treat it like any page from a stranger, and never enter a password or payment details.
A canvas dot field for a hero section. Concentric waves travel outward, and dot density/brightness peaks in a ring set slightly off-center — soft in the middle so the headline stays crisp, soft at the edges so it dissolves into the background. Cursor adds a proximity glow and trails ripples; click fires a strong one. Gear icon opens live tuning for ring position, falloff, spacing, wave density, speed and brightness.
The prompt behind this Pen
In VibePen, could you create a background for a hero section that is dots that react in a wave pattern? I want the dots to be softer the further it gets away from the center, and I want it softer in the center too, so the highest concentration should be slightly out. This way a hero title would not be washed out.
Source
15.8 KB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Particle Ring Hero</title>
<style>
:root{
--bg: #111113;
--ink: #f2f2f4;
--ink-dim: #8d8d94;
--line: rgba(255,255,255,.09);
}
*{ box-sizing:border-box; }
html,body{ height:100%; }
body{
margin:0;
background:var(--bg);
color:var(--ink);
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing:antialiased;
}
.hero{
position:relative;
min-height:100svh;
display:grid;
place-items:center;
overflow:hidden;
}
#field{
position:absolute;
inset:0;
width:100%; height:100%;
display:block;
z-index:0;
}
.hero__inner{
position:relative;
z-index:2;
width:100%;
max-width:560px;
padding:0 24px;
text-align:center;
}
h1{
margin:0 0 34px;
font-size:clamp(32px, 5vw, 52px);
line-height:1.1;
letter-spacing:-.028em;
font-weight:400;
color:var(--ink);
}
.prompt{
display:flex; align-items:center; gap:12px;
background:#1a1a1d;
border:1px solid var(--line);
border-radius:18px;
padding:14px 14px 14px 20px;
text-align:left;
box-shadow:0 18px 50px rgba(0,0,0,.45);
}
.prompt input{
flex:1; min-width:0;
background:transparent; border:0; outline:0;
color:var(--ink); font:inherit; font-size:15px;
}
.prompt input::placeholder{ color:#6e6e76; }
.prompt button{
flex:none; border:0; cursor:pointer;
font:inherit; font-size:14px; font-weight:500;
color:#8d8d94; background:#232327;
padding:9px 16px; border-radius:12px;
transition:background .2s ease, color .2s ease;
}
.prompt button:hover{ background:#2e2e33; color:var(--ink); }
.suggests{
margin:26px 0 0;
padding:0; list-style:none;
display:flex; flex-direction:column; gap:12px;
align-items:flex-start;
width:max-content;
margin-inline:auto;
}
.suggests li{
display:flex; align-items:center; gap:11px;
font-size:14px; color:#9a9aa2; cursor:pointer;
transition:color .18s ease;
}
.suggests li:hover{ color:var(--ink); }
.suggests svg{ flex:none; opacity:.75; }
/* ---- tuning panel ---- */
#gear{
position:fixed; right:16px; bottom:16px; z-index:20;
width:36px; height:36px; border-radius:10px; cursor:pointer;
display:grid; place-items:center;
background:rgba(30,30,34,.8); color:#9a9aa2;
border:1px solid var(--line);
backdrop-filter:blur(8px);
}
#gear:hover{ color:#fff; }
#panel{
position:fixed; right:16px; bottom:60px; z-index:20;
width:262px; padding:14px 16px 16px;
border-radius:12px;
background:rgba(22,22,26,.94);
border:1px solid var(--line);
backdrop-filter:blur(14px);
box-shadow:0 20px 50px rgba(0,0,0,.55);
font-size:11.5px; color:#9a9aa2;
display:none;
}
#panel.open{ display:block; }
#panel h3{ margin:0 0 12px; font-size:10.5px; letter-spacing:.12em; text-transform:uppercase; color:#6e6e76; font-weight:600; }
.row{ margin-bottom:9px; }
.row label{ display:flex; justify-content:space-between; margin-bottom:5px; }
.row label b{ color:#e6e6ea; font-weight:500; font-variant-numeric:tabular-nums; }
input[type=range]{ width:100%; height:3px; -webkit-appearance:none; appearance:none; background:rgba(255,255,255,.14); border-radius:2px; }
input[type=range]::-webkit-slider-thumb{ -webkit-appearance:none; width:12px; height:12px; border-radius:50%; background:#4d8bf5; cursor:pointer; }
input[type=range]::-moz-range-thumb{ width:12px; height:12px; border:0; border-radius:50%; background:#4d8bf5; cursor:pointer; }
.hint{ margin-top:10px; font-size:10.5px; line-height:1.5; color:#5c5c64; }
</style>
</head>
<body>
<section class="hero">
<canvas id="field"></canvas>
<div class="hero__inner">
<h1>Bring your idea to life</h1>
<div class="prompt">
<input type="text" placeholder="Describe your idea in a sentence or two" />
<button type="button">Get started</button>
</div>
<ul class="suggests">
<li><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H10a2 2 0 0 1 2 2v14a1.5 1.5 0 0 0-1.5-1.5H5.5A1.5 1.5 0 0 1 4 17V5.5Z"/><path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H14a2 2 0 0 0-2 2v14a1.5 1.5 0 0 1 1.5-1.5h5A1.5 1.5 0 0 0 20 17V5.5Z"/></svg> A conversational language learning app</li>
<li><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="9" cy="9" r="1.6"/><path d="m4 16 4.5-4.5L14 17"/></svg> An app to remove backgrounds</li>
<li><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M12 17c2.8 0 5-2.1 5-4.7 0-3-3-5.6-5-7.3-2 1.7-5 4.3-5 7.3C7 14.9 9.2 17 12 17Z"/></svg> Help me design my dream garden</li>
</ul>
</div>
</section>
<div id="gear" title="Tune the field">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1 1.56V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 8.9 19.3a1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.56-1H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.56V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.56 1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9v0a1.7 1.7 0 0 0 1.56 1H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1z"/>
</svg>
</div>
<div id="panel">
<h3>Particle ring</h3>
<div class="row"><label>Tilt <b id="v-tilt">26</b>°</label><input id="s-tilt" type="range" min="8" max="70" step="1" value="26"></div>
<div class="row"><label>Inner edge <b id="v-inner">0.46</b></label><input id="s-inner" type="range" min="0.05" max="0.9" step="0.01" value="0.46"></div>
<div class="row"><label>Ring peak <b id="v-peak">0.80</b></label><input id="s-peak" type="range" min="0.3" max="1.1" step="0.01" value="0.80"></div>
<div class="row"><label>Outer fade <b id="v-outer">1.24</b></label><input id="s-outer" type="range" min="0.6" max="1.6" step="0.01" value="1.24"></div>
<div class="row"><label>Wave height <b id="v-amp">64</b></label><input id="s-amp" type="range" min="0" max="160" step="2" value="64"></div>
<div class="row"><label>Wave scale <b id="v-scale">1.00</b></label><input id="s-scale" type="range" min="0.35" max="2.5" step="0.05" value="1"></div>
<div class="row"><label>Speed <b id="v-speed">1.00</b></label><input id="s-speed" type="range" min="0" max="2.5" step="0.05" value="1"></div>
<div class="row"><label>Spin <b id="v-spin">0.05</b></label><input id="s-spin" type="range" min="-0.3" max="0.3" step="0.01" value="0.05"></div>
<div class="row"><label>Density <b id="v-count">2400</b></label><input id="s-count" type="range" min="600" max="6000" step="100" value="2400"></div>
<div class="row"><label>Blue mix <b id="v-blue">0.06</b></label><input id="s-blue" type="range" min="0" max="0.4" step="0.01" value="0.06"></div>
<div class="hint">Move the pointer over the ring to push the surface.</div>
</div>
<script>
(() => {
const canvas = document.getElementById('field');
const ctx = canvas.getContext('2d');
const hero = document.querySelector('.hero');
const TAU = Math.PI * 2;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ---- tunables -------------------------------------------------------
const C = {
count: 2400,
tilt: 26, // degrees above the plane of the ring (small = more edge-on)
inner: 0.46, // where the ring starts, as a fraction of the ring radius
peak: 0.80, // where density is highest
outer: 1.24, // where it has faded to nothing
innerSoft: 0.30, // width of the inner ramp
amp: 64, // wave height in world units
scale: 1.0, // wavelength multiplier
speed: 1.0,
spin: 0.05, // radians/sec the whole ring turns
blue: 0.06, // fraction of particles that take the accent colour
dist: 4600, // camera distance (drives how strong the perspective is)
pushR: 210,
pushAmp: 46
};
// Travelling swell across the plane of the ring.
const WAVES = [
{ dir: [ 1.00, 0.22], len: 1.35, amp: 0.55, spd: 0.42 },
{ dir: [ 0.35, -1.00], len: 0.82, amp: 0.28, spd: 0.61 },
{ dir: [-0.70, 0.62], len: 2.10, amp: 0.34, spd: 0.26 }
];
WAVES.forEach(w => { const m = Math.hypot(w.dir[0], w.dir[1]); w.dir[0] /= m; w.dir[1] /= m; });
// ---- particle sprites ----------------------------------------------
function sprite(r, g, b) {
const S = 24, c = document.createElement('canvas');
c.width = c.height = S;
const x = c.getContext('2d');
const grd = x.createRadialGradient(S/2, S/2, 0, S/2, S/2, S/2);
grd.addColorStop(0.00, 'rgba(' + r + ',' + g + ',' + b + ',1)');
grd.addColorStop(0.30, 'rgba(' + r + ',' + g + ',' + b + ',0.97)');
grd.addColorStop(0.46, 'rgba(' + r + ',' + g + ',' + b + ',0.35)');
grd.addColorStop(0.70, 'rgba(' + r + ',' + g + ',' + b + ',0.05)');
grd.addColorStop(1.00, 'rgba(' + r + ',' + g + ',' + b + ',0)');
x.fillStyle = grd;
x.fillRect(0, 0, S, S);
return c;
}
const SPR_WHITE = sprite(238, 240, 246);
const SPR_BLUE = sprite(58, 124, 246);
// ---- geometry -------------------------------------------------------
let W = 0, H = 0, dpr = 1, R = 600;
let P = null; // particle store
const smoothstep = (a, b, t) => {
if (a === b) return t < a ? 0 : 1;
let x = (t - a) / (b - a);
x = x < 0 ? 0 : x > 1 ? 1 : x;
return x * x * (3 - 2 * x);
};
// Soft on the inside, soft on the outside, densest slightly out. This is the
// whole point: the middle stays clear so the headline never fights the field.
function density(u) {
const inner = smoothstep(C.inner, C.peak, u); // 0 at the hole, 1 at the peak
const outer = 1 - smoothstep(C.peak + 0.05, C.outer, u); // fades away outward
return Math.pow(inner, 1.6) * outer;
}
// deterministic PRNG so a resize doesn't reshuffle the whole sky
function mulberry(seed) {
return function () {
seed |= 0; 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;
};
}
function seedParticles() {
const rnd = mulberry(20260813);
const n = C.count | 0;
P = {
r: new Float32Array(n), // rest radius, 0..outer
a: new Float32Array(n), // rest angle
al: new Float32Array(n), // alpha
sz: new Float32Array(n), // base size
blue: new Uint8Array(n),
n: 0
};
let made = 0, guard = 0;
while (made < n && guard < n * 60) {
guard++;
// area-uniform sample across the annulus, then thinned by the density curve
const u = Math.sqrt(rnd() * (C.outer * C.outer - 0.04) + 0.04);
const d = density(u);
if (d <= 0.001 || rnd() > Math.pow(d, 0.55)) continue;
P.r[made] = u;
P.a[made] = rnd() * TAU;
P.al[made] = (0.20 + 0.80 * Math.pow(rnd(), 1.4)) * Math.pow(d, 0.45);
P.sz[made] = 0.70 + 0.95 * Math.pow(rnd(), 2.2);
P.blue[made] = rnd() < C.blue ? 1 : 0;
made++;
}
P.n = made;
}
function resize() {
const rc = hero.getBoundingClientRect();
W = Math.round(rc.width);
H = Math.round(rc.height);
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// ring radius: wide enough to run off the sides a little
R = Math.max(W * 0.47, H * 0.68);
}
// ---- pointer --------------------------------------------------------
let px = -99999, py = -99999, pActive = false;
if (!reduced) {
hero.addEventListener('pointermove', (e) => {
const rc = hero.getBoundingClientRect();
px = e.clientX - rc.left; py = e.clientY - rc.top; pActive = true;
}, { passive: true });
hero.addEventListener('pointerleave', () => { pActive = false; }, { passive: true });
}
// ---- render ---------------------------------------------------------
let clock = 0, last = performance.now();
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
if (!reduced) clock += dt * C.speed;
const t = clock;
ctx.clearRect(0, 0, W, H);
const e = C.tilt * Math.PI / 180;
const se = Math.sin(e), ce = Math.cos(e);
const cx = W / 2, cy = H * 0.5;
const D = C.dist, F = D; // focal length == distance: unit scale at origin
const amp = C.amp, sc = C.scale;
const spinA = t * C.spin;
const n = P.n;
for (let i = 0; i < n; i++) {
const rr = P.r[i] * R;
const ang = P.a[i] + spinA;
const X = rr * Math.cos(ang);
const Z = rr * Math.sin(ang);
// --- height field: the dots physically rise and fall ---
let Y = 0;
for (let k = 0; k < WAVES.length; k++) {
const wv = WAVES[k];
const kn = TAU / (wv.len * sc * R);
Y += amp * wv.amp * Math.sin((X * wv.dir[0] + Z * wv.dir[1]) * kn - t * wv.spd * TAU * 0.25);
}
// --- project: tilt the plane, then divide by depth ---
const dep = Z * ce + Y * se; // toward the viewer
const zc = D - dep;
if (zc < 1) continue;
const k = F / zc;
let sx = cx + X * k;
let sy = cy + (Z * se - Y * ce) * k;
// --- pointer lifts the surface where it hovers ---
if (pActive) {
const ddx = sx - px, ddy = sy - py;
const q = (ddx * ddx + ddy * ddy) / (C.pushR * C.pushR);
if (q < 5) {
const f = Math.exp(-q * 1.4) * C.pushAmp;
const inv = 1 / (Math.sqrt(ddx * ddx + ddy * ddy) + 0.001);
sx += ddx * inv * f;
sy += ddy * inv * f;
}
}
if (sx < -20 || sx > W + 20 || sy < -20 || sy > H + 20) continue;
// nearer particles read slightly larger and brighter — that is depth,
// not a timed fade: a dot only brightens because it moved toward you.
const s = P.sz[i] * (0.80 + 0.42 * k) * 3.0;
const a = Math.min(1, P.al[i] * (0.70 + 0.52 * k) * 1.25);
ctx.globalAlpha = a;
ctx.drawImage(P.blue[i] ? SPR_BLUE : SPR_WHITE, sx - s / 2, sy - s / 2, s, s);
}
ctx.globalAlpha = 1;
requestAnimationFrame(frame);
}
// ---- wiring ---------------------------------------------------------
let rt;
window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(resize, 120); });
seedParticles();
resize();
requestAnimationFrame(frame);
const gear = document.getElementById('gear');
const panel = document.getElementById('panel');
gear.addEventListener('click', () => panel.classList.toggle('open'));
const bind = (id, key, fmt, reseed) => {
const s = document.getElementById('s-' + id);
const v = document.getElementById('v-' + id);
s.addEventListener('input', () => {
C[key] = parseFloat(s.value);
v.textContent = fmt(C[key]);
if (reseed) seedParticles();
});
};
const f2 = (n) => n.toFixed(2);
const int = (n) => String(n | 0);
bind('tilt', 'tilt', int, false);
bind('inner', 'inner', f2, true);
bind('peak', 'peak', f2, true);
bind('outer', 'outer', f2, true);
bind('amp', 'amp', int, false);
bind('scale', 'scale', f2, false);
bind('speed', 'speed', f2, false);
bind('spin', 'spin', f2, false);
bind('count', 'count', int, true);
bind('blue', 'blue', f2, true);
})();
</script>
</body>
</html>