# Wet Asphalt at Night WebGL Background with Puddles That Mirror Coloured Shop Signs and Rain Rings

> Night street background in WebGL: a shader draws rain-soaked asphalt whose puddles mirror coloured shop signs and rain rings. Free React and HTML to copy.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/wet-asphalt-at-night-webgl-background-with-puddles-that-mirror-coloured-shop-signs-and-rain-rings).

- Page, with a live preview: https://shadcn.exchange/r/weinc/wet-asphalt-at-night-webgl-background-with-puddles-that-mirror-coloured-shop-signs-and-rain-rings
- Screenshot: https://shadcn.exchange/shot/weinc/wet-asphalt-at-night-webgl-background-with-puddles-that-mirror-coloured-shop-signs-and-rain-rings/light.png
- Registry item: https://we.inc/r/wet-asphalt-at-night-webgl-background-with-puddles-that-mirror-coloured-shop-signs-and-rain-rings.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/wet-asphalt-at-night-webgl-background-with-puddles-that-mirror-coloured-shop-signs-and-rain-rings.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `components/we-inc/wet-asphalt-at-night-webgl-background-with-puddles-that-mirror-coloured-shop-signs-and-rain-rings.tsx`

```tsx
import { useEffect, useRef, useState } from "react";

// Everything about the street. Colours are red, green, blue from 0 to 1.
const street = {
  speed: 1, // how fast time passes for the rain and the walk; 0 freezes the street
  water: 0.5, // how much of the road lies under puddles; 0.35 is nearly drained, 0.62 flooded
  rain: 1, // strength of the rings where drops land; 0 leaves the puddles as still mirrors
  stone: 1, // height of the stone in the road surface; 0 is fresh, smooth tarmac
  glow: 1, // brightness of the signs across the street
  patch: [25, 8], // which stretch of road you stand on; any two numbers give a new set of puddles
  paint: 1, // 1 keeps the worn white line, 0 removes it
  eye: 1.35, // height of the viewer's eye above the road
  drift: 0.7, // how far the viewer wanders left and right
  quality: 0.8, // share of the device pixels that are drawn
  maxPixels: 950000, // the canvas never draws more pixels than this
  signs: [
    [1.0, 0.1, 0.55], // magenta, the tall sign on the left
    [0.1, 0.85, 1.0], // cyan, the long sign in the middle
    [1.0, 0.6, 0.12], // amber, the ring
    [1.0, 0.16, 0.12], // red, the small square
    [0.3, 1.0, 0.5], // green, the thin sign on the right
  ],
};

const VERTEX = `
attribute vec2 position;
void main() { gl_Position = vec4(position, 0.0, 1.0); }`;

const FRAGMENT = `
precision highp float;
uniform vec2 uRes;
uniform float uTime;
uniform vec2 uEye;
uniform float uHeight;
uniform float uWater;
uniform float uRain;
uniform float uStone;
uniform float uGlow;
uniform float uPaint;
uniform vec2 uPatch;
uniform vec3 uSignA;
uniform vec3 uSignB;
uniform vec3 uSignC;
uniform vec3 uSignD;
uniform vec3 uSignE;

const float WALL = 14.0; // how far away the shop fronts are

float hash(vec2 p) {
  vec3 q = fract(vec3(p.xyx) * 0.1031);
  q += dot(q, q.yzx + 33.33);
  return fract((q.x + q.y) * q.z);
}
vec2 hash2(vec2 p) { return vec2(hash(p), hash(p.yx + 19.19)); }
float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  f = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
}

// The stone rolled into the tar, and the slow dips in the road where water collects.
float stones(vec2 x) { return 0.5 * noise(x * 58.0) + 0.38 * noise(x * 121.0 + 3.1) + 0.12 * noise(x * 23.0 + 8.0); }
float lie(vec2 x) {
  x += uPatch;
  return 0.62 * noise(x * 0.55 + 2.7) + 0.38 * noise(x * 1.35 + 9.4);
}

// The ring thrown out by one raindrop in each square of a loose grid.
vec2 rings(vec2 x, float seed) {
  vec2 g = x * 1.7 + seed * 5.3;
  vec2 id = floor(g);
  vec2 f = fract(g) - 0.5;
  vec2 away = f - (hash2(id + seed) - 0.5) * 0.3;
  float d = length(away);
  float age = fract(uTime * 0.6 + hash(id + seed * 3.0));
  float gap = (d - age * 0.4) * 16.0;
  float wave = sin(gap * 4.4) * exp(-gap * gap) * (1.0 - age) * (1.0 - age);
  return away / max(d, 0.001) * wave;
}

float box(vec2 d, vec2 h) {
  vec2 a = abs(d) - h;
  return length(max(a, 0.0)) + min(max(a.x, a.y), 0.0);
}
// A lit panel and a bent glass tube, each with the haze that hangs round it in damp air.
float panel(float sd, float soft) { return smoothstep(soft, -soft, sd) + 0.3 * exp(-max(sd, 0.0) / (soft * 2.0 + 0.3)); }
float tube(float sd, float soft) {
  float d = abs(sd) - 0.06;
  return 1.25 * smoothstep(soft, -soft, d) + 0.34 * exp(-max(d, 0.0) / (soft * 2.0 + 0.3));
}

// What a ray leaving the road sees across the street: tube signs on a dark frontage and a
// few lit windows above. rough smears every shape, far more up and down than side to side,
// which is what pulls a sign into a long streak on a wet road.
vec3 across(vec3 from, vec3 dir, float rough) {
  float s = (WALL - from.z) / max(dir.z, 0.02);
  vec2 q = from.xy + dir.xy * s;
  vec2 k = vec2(1.0, 1.0 / (1.0 + rough * 16.0)); // squeeze the wall, measure, and the blur comes out tall
  float soft = 0.035 + rough * 0.42;
  float thin = 1.0 / (1.0 + rough * 2.2);
  float buzz = 0.8 + 0.2 * step(0.12, noise(vec2(uTime * 9.0, 3.0)));
  vec2 d;

  // Magenta: a tall framed sign with a column of short strokes inside it.
  d = q - vec2(-5.2, 5.8);
  float a = tube(box(d * k, vec2(0.62, 2.9) * k) - 0.12, soft);
  a += panel(box(vec2(d.x, mod(d.y + 0.45, 0.9) - 0.45) * k, vec2(0.3, 0.13) * k), soft) * step(abs(d.y), 2.5);
  vec3 col = uSignA * buzz * a;
  // Cyan: a long frame with a row of upright strokes.
  d = q - vec2(0.8, 3.2);
  float c = tube(box(d * k, vec2(2.7, 0.62) * k) - 0.12, soft);
  c += panel(box(vec2(mod(d.x + 0.3, 0.6) - 0.3, d.y) * k, vec2(0.1, 0.3) * k), soft) * step(abs(d.x), 2.3);
  col += uSignB * c;
  // Amber: a ring with a dot in it.
  d = (q - vec2(5.6, 6.6)) * k;
  col += uSignC * (tube(length(d) - 1.2 * mix(1.0, k.y, 0.5), soft) + panel(length(d) - 0.3 * k.y, soft));
  // Red: one round lamp. Green: a thin upright.
  d = (q - vec2(-1.7, 8.4)) * k;
  col += uSignD * 1.1 * (tube(length(d) - 0.62 * mix(1.0, k.y, 0.5), soft) + panel(length(d) - 0.2 * k.y, soft));
  col += uSignE * panel(box((q - vec2(9.3, 4.6)) * k, vec2(0.2, 2.3) * k), soft);
  // More of the same further along and higher up.
  col += uSignB * 0.8 * tube(box((q - vec2(-10.2, 3.4)) * k, vec2(1.8, 0.5) * k) - 0.1, soft);
  col += uSignA * 0.8 * tube(box((q - vec2(2.8, 11.0)) * k, vec2(1.7, 0.4) * k) - 0.1, soft);
  col += uSignD * 0.9 * tube(box((q - vec2(7.4, 15.0)) * k, vec2(0.5, 3.2) * k) - 0.1, soft);
  col += uSignC * 0.8 * panel(box((q - vec2(-8.0, 12.0)) * k, vec2(0.22, 2.6) * k), soft);
  col += uSignE * 0.7 * tube(length((q - vec2(-2.6, 17.5)) * k) - 1.0 * mix(1.0, k.y, 0.5), soft);
  col += vec3(1.0, 0.8, 0.55) * 1.5 * panel(length((q - vec2(-3.6, 12.8)) * k) - 0.25 * k.y, soft); // a street lamp
  col *= thin * uGlow * 5.0;

  // The sky over the roofs carries the town's glow.
  col += vec3(0.17, 0.11, 0.24) * (0.3 + 0.7 * smoothstep(0.0, 26.0, q.y));
  return col;
}

// Light arriving at the road from one sign, on a surface facing n.
vec3 wash(vec3 g, vec3 n, vec3 at, vec3 tint, float power) {
  vec3 to = at - g;
  float far = dot(to, to);
  return tint * power * max(dot(n, to / sqrt(far)), 0.0) / (1.0 + far * 0.03);
}

void main() {
  // An upright screen gets a wider lens and looks further down, so it still takes in several signs.
  float tall = step(uRes.x, uRes.y);
  vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y * (1.0 + 0.3 * tall);
  vec3 rd = normalize(vec3(uv.x, uv.y, 1.08));
  float tilt = 0.56 + 0.1 * tall + uEye.y;
  rd = vec3(rd.x, rd.y * cos(tilt) - rd.z * sin(tilt), rd.y * sin(tilt) + rd.z * cos(tilt));
  vec3 ro = vec3(uEye.x, uHeight, 0.0);
  float t = ro.y / max(-rd.y, 0.02);
  vec3 g = ro + rd * t;
  vec2 x = g.xz;
  float near = 1.0 / (1.0 + t * 0.22); // fine detail is let go with distance so it does not shimmer

  float e = 0.003;
  float h = stones(x);
  vec2 slope = vec2(stones(x + vec2(e, 0.0)) - h, stones(x + vec2(0.0, e)) - h) / e;
  float low = lie(x);
  float depth = uWater - low - (h - 0.5) * 0.07 * uStone * near;
  float pool = smoothstep(0.0, 0.028, depth);
  float damp = smoothstep(-0.12, 0.0, depth);

  vec2 ripple = (rings(x, 1.0) + rings(x * 1.31 + 7.0, 2.0)) * 0.05 * uRain;
  ripple += (vec2(noise(x * 5.0 + uTime * 0.4), noise(x * 5.0 - uTime * 0.33 + 4.0)) - 0.5) * 0.008;
  vec3 nRoad = normalize(vec3(-slope.x * 0.009 * uStone * near * near, 1.0, -slope.y * 0.009 * uStone * near * near));
  vec3 nPool = normalize(vec3(-ripple.x, 1.0, -ripple.y));
  vec3 n = normalize(mix(nRoad, nPool, pool));

  // The road itself: black binder, paler stone showing through where tyres have worn it.
  float grit = smoothstep(0.52, 0.74, h);
  vec3 tar = mix(vec3(0.02, 0.021, 0.025), vec3(0.075, 0.076, 0.08), grit * near);
  // A worn white line running away up the street.
  float side = x.x + 0.9 - x.y * 0.16;
  float worn = smoothstep(0.38, 0.62, noise(x * 11.0) * 0.5 + noise(x * 2.2 + 5.0) * 0.5 + (0.5 - h) * 0.7);
  float line = uPaint * smoothstep(0.085, 0.07, abs(side)) * worn;
  tar = mix(tar, vec3(0.36, 0.36, 0.34) * (0.7 + 0.4 * h), line * 0.85);
  tar *= mix(1.0, 0.6, damp); // wet stone is darker
  tar *= mix(1.0, 0.3, pool); // and darker again seen through water

  vec3 light = wash(g, n, vec3(-5.2, 5.6, WALL), uSignA, 6.0);
  light += wash(g, n, vec3(0.6, 3.1, WALL), uSignB, 6.5);
  light += wash(g, n, vec3(5.4, 6.4, WALL), uSignC, 6.0);
  light += wash(g, n, vec3(9.2, 4.4, WALL), uSignE, 3.5);
  light += wash(g, n, vec3(-3.4, 12.6, 5.0), vec3(1.0, 0.8, 0.55), 7.0);
  vec3 col = tar * (light * uGlow + vec3(0.05, 0.05, 0.07));

  // The film of water on top mirrors the far side of the street.
  vec3 r = reflect(rd, n);
  float facing = max(dot(-rd, n), 0.0);
  float fres = 0.05 + 0.95 * pow(1.0 - facing, 3.0);
  float rough = mix(0.34 + 0.3 * noise(x * 3.0) + 0.25 * (1.0 - damp), 0.0, pool);
  vec3 seen = across(g, r, rough);
  col += seen * fres * mix(0.34, 1.0, pool) * (0.5 + 0.5 * damp);

  // Haze in the distance, vignette, grain.
  col += vec3(0.05, 0.02, 0.06) * smoothstep(3.5, 9.0, t) * 0.35;
  vec2 c = gl_FragCoord.xy / uRes - 0.5;
  col *= 1.0 - 0.7 * dot(c, c);
  col = 1.0 - exp(-col * 2.3);
  col += (hash(gl_FragCoord.xy + fract(uTime) * 31.0) - 0.5) * 0.018;
  gl_FragColor = vec4(pow(max(col, 0.0), vec3(0.8)), 1.0);
}`;

const mix = (a: number, b: number, t: number) => a + (b - a) * t;

/** One fragment shader fills the block. `onLive` reports when a frame has been painted. */
function StreetCanvas({ onLive }: { onLive: (live: boolean) => void }) {
  const ref = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = ref.current;
    const gl = canvas?.getContext("webgl", { antialias: false, alpha: false });
    if (!canvas || !gl) return; // no WebGL: the CSS street underneath stays

    const compile = (type: number, source: string) => {
      const s = gl.createShader(type)!;
      gl.shaderSource(s, source);
      gl.compileShader(s);
      return s;
    };
    const program = gl.createProgram()!;
    gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX));
    gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAGMENT));
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;
    gl.useProgram(program);
    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
    const position = gl.getAttribLocation(program, "position");
    gl.enableVertexAttribArray(position);
    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
    const at = (name: string) => gl.getUniformLocation(program, name);
    const u = { res: at("uRes"), time: at("uTime"), eye: at("uEye") };
    gl.uniform1f(at("uHeight"), street.eye);
    gl.uniform1f(at("uWater"), street.water);
    gl.uniform1f(at("uRain"), street.rain);
    gl.uniform1f(at("uStone"), street.stone);
    gl.uniform1f(at("uGlow"), street.glow);
    gl.uniform1f(at("uPaint"), street.paint);
    gl.uniform2f(at("uPatch"), street.patch[0], street.patch[1]);
    ["uSignA", "uSignB", "uSignC", "uSignD", "uSignE"].forEach((name, i) => gl.uniform3fv(at(name), street.signs[i]));

    const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const eye = { x: 0, y: 0, tx: 0, ty: 0, touched: -1e9 };
    let raf = 0;
    let visible = true;
    let time = 12;
    let before = 0;

    const draw = () => {
      gl.uniform1f(u.time, time);
      gl.uniform2f(u.eye, eye.x * street.drift, eye.y * 0.05);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    };
    const resize = () => {
      const w = canvas.clientWidth;
      const h = canvas.clientHeight || 1;
      const scale = Math.min(Math.min(Math.max(window.devicePixelRatio || 1, 1), 2) * street.quality, Math.sqrt(street.maxPixels / Math.max(1, w * h)));
      canvas.width = Math.max(1, Math.round(w * scale));
      canvas.height = Math.max(1, Math.round(h * scale));
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.uniform2f(u.res, canvas.width, canvas.height);
      draw();
      onLive(true);
    };
    const loop = (now: number) => {
      raf = requestAnimationFrame(loop);
      const dt = Math.min((now - (before || now)) / 1000, 0.05);
      before = now;
      time += dt * street.speed;
      // Left alone, the viewer shifts their weight from foot to foot, and the mirrored signs slide.
      if (now - eye.touched > 2600) {
        eye.tx = Math.sin(time * 0.35);
        eye.ty = 0.5 * Math.sin(time * 0.23 + 1);
      }
      const k = 1 - Math.exp(-dt * 2.6);
      eye.x = mix(eye.x, eye.tx, k);
      eye.y = mix(eye.y, eye.ty, k);
      draw();
    };
    const start = () => {
      cancelAnimationFrame(raf);
      before = 0;
      if (still) draw();
      else if (visible && !document.hidden) raf = requestAnimationFrame(loop);
    };
    const onPointer = (e: PointerEvent) => {
      const box = canvas.getBoundingClientRect();
      const x = ((e.clientX - box.left) / box.width) * 2 - 1;
      const y = 1 - ((e.clientY - box.top) / box.height) * 2;
      if (Math.abs(x) > 1 || Math.abs(y) > 1) return;
      eye.tx = x * 1.6;
      eye.ty = -y;
      eye.touched = performance.now();
    };
    const onLost = (e: Event) => {
      e.preventDefault();
      cancelAnimationFrame(raf);
      onLive(false);
    };

    const sizeWatcher = new ResizeObserver(resize);
    sizeWatcher.observe(canvas);
    const viewWatcher = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      start();
    });
    viewWatcher.observe(canvas);
    document.addEventListener("visibilitychange", start);
    if (!still) window.addEventListener("pointermove", onPointer, { passive: true });
    canvas.addEventListener("webglcontextlost", onLost);
    resize();
    start();

    return () => {
      cancelAnimationFrame(raf);
      sizeWatcher.disconnect();
      viewWatcher.disconnect();
      document.removeEventListener("visibilitychange", start);
      window.removeEventListener("pointermove", onPointer);
      canvas.removeEventListener("webglcontextlost", onLost);
      gl.getExtension("WEBGL_lose_context")?.loseContext();
    };
  }, [onLive]);

  return <canvas ref={ref} aria-hidden="true" className="absolute inset-0 h-full w-full" />;
}

/** The street without a shader: blurred colour streaks on near-black. Shown first, and kept when WebGL is missing. */
function StillStreet() {
  return (
    <div aria-hidden="true" className="absolute inset-0 overflow-hidden bg-[#07070b]">
      <div className="absolute left-[14%] top-[-10%] h-[85%] w-[7%] bg-[linear-gradient(180deg,#ff1a8c_0%,rgba(255,26,140,0.35)_55%,rgba(255,26,140,0)_100%)] blur-2xl" />
      <div className="absolute left-[38%] top-[-10%] h-[60%] w-[26%] bg-[linear-gradient(180deg,#19d9ff_0%,rgba(25,217,255,0.3)_50%,rgba(25,217,255,0)_100%)] blur-3xl" />
      <div className="absolute left-[70%] top-[-10%] h-[95%] w-[9%] bg-[linear-gradient(180deg,#ff991f_0%,rgba(255,153,31,0.35)_60%,rgba(255,153,31,0)_100%)] blur-2xl" />
      <div className="absolute left-[88%] top-[-10%] h-[70%] w-[4%] bg-[linear-gradient(180deg,#4dff80_0%,rgba(77,255,128,0.3)_55%,rgba(77,255,128,0)_100%)] blur-2xl" />
      <div className="absolute inset-0 bg-[radial-gradient(120%_90%_at_50%_0%,rgba(7,7,11,0)_30%,rgba(7,7,11,0.85)_100%)]" />
    </div>
  );
}

export default function WetAsphaltAtNightWebglBackgroundWithPuddlesThatMirrorColouredShopSignsAndRainRings() {
  const [live, setLive] = useState(false);

  return (
    <section className="relative isolate h-[620px] overflow-hidden bg-[#07070b] sm:h-[760px]">
      <StillStreet />
      <div className={`pointer-events-none absolute inset-0 transition-opacity duration-700 motion-reduce:transition-none ${live ? "opacity-100" : "opacity-0"}`}>
        <StreetCanvas onLive={setLive} />
      </div>
      <p className="absolute bottom-5 left-5 rounded-full bg-black/80 px-3.5 py-2 font-mono text-[11px] uppercase tracking-[0.2em] text-zinc-50 sm:bottom-7 sm:left-7">
        Wet asphalt <span className="hidden text-cyan-200 sm:inline">/ after rain, signs across the road</span>
      </p>
    </section>
  );
}
```
