Live demo, running in your browser

Fog letters

A giant MIST headline made of thousands of glowing fog particles that scatter and swirl away from the pointer, then slowly re-condense, with a wandering virtual cursor keeping it alive when idle.

Code: MIT license.

<!doctype html>
<html lang="en">
  <head
    ><script>
      /* 6to9 pattern harness */ (function () {
        var parentPaused = false,
          hidden = document.hidden,
          p = (window.__pattern = { paused: false, reducedMotion: false, maxDpr: 2 });
        function sync() {
          p.paused = parentPaused || hidden;
        }
        sync();
        try {
          p.reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
        } catch (e) {}
        document.addEventListener("visibilitychange", function () {
          hidden = document.hidden;
          sync();
        });
        addEventListener("message", function (e) {
          var d = e.data || {};
          if (d.pattern === "pause") {
            parentPaused = true;
            sync();
          }
          if (d.pattern === "play") {
            parentPaused = false;
            sync();
          }
        });
      })();
    </script>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Mist - fog letters hero</title>
    <style>
      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      html,
      body {
        height: 100%;
        overflow: hidden;
        background: #0d1626;
      }

      body {
        font-family:
          ui-sans-serif,
          system-ui,
          -apple-system,
          "Segoe UI",
          Roboto,
          Helvetica,
          Arial,
          sans-serif;
        touch-action: none;
        -webkit-font-smoothing: antialiased;
      }

      .hero {
        position: relative;
        width: 100vw;
        height: 100vh;
        height: 100dvh;
        overflow: hidden;
        background: radial-gradient(ellipse at 50% 40%, #27406a 0%, #1a2b4a 42%, #0e1829 100%);
      }

      .hero::after {
        content: "";
        position: absolute;
        inset: 0;
        pointer-events: none;
        background: radial-gradient(
          ellipse at 50% 45%,
          rgba(0, 0, 0, 0) 45%,
          rgba(5, 9, 18, 0.55) 100%
        );
      }

      canvas {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        display: block;
      }

      /* CSS fallback headline (hazy glass-fog look) when WebGL is unavailable */
      h1 {
        position: absolute;
        left: 50%;
        top: 40%;
        transform: translate(-50%, -50%);
        font-size: min(26vw, 44vh);
        font-weight: 900;
        letter-spacing: -0.04em;
        line-height: 0.9;
        color: rgba(214, 229, 250, 0.78);
        text-shadow:
          0 0 24px rgba(150, 190, 240, 0.55),
          0 0 80px rgba(120, 160, 220, 0.35);
        filter: blur(1.5px);
        z-index: 1;
      }

      /* With WebGL the particles are the headline; the h1 stays for screen readers */
      .gl h1 {
        left: 0;
        top: 0;
        transform: none;
        width: 1px;
        height: 1px;
        font-size: 1px;
        overflow: hidden;
        clip-path: inset(50%);
        filter: none;
        text-shadow: none;
        color: transparent;
      }

      .copy {
        position: absolute;
        left: 50%;
        top: 62%;
        transform: translateX(-50%);
        width: min(88vw, 520px);
        text-align: center;
        z-index: 2;
        animation: rise 1.6s ease-out 0.4s both;
      }

      .copy p {
        color: #c3d1e6;
        font-size: clamp(15px, 1.25vw + 10px, 19px);
        line-height: 1.55;
        margin-bottom: 26px;
        text-wrap: balance;
      }

      .btn {
        appearance: none;
        border: 0;
        cursor: pointer;
        font: inherit;
        font-weight: 650;
        font-size: 16px;
        letter-spacing: 0.01em;
        color: #0f1a2c;
        background: #e6eefb;
        padding: 14px 30px;
        border-radius: 999px;
        box-shadow: 0 10px 30px rgba(6, 12, 24, 0.45);
        transition:
          transform 0.25s ease,
          background 0.25s ease;
      }

      .btn:hover {
        transform: translateY(-2px);
        background: #ffffff;
      }

      .btn:focus-visible {
        outline: 2px solid #9fc2f5;
        outline-offset: 3px;
      }

      @keyframes rise {
        from {
          opacity: 0;
          transform: translate(-50%, 12px);
        }
        to {
          opacity: 1;
          transform: translate(-50%, 0);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        .copy {
          animation: none;
        }
      }
    </style>
  </head>
  <body>
    <main class="hero" id="hero">
      <canvas id="fog" aria-hidden="true"></canvas>
      <h1 id="title">MIST</h1>
      <div class="copy" id="copy">
        <p
          >A calm workspace that clears as you move through it, then quietly settles back into
          focus.</p
        >
        <button class="btn" type="button">Join the waitlist</button>
      </div>
    </main>
    <script>
      (function () {
        "use strict";

        var canvas = document.getElementById("fog");
        var copy = document.getElementById("copy");
        var MAX_TRAIL = 32;
        var SPACING = 56;
        var FONT_STACK =
          "ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif";

        function pattern() {
          return window.__pattern || { paused: false, reducedMotion: false, maxDpr: 2 };
        }

        // ---------------------------------------------------------------
        // Shaders. Displacement happens on the GPU: every particle reads
        // a short pointer trail (position, birth time, velocity) and is
        // pushed along a curl-noise swirl that decays with age, so the fog
        // scatters and then re-condenses by itself. Kept deliberately
        // light (32 trail slots, early-outs) so it runs on weak GPUs.
        // ---------------------------------------------------------------
        var VERT = `#version 300 es
precision highp float;
in vec2 a_rest;
in vec4 a_data;
uniform vec2 u_res;
uniform float u_time;
uniform float u_now;
uniform float u_dpr;
uniform float u_radius;
uniform vec4 u_trail[32];
uniform vec2 u_vel[32];
out float v_alpha;
out float v_heat;
out float v_tint;

float hash(vec2 p) {
  p = fract(p * vec2(123.34, 456.21));
  p += dot(p, p + 45.32);
  return fract(p.x * p.y);
}

float vnoise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  f = f * f * (3.0 - 2.0 * f);
  float a = hash(i);
  float b = hash(i + vec2(1.0, 0.0));
  float c = hash(i + vec2(0.0, 1.0));
  float d = hash(i + vec2(1.0, 1.0));
  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

vec2 curl(vec2 p) {
  float e = 0.1;
  float a = vnoise(p + vec2(0.0, e)) - vnoise(p - vec2(0.0, e));
  float b = vnoise(p + vec2(e, 0.0)) - vnoise(p - vec2(e, 0.0));
  return vec2(a, -b) / (2.0 * e);
}

void main() {
  vec2 rest = a_rest;
  float s1 = a_data.x;
  float s2 = a_data.y;
  float amb = step(a_data.w, 0.12);
  float t = u_time;

  // rest state: tiny shimmer plus a slow invisible current
  vec2 p = rest;
  p += vec2(sin(t * 1.7 + s1 * 40.0), cos(t * 1.3 + s2 * 40.0)) * 0.9;
  p += curl(rest * 0.006 + vec2(t * 0.04, t * 0.03)) * mix(3.0, 40.0, amb);

  // pointer trail: scatter, swirl, then re-condense as the trail ages
  vec2 disp = vec2(0.0);
  float heat = 0.0;
  float r = u_radius * (0.75 + 0.5 * s2);
  float r2 = r * r;
  for (int i = 0; i < 32; i++) {
    vec4 tr = u_trail[i];
    if (tr.w <= 0.0) continue;
    vec2 d = rest - tr.xy;
    float dd = dot(d, d);
    if (dd > r2 * 4.6) continue;
    float age = (u_now - tr.z) * (0.75 + 0.5 * s1);
    if (age < 0.0 || age > 5.0) continue;
    float f = exp(-dd / r2);
    float life = exp(-age * 0.8) * smoothstep(0.0, 0.12, age);
    float w = f * life * tr.w * 0.3;
    vec2 dir = d / (sqrt(dd) + 1.0);
    vec2 perp = vec2(-dir.y, dir.x);
    vec2 fl = curl(rest * 0.007 + vec2(age * 0.25, s1 * 3.0));
    vec2 push = dir * 1.2 + perp * 0.9 + fl * 1.4 + u_vel[i] * 0.7;
    disp += push * w * (110.0 + 90.0 * s2);
    heat += w;
  }
  disp = disp / (1.0 + length(disp) / 320.0);
  p += disp;
  heat = clamp(heat * 1.5, 0.0, 1.0);

  vec2 ndc = vec2(p.x / u_res.x * 2.0 - 1.0, 1.0 - p.y / u_res.y * 2.0);
  gl_Position = vec4(ndc, 0.0, 1.0);
  gl_PointSize = a_data.z * u_dpr * (1.0 + heat * 1.2);
  float twinkle = 0.7 + 0.3 * sin(t * 2.1 + s1 * 60.0);
  v_alpha = a_data.w * twinkle * (1.0 - 0.45 * heat);
  v_heat = heat;
  v_tint = s2;
}`;

        var FRAG = `#version 300 es
precision mediump float;
in float v_alpha;
in float v_heat;
in float v_tint;
out vec4 outColor;

void main() {
  float d = length(gl_PointCoord - 0.5);
  float a = smoothstep(0.5, 0.0, d);
  a *= a;
  vec3 cool = vec3(0.55, 0.70, 0.92);
  vec3 pale = vec3(0.92, 0.96, 1.0);
  vec3 col = mix(cool, pale, v_tint * 0.8 + 0.1);
  col = mix(col, cool, v_heat * 0.6);
  float al = a * v_alpha;
  outColor = vec4(col * al, al);
}`;

        var gl = null;
        try {
          gl = canvas.getContext("webgl2", {
            alpha: true,
            antialias: false,
            premultipliedAlpha: true,
          });
        } catch (err) {
          gl = null;
        }
        if (!gl) {
          return;
        }

        var program = null;
        var vao = null;
        var buffer = null;
        var loc = {};
        var scratch = document.createElement("canvas");
        var S = { W: 0, H: 0, dpr: 1, count: 0, cy: 0, radius: 150 };
        var trail = new Float32Array(MAX_TRAIL * 4);
        var vel = new Float32Array(MAX_TRAIL * 2);
        var head = 0;
        var clock = 0;
        var lastTs = performance.now();
        var needsStill = true;
        var lastWander = -1;
        var pointer = { has: false, x: 0, y: 0, t: 0, ex: 0, ey: 0, vn: 0, lastMove: -99 };

        function compile(type, src) {
          var shader = gl.createShader(type);
          gl.shaderSource(shader, src);
          gl.compileShader(shader);
          if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
            throw new Error(gl.getShaderInfoLog(shader));
          }
          return shader;
        }

        function clearTrail() {
          trail.fill(0);
          vel.fill(0);
        }

        function emit(x, y, vx, vy, strength) {
          var i = head;
          head = (head + 1) % MAX_TRAIL;
          trail[i * 4] = x;
          trail[i * 4 + 1] = y;
          trail[i * 4 + 2] = clock;
          trail[i * 4 + 3] = strength;
          vel[i * 2] = vx;
          vel[i * 2 + 1] = vy;
        }

        // Lay out the giant word, rasterise it to a scratch canvas and
        // turn the covered pixels into particle rest positions.
        function build() {
          var W = window.innerWidth;
          var H = window.innerHeight;
          var tall = W / H < 0.8;
          var lines = tall ? ["MI", "ST"] : ["MIST"];
          var n = lines.length;

          scratch.width = W;
          scratch.height = H;
          var ctx = scratch.getContext("2d", { willReadFrequently: true });
          var hasSpacing = "letterSpacing" in ctx;

          ctx.font = "900 100px " + FONT_STACK;
          if (hasSpacing) {
            ctx.letterSpacing = "-3px";
          }
          var maxW = 1;
          lines.forEach(function (line) {
            maxW = Math.max(maxW, ctx.measureText(line).width);
          });

          var em = (n - 1) * 0.86 + 0.72;
          var fsW = (100 * (W * 0.9)) / maxW;
          var fsH = (H * (tall ? 0.46 : 0.5)) / em;
          var fs = Math.min(fsW, fsH);
          var cy = H * (tall ? 0.34 : 0.41);
          var totalH = em * fs;

          ctx.clearRect(0, 0, W, H);
          ctx.font = "900 " + fs + "px " + FONT_STACK;
          if (hasSpacing) {
            ctx.letterSpacing = -0.03 * fs + "px";
          }
          ctx.fillStyle = "#ffffff";
          ctx.textAlign = "center";
          ctx.textBaseline = "alphabetic";
          var top = cy - totalH / 2;
          lines.forEach(function (line, i) {
            ctx.fillText(line, W / 2, top + 0.72 * fs + i * 0.86 * fs);
          });

          var img = ctx.getImageData(0, 0, W, H).data;
          var xs = [];
          var ys = [];
          for (var y = 0; y < H; y += 3) {
            for (var x = 0; x < W; x += 3) {
              if (img[(y * W + x) * 4 + 3] > 128) {
                xs.push(x);
                ys.push(y);
              }
            }
          }

          var covered = xs.length * 9;
          var minCount = tall ? 6000 : 5000;
          var textCount = Math.min(13000, Math.max(minCount, Math.round(covered * 0.05)));
          if (xs.length === 0) {
            textCount = 0;
          }
          var ambCount = Math.min(450, Math.max(200, Math.round((W * H) / 3000)));
          var total = textCount + ambCount;
          var data = new Float32Array(total * 6);
          var k = 0;
          var j;
          for (j = 0; j < textCount; j++) {
            var idx = Math.floor(Math.random() * xs.length);
            data[k++] = xs[idx] + (Math.random() - 0.5) * 3.0;
            data[k++] = ys[idx] + (Math.random() - 0.5) * 3.0;
            data[k++] = Math.random();
            data[k++] = Math.random();
            data[k++] = 2.6 + Math.random() * 2.0;
            data[k++] = 0.45 + Math.random() * 0.5;
          }
          for (j = 0; j < ambCount; j++) {
            data[k++] = Math.random() * W;
            data[k++] = Math.random() * H;
            data[k++] = Math.random();
            data[k++] = Math.random();
            data[k++] = 6.0 + Math.random() * 8.0;
            data[k++] = 0.03 + Math.random() * 0.06;
          }

          gl.bindVertexArray(vao);
          gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
          gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
          var aRest = gl.getAttribLocation(program, "a_rest");
          var aData = gl.getAttribLocation(program, "a_data");
          gl.enableVertexAttribArray(aRest);
          gl.vertexAttribPointer(aRest, 2, gl.FLOAT, false, 24, 0);
          gl.enableVertexAttribArray(aData);
          gl.vertexAttribPointer(aData, 4, gl.FLOAT, false, 24, 8);

          var dpr = Math.min(window.devicePixelRatio || 1, pattern().maxDpr || 2, 1.5);
          canvas.width = Math.round(W * dpr);
          canvas.height = Math.round(H * dpr);

          S.W = W;
          S.H = H;
          S.dpr = dpr;
          S.count = total;
          S.cy = cy;
          S.radius = Math.min(190, Math.max(80, Math.min(W, H) * 0.2));

          copy.style.top = Math.round(cy + totalH / 2 + Math.max(26, H * 0.055)) + "px";
          needsStill = true;
        }

        function draw() {
          gl.viewport(0, 0, canvas.width, canvas.height);
          gl.clearColor(0, 0, 0, 0);
          gl.clear(gl.COLOR_BUFFER_BIT);
          gl.useProgram(program);
          gl.uniform2f(loc.res, S.W, S.H);
          gl.uniform1f(loc.time, clock);
          gl.uniform1f(loc.now, clock);
          gl.uniform1f(loc.dpr, S.dpr);
          gl.uniform1f(loc.radius, S.radius);
          gl.uniform4fv(loc.trail, trail);
          gl.uniform2fv(loc.vel, vel);
          gl.bindVertexArray(vao);
          gl.drawArrays(gl.POINTS, 0, S.count);
        }

        try {
          program = gl.createProgram();
          gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
          gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
          gl.linkProgram(program);
          if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
            throw new Error(gl.getProgramInfoLog(program));
          }
          loc.res = gl.getUniformLocation(program, "u_res");
          loc.time = gl.getUniformLocation(program, "u_time");
          loc.now = gl.getUniformLocation(program, "u_now");
          loc.dpr = gl.getUniformLocation(program, "u_dpr");
          loc.radius = gl.getUniformLocation(program, "u_radius");
          loc.trail = gl.getUniformLocation(program, "u_trail[0]");
          loc.vel = gl.getUniformLocation(program, "u_vel[0]");
          vao = gl.createVertexArray();
          buffer = gl.createBuffer();
          gl.enable(gl.BLEND);
          gl.blendFunc(gl.ONE, gl.ONE);
          build();
          clock = 2;
          draw();
          document.body.classList.add("gl");
        } catch (err) {
          document.body.classList.remove("gl");
          return;
        }

        // ---------------------------------------------------------------
        // Pointer: position AND velocity. Points are dropped along the
        // path every SPACING px, carrying direction and speed.
        // ---------------------------------------------------------------
        function onMove(e) {
          if (pattern().reducedMotion) {
            return;
          }
          var x = e.clientX;
          var y = e.clientY;
          if (typeof x !== "number" || typeof y !== "number" || isNaN(x) || isNaN(y)) {
            return;
          }
          var now = performance.now();
          if (!pointer.has) {
            pointer.has = true;
            pointer.x = x;
            pointer.y = y;
            pointer.ex = x;
            pointer.ey = y;
            pointer.t = now;
            pointer.lastMove = clock;
            emit(x, y, 0, 0, 0.3);
            return;
          }
          var dx = x - pointer.x;
          var dy = y - pointer.y;
          var len = Math.hypot(dx, dy);
          if (len < 0.5) {
            return;
          }
          var dt = Math.max(now - pointer.t, 8);
          var vn = Math.min(len / dt / 1.6, 1);
          pointer.vn = pointer.vn * 0.5 + vn * 0.5;
          var ux = dx / len;
          var uy = dy / len;
          pointer.x = x;
          pointer.y = y;
          pointer.t = now;
          pointer.lastMove = clock;
          var strength = 0.3 + 0.7 * pointer.vn;
          var ex = x - pointer.ex;
          var ey = y - pointer.ey;
          var ed = Math.hypot(ex, ey);
          var guard = 0;
          while (ed >= SPACING && guard < 8) {
            var step = SPACING / ed;
            pointer.ex += ex * step;
            pointer.ey += ey * step;
            emit(pointer.ex, pointer.ey, ux * pointer.vn, uy * pointer.vn, strength);
            ex = x - pointer.ex;
            ey = y - pointer.ey;
            ed = Math.hypot(ex, ey);
            guard++;
          }
          if (guard >= 8) {
            pointer.ex = x;
            pointer.ey = y;
          }
        }

        window.addEventListener("pointermove", onMove, { passive: true });
        window.addEventListener("mousemove", onMove, { passive: true });

        // A wandering virtual pointer keeps the fog alive before any input
        // and after the real pointer has been still for a while.
        function wander() {
          var idle = !pointer.has || clock - pointer.lastMove > 3.5;
          if (!idle || clock - lastWander < 0.12) {
            return;
          }
          lastWander = clock;
          var t = clock;
          var x = S.W * (0.5 + 0.36 * Math.sin(t * 0.33));
          var y = S.cy + S.H * 0.14 * Math.sin(t * 0.51 + 0.7);
          var dx = S.W * 0.36 * 0.33 * Math.cos(t * 0.33);
          var dy = S.H * 0.14 * 0.51 * Math.cos(t * 0.51 + 0.7);
          var n = Math.hypot(dx, dy) || 1;
          emit(x, y, (dx / n) * 0.5, (dy / n) * 0.5, pointer.has ? 0.3 : 0.5);
        }

        function frame(ts) {
          requestAnimationFrame(frame);
          var p = pattern();
          var dt = Math.min((ts - lastTs) / 1000, 0.05);
          lastTs = ts;
          if (!(dt >= 0)) {
            dt = 0;
          }
          if (p.paused) {
            return;
          }
          if (p.reducedMotion) {
            if (needsStill) {
              clock = 3;
              clearTrail();
              draw();
              needsStill = false;
            }
            return;
          }
          needsStill = true;
          clock += dt;
          wander();
          draw();
        }

        var resizeTimer = 0;
        window.addEventListener("resize", function () {
          window.clearTimeout(resizeTimer);
          resizeTimer = window.setTimeout(function () {
            try {
              build();
              if (pattern().reducedMotion) {
                needsStill = true;
              }
            } catch (err) {
              document.body.classList.remove("gl");
            }
          }, 100);
        });

        requestAnimationFrame(frame);
      })();
    </script>
  </body>
</html>

Made by 6to9. Back to all motion patterns.