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.