VibePen

Dot Wave Hero — Annular Falloff

Dustin Adkison · 1 hour ago · v3

Open standalone ↗

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>&deg;</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>

Back to the gallery