M-026 Background advanced

How to build a raindrops-on-glass hero background with Canvas Canvas で作る窓ガラスを伝う雨粒のFV背景

A rainy-window hero background: beads merge, heavy drops run down and clear a streak, and each drop refracts an in-focus, upside-down copy of the blurred city behind it. 雨の窓を再現するヒーロー背景です。水滴が合体し、重くなった雨粒が筋を残して流れ、雨粒の中には背景の街明かりがピントの合った逆さまの姿で映ります。

ライブデモ Live Demo

Café Amayadori

雨の窓辺で、ひと休み A quiet seat by the rainy window

1.0
1.0

概要・用途・特徴Overview, Usage & Features

何ができるかWhat it does

A hero background that recreates a window on a rainy night: blurred city bokeh behind the glass, with beads, merging drops and running streaks drawn on top, each drop acting as a small lens.

雨の夜の窓を再現するヒーロー背景です。ガラスの向こうにぼけた街明かりがあり、その上に水滴・合体する雨粒・流れ落ちる筋を描きます。雨粒はそれぞれ小さなレンズとして振る舞います。

どこで使うかWhere to use

Use it behind the headline of café, bar, hotel, umbrella or apparel brands, rainy-season campaigns and any page that wants a calm, cinematic mood rather than a literal photo.

カフェ・バー・ホテル・傘やアパレルのブランド、梅雨のキャンペーンなど、写真をそのまま見せるより静かで映画的な雰囲気を出したい見出しの背景に向きます。

特徴Key features

Seeded city bokeh painted once per resize in soft and crisp versions, lens refraction with an upside-down in-focus image, merging beads, drops that start running once heavy, random stalls and wobble, trailing beads and a cleared streak, tap to add a drop, an area-based bead cap, resize tracking, a loop that pauses offscreen or in a hidden tab, and a still frame under prefers-reduced-motion.

リサイズ時だけ描くシード付きの街明かり(ぼけ版とくっきり版)、ピントの合った逆さ像を映すレンズ表現、合体する水滴、重くなると流れ出す雨粒、ランダムな引っかかりと揺れ、後ろに残る水滴と澄んだ筋、タップでの雨粒追加、面積に応じた水滴の上限、リサイズ追従、画面外・非表示タブでの停止、prefers-reduced-motion での静止描画に対応します。

調整可能パラメータ Adjustable Parameters

Parameter Default Description
rain1How much rain falls. 0 stops new drops.
size1Multiplier for the big running drops.
HUES[30, 42, 8, 196, 328, 48]Hues of the city lights.
maxDropsarea ÷ 2400 (40–420)Cap on the number of beads.
span5How much of the scene each drop shows, in radii.

実装コード Implementation Code

<!-- (1) Hero: one canvas for the glass, copy on top -->
<section class="rain-hero" id="rain_hero">
  <canvas class="rain-canvas" id="rain_canvas" aria-hidden="true"></canvas>
  <div class="rain-copy">
    <p class="rain-eyebrow">Café Amayadori</p>
    <h2 class="rain-title">A quiet seat by the rainy window</h2>
  </div>
</section>

<script>
(function () {
  const TAU = Math.PI * 2;
  const HUES = [30, 42, 8, 196, 328, 48];           // sodium, warm white, tail light, cyan, neon pink, amber
  const opts = { rain: 1, size: 1 };

  const hero = document.getElementById('rain_hero');
  const canvas = document.getElementById('rain_canvas');
  const ctx = canvas.getContext('2d');
  const blurBg = document.createElement('canvas');  // what you see through the glass
  const sharpBg = document.createElement('canvas'); // what each drop refracts

  let w = 0, h = 0, dpr = 1, unit = 1, maxDrops = 0;
  let drops = [];
  let rafId = null, running = false, last = 0, spawnAcc = 0;

  // Seeded random so the city lights stay put when the hero is resized
  function seeded(seed) {
    return function () {
      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;
    };
  }

  // (2) Night city bokeh, painted twice: soft for the glass, crisp for the lenses
  function paintScene(target, soft) {
    target.width = w;
    target.height = h;
    const g = target.getContext('2d');
    const sky = g.createLinearGradient(0, 0, 0, h);
    sky.addColorStop(0, '#0a1230');
    sky.addColorStop(0.55, '#1d2552');
    sky.addColorStop(1, '#3a1d40');
    g.fillStyle = sky;
    g.fillRect(0, 0, w, h);
    g.globalCompositeOperation = 'lighter';
    const rnd = seeded(7);
    const count = Math.round(24 + (w * h) / 12000);
    for (let i = 0; i < count; i++) {
      const x = rnd() * w;
      const y = h * (0.2 + rnd() * 0.8);
      const r = (8 + rnd() * 26) * unit * (soft ? 2.2 : 1);
      const hue = HUES[Math.floor(rnd() * HUES.length)];
      const a = soft ? 0.22 + rnd() * 0.2 : 0.55 + rnd() * 0.35;
      const glow = g.createRadialGradient(x, y, 0, x, y, r);
      glow.addColorStop(0, `hsla(${hue}, 95%, 68%, ${a})`);
      glow.addColorStop(soft ? 0.35 : 0.6, `hsla(${hue}, 95%, 58%, ${a * 0.6})`);
      glow.addColorStop(1, `hsla(${hue}, 95%, 50%, 0)`);
      g.fillStyle = glow;
      g.fillRect(x - r, y - r, r * 2, r * 2);
    }
    g.globalCompositeOperation = 'source-over';
  }

  // (3) Drops: small ones bead and merge, big ones slide and leave a trail
  function addDrop(x, y, r, hold) {
    drops.push({ x, y, r, vy: 0, hold, trail: 0, wob: Math.random() * TAU, dead: false });
  }

  function sprinkle() {
    const x = Math.random() * w;
    const y = Math.random() * h;
    const r = (0.8 + Math.random() * 1.8) * unit;
    for (const d of drops) {
      if (d.vy === 0 && Math.hypot(d.x - x, d.y - y) < d.r + r) {
        // Landing on a bead grows it; once heavy enough it starts to slide
        d.r = Math.min(12 * unit * opts.size, Math.sqrt(d.r * d.r + r * r));
        if (d.r > 3.4 * unit * opts.size && d.hold === Infinity) d.hold = 0.3 + Math.random() * 1.5;
        return;
      }
    }
    if (drops.length < maxDrops) addDrop(x, y, r, Infinity);
  }

  function seed() {
    drops = [];
    for (let i = 0; i < maxDrops * 0.7; i++) sprinkle();
    for (let i = 0; i < 6; i++) {
      addDrop(Math.random() * w, Math.random() * h * 0.7, (5 + Math.random() * 5) * unit * opts.size, Math.random() * 3);
    }
  }

  function step(dt) {
    spawnAcc += dt * opts.rain * (w * h) / 5000;
    while (spawnAcc >= 1) { spawnAcc -= 1; sprinkle(); }
    if (Math.random() < dt * opts.rain * 0.6) {
      addDrop(Math.random() * w, Math.random() * h * 0.4, (5 + Math.random() * 5) * unit * opts.size, Math.random() * 1.5);
    }

    for (const d of drops) {
      if (d.dead || d.hold === Infinity) continue;
      if (d.hold > 0) { d.hold -= dt; continue; }
      // Bigger drops fall faster; random stalls mimic friction on the glass
      const target = (30 + d.r * 26 / unit) * unit;
      d.vy += (target - d.vy) * Math.min(1, dt * 2.5);
      if (Math.random() < dt * 0.8) d.vy *= 0.25;
      d.y += d.vy * dt;
      d.wob += dt * 3;
      d.x += Math.sin(d.wob) * dt * 8 * unit;
      d.trail += d.vy * dt;
      if (d.trail > d.r * 1.6 && d.r > 2.4 * unit) {
        d.trail = 0;
        addDrop(d.x + (Math.random() - 0.5) * d.r * 0.4, d.y - d.r * 1.2, d.r * (0.18 + Math.random() * 0.14), Infinity);
        d.r *= 0.985;
      }
      // Swallow anything in the path, which leaves a clear streak behind
      for (const o of drops) {
        if (o === d || o.dead || o.r > d.r) continue;
        if (Math.hypot(o.x - d.x, o.y - d.y) < d.r + o.r * 0.6) {
          o.dead = true;
          d.r = Math.min(13 * unit * opts.size, Math.sqrt(d.r * d.r + o.r * o.r * 0.5));
        }
      }
      if (d.y - d.r > h) d.dead = true;
    }
    drops = drops.filter((d) => !d.dead);
  }

  function drawDrop(d) {
    const rx = d.r;
    const ry = d.r * (1 + Math.min(d.vy / (400 * unit), 0.35));
    // Lens: a wider patch of the crisp scene, turned upside down like a real droplet
    ctx.save();
    ctx.beginPath();
    ctx.ellipse(d.x, d.y, rx, ry, 0, 0, TAU);
    ctx.clip();
    const span = d.r * 5;
    ctx.translate(d.x, d.y);
    ctx.scale(1, -1);
    ctx.drawImage(sharpBg, d.x - span, d.y - span, span * 2, span * 2, -rx, -ry, rx * 2, ry * 2);
    ctx.restore();
    // Darker rim, then a specular highlight
    const rim = ctx.createRadialGradient(d.x, d.y, d.r * 0.6, d.x, d.y, d.r * 1.05);
    rim.addColorStop(0, 'rgba(4, 8, 24, 0)');
    rim.addColorStop(1, 'rgba(4, 8, 24, 0.32)');
    ctx.fillStyle = rim;
    ctx.beginPath();
    ctx.ellipse(d.x, d.y, rx, ry, 0, 0, TAU);
    ctx.fill();
    if (d.r > 1.2 * unit) {
      ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
      ctx.beginPath();
      ctx.ellipse(d.x - rx * 0.32, d.y - ry * 0.42, rx * 0.26, ry * 0.16, -0.5, 0, TAU);
      ctx.fill();
      // A real bead focuses light into a soft glint along its lower edge
      ctx.fillStyle = 'rgba(255, 240, 220, 0.22)';
      ctx.beginPath();
      ctx.ellipse(d.x + rx * 0.1, d.y + ry * 0.58, rx * 0.5, ry * 0.2, 0, 0, TAU);
      ctx.fill();
    }
  }

  function render() {
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.drawImage(blurBg, 0, 0, w, h);
    for (const d of drops) drawDrop(d);
  }

  function resize() {
    const r = hero.getBoundingClientRect();
    if (!r.width || !r.height) return;
    const nw = Math.round(r.width), nh = Math.round(r.height);
    if (nw === w && nh === h) return;
    w = nw; h = nh;
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    unit = Math.min(1.2, Math.max(0.6, w / 800));
    maxDrops = Math.round(Math.min(420, Math.max(40, (w * h) / 2400)));
    canvas.width = Math.round(w * dpr);
    canvas.height = Math.round(h * dpr);
    paintScene(blurBg, true);
    paintScene(sharpBg, false);
    seed();
    render();
  }

  function frame(now) {
    rafId = requestAnimationFrame(frame);
    const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
    last = now;
    step(dt);
    render();
  }

  function start() { if (!running) { running = true; last = 0; rafId = requestAnimationFrame(frame); } }
  function stop()  { if (running) { running = false; cancelAnimationFrame(rafId); } }

  // (4) Tap the glass to drop a fresh bead that runs straight away
  hero.addEventListener('pointerdown', (e) => {
    const r = hero.getBoundingClientRect();
    addDrop(e.clientX - r.left, e.clientY - r.top, 8 * unit * opts.size, 0.12);
    if (!running) render();
  });

  // (5) Size tracking, offscreen pause, and a still frame under reduced motion
  new ResizeObserver(resize).observe(hero);
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (!reduceMotion) {
    document.addEventListener('visibilitychange', () => { document.hidden ? stop() : start(); });
    if ('IntersectionObserver' in window) {
      new IntersectionObserver((e) => { e[0].isIntersecting ? start() : stop(); }).observe(hero);
    } else { start(); }
  }
})();
</script>
.rain-hero {
  position: relative;
  width: 100%;
  overflow: hidden;
  min-height: 240px;
  aspect-ratio: 16 / 9;
  border-radius: 24px;
  background: #121a3a;
  cursor: crosshair;
  touch-action: pan-y;
}
.rain-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.rain-copy {
  position: relative;
  z-index: 1;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 8px;
  padding: clamp(20px, 6vw, 56px);
  color: #fff;
  /* A soft plate on the left keeps the copy readable over bright bokeh */
  background: linear-gradient(90deg, rgba(6, 10, 30, 0.55), rgba(6, 10, 30, 0) 70%);
  text-shadow: 0 2px 16px rgba(4, 8, 24, 0.7);
  pointer-events: none;
}
.rain-eyebrow {
  margin: 0;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: #fcd34d;
}
.rain-title {
  margin: 0;
  font-size: clamp(24px, 5vw, 44px);
  line-height: 1.2;
  font-weight: 700;
}
import { useEffect, useRef } from "react";
import "./M-026.css";

const TAU = Math.PI * 2;
const HUES = [30, 42, 8, 196, 328, 48];

// Seeded random so the city lights stay put when the hero is resized
function seeded(seed) {
  return function () {
    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;
  };
}

// Night city bokeh, painted twice: soft for the glass, crisp for the lenses
function paintScene(target, soft, w, h, unit) {
  target.width = w;
  target.height = h;
  const g = target.getContext("2d");
  const sky = g.createLinearGradient(0, 0, 0, h);
  sky.addColorStop(0, "#0a1230");
  sky.addColorStop(0.55, "#1d2552");
  sky.addColorStop(1, "#3a1d40");
  g.fillStyle = sky;
  g.fillRect(0, 0, w, h);
  g.globalCompositeOperation = "lighter";
  const rnd = seeded(7);
  const count = Math.round(24 + (w * h) / 12000);
  for (let i = 0; i < count; i++) {
    const x = rnd() * w;
    const y = h * (0.2 + rnd() * 0.8);
    const r = (8 + rnd() * 26) * unit * (soft ? 2.2 : 1);
    const hue = HUES[Math.floor(rnd() * HUES.length)];
    const a = soft ? 0.22 + rnd() * 0.2 : 0.55 + rnd() * 0.35;
    const glow = g.createRadialGradient(x, y, 0, x, y, r);
    glow.addColorStop(0, `hsla(${hue}, 95%, 68%, ${a})`);
    glow.addColorStop(soft ? 0.35 : 0.6, `hsla(${hue}, 95%, 58%, ${a * 0.6})`);
    glow.addColorStop(1, `hsla(${hue}, 95%, 50%, 0)`);
    g.fillStyle = glow;
    g.fillRect(x - r, y - r, r * 2, r * 2);
  }
  g.globalCompositeOperation = "source-over";
}

export default function RainyWindow({ rain = 1, size = 1, eyebrow = "Café Amayadori", title = "A quiet seat by the rainy window" }) {
  const heroRef = useRef(null);
  const canvasRef = useRef(null);
  // Live values, so prop changes don't restart the loop
  const opts = useRef({ rain, size });
  opts.current = { rain, size };

  useEffect(() => {
    const hero = heroRef.current;
    const canvas = canvasRef.current;
    const ctx = canvas && canvas.getContext("2d");
    if (!hero || !ctx) return undefined;

    const blurBg = document.createElement("canvas");
    const sharpBg = document.createElement("canvas");
    let w = 0, h = 0, dpr = 1, unit = 1, maxDrops = 0;
    let drops = [];
    let rafId = null, running = false, last = 0, spawnAcc = 0;

    const addDrop = (x, y, r, hold) => {
      drops.push({ x, y, r, vy: 0, hold, trail: 0, wob: Math.random() * TAU, dead: false });
    };

    function sprinkle() {
      const { size } = opts.current;
      const x = Math.random() * w;
      const y = Math.random() * h;
      const r = (0.8 + Math.random() * 1.8) * unit;
      for (const d of drops) {
        if (d.vy === 0 && Math.hypot(d.x - x, d.y - y) < d.r + r) {
          // Landing on a bead grows it; once heavy enough it starts to slide
          d.r = Math.min(12 * unit * size, Math.sqrt(d.r * d.r + r * r));
          if (d.r > 3.4 * unit * size && d.hold === Infinity) d.hold = 0.3 + Math.random() * 1.5;
          return;
        }
      }
      if (drops.length < maxDrops) addDrop(x, y, r, Infinity);
    }

    function seed() {
      drops = [];
      for (let i = 0; i < maxDrops * 0.7; i++) sprinkle();
      for (let i = 0; i < 6; i++) {
        addDrop(Math.random() * w, Math.random() * h * 0.7, (5 + Math.random() * 5) * unit * opts.current.size, Math.random() * 3);
      }
    }

    function step(dt) {
      const { rain, size } = opts.current;
      spawnAcc += dt * rain * (w * h) / 5000;
      while (spawnAcc >= 1) { spawnAcc -= 1; sprinkle(); }
      if (Math.random() < dt * rain * 0.6) {
        addDrop(Math.random() * w, Math.random() * h * 0.4, (5 + Math.random() * 5) * unit * size, Math.random() * 1.5);
      }
      for (const d of drops) {
        if (d.dead || d.hold === Infinity) continue;
        if (d.hold > 0) { d.hold -= dt; continue; }
        // Bigger drops fall faster; random stalls mimic friction on the glass
        const target = (30 + d.r * 26 / unit) * unit;
        d.vy += (target - d.vy) * Math.min(1, dt * 2.5);
        if (Math.random() < dt * 0.8) d.vy *= 0.25;
        d.y += d.vy * dt;
        d.wob += dt * 3;
        d.x += Math.sin(d.wob) * dt * 8 * unit;
        d.trail += d.vy * dt;
        if (d.trail > d.r * 1.6 && d.r > 2.4 * unit) {
          d.trail = 0;
          addDrop(d.x + (Math.random() - 0.5) * d.r * 0.4, d.y - d.r * 1.2, d.r * (0.18 + Math.random() * 0.14), Infinity);
          d.r *= 0.985;
        }
        // Swallow anything in the path, which leaves a clear streak behind
        for (const o of drops) {
          if (o === d || o.dead || o.r > d.r) continue;
          if (Math.hypot(o.x - d.x, o.y - d.y) < d.r + o.r * 0.6) {
            o.dead = true;
            d.r = Math.min(13 * unit * size, Math.sqrt(d.r * d.r + o.r * o.r * 0.5));
          }
        }
        if (d.y - d.r > h) d.dead = true;
      }
      drops = drops.filter((d) => !d.dead);
    }

    function drawDrop(d) {
      const rx = d.r;
      const ry = d.r * (1 + Math.min(d.vy / (400 * unit), 0.35));
      // Lens: a wider patch of the crisp scene, turned upside down like a real droplet
      ctx.save();
      ctx.beginPath();
      ctx.ellipse(d.x, d.y, rx, ry, 0, 0, TAU);
      ctx.clip();
      const span = d.r * 5;
      ctx.translate(d.x, d.y);
      ctx.scale(1, -1);
      ctx.drawImage(sharpBg, d.x - span, d.y - span, span * 2, span * 2, -rx, -ry, rx * 2, ry * 2);
      ctx.restore();
      const rim = ctx.createRadialGradient(d.x, d.y, d.r * 0.6, d.x, d.y, d.r * 1.05);
      rim.addColorStop(0, "rgba(4, 8, 24, 0)");
      rim.addColorStop(1, "rgba(4, 8, 24, 0.32)");
      ctx.fillStyle = rim;
      ctx.beginPath();
      ctx.ellipse(d.x, d.y, rx, ry, 0, 0, TAU);
      ctx.fill();
      if (d.r > 1.2 * unit) {
        ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
        ctx.beginPath();
        ctx.ellipse(d.x - rx * 0.32, d.y - ry * 0.42, rx * 0.26, ry * 0.16, -0.5, 0, TAU);
        ctx.fill();
        // A real bead focuses light into a soft glint along its lower edge
        ctx.fillStyle = "rgba(255, 240, 220, 0.22)";
        ctx.beginPath();
        ctx.ellipse(d.x + rx * 0.1, d.y + ry * 0.58, rx * 0.5, ry * 0.2, 0, 0, TAU);
        ctx.fill();
      }
    }

    function render() {
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.drawImage(blurBg, 0, 0, w, h);
      for (const d of drops) drawDrop(d);
    }

    function resize() {
      const r = hero.getBoundingClientRect();
      if (!r.width || !r.height) return;
      const nw = Math.round(r.width), nh = Math.round(r.height);
      if (nw === w && nh === h) return;
      w = nw; h = nh;
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      unit = Math.min(1.2, Math.max(0.6, w / 800));
      maxDrops = Math.round(Math.min(420, Math.max(40, (w * h) / 2400)));
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      paintScene(blurBg, true, w, h, unit);
      paintScene(sharpBg, false, w, h, unit);
      seed();
      render();
    }

    function frame(now) {
      rafId = requestAnimationFrame(frame);
      const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
      last = now;
      step(dt);
      render();
    }

    const start = () => { if (!running) { running = true; last = 0; rafId = requestAnimationFrame(frame); } };
    const stop = () => { if (running) { running = false; cancelAnimationFrame(rafId); } };
    const onDown = (e) => {
      const r = hero.getBoundingClientRect();
      addDrop(e.clientX - r.left, e.clientY - r.top, 8 * unit * opts.current.size, 0.12);
      if (!running) render();
    };
    const onVisibility = () => (document.hidden ? stop() : start());

    hero.addEventListener("pointerdown", onDown);
    const ro = new ResizeObserver(resize);
    ro.observe(hero);

    let io = null;
    if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      document.addEventListener("visibilitychange", onVisibility);
      io = new IntersectionObserver((e) => (e[0].isIntersecting ? start() : stop()));
      io.observe(hero);
    }

    return () => {
      stop();
      ro.disconnect();
      if (io) io.disconnect();
      hero.removeEventListener("pointerdown", onDown);
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  return (
    <section className="rain-hero" ref={heroRef}>
      <canvas className="rain-canvas" ref={canvasRef} aria-hidden="true" />
      <div className="rain-copy">
        <p className="rain-eyebrow">{eyebrow}</p>
        <h2 className="rain-title">{title}</h2>
      </div>
    </section>
  );
}
.rain-hero {
  position: relative;
  width: 100%;
  overflow: hidden;
  min-height: 240px;
  aspect-ratio: 16 / 9;
  border-radius: 24px;
  background: #121a3a;
  cursor: crosshair;
  touch-action: pan-y;
}
.rain-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.rain-copy {
  position: relative;
  z-index: 1;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 8px;
  padding: clamp(20px, 6vw, 56px);
  color: #fff;
  /* A soft plate on the left keeps the copy readable over bright bokeh */
  background: linear-gradient(90deg, rgba(6, 10, 30, 0.55), rgba(6, 10, 30, 0) 70%);
  text-shadow: 0 2px 16px rgba(4, 8, 24, 0.7);
  pointer-events: none;
}
.rain-eyebrow {
  margin: 0;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: #fcd34d;
}
.rain-title {
  margin: 0;
  font-size: clamp(24px, 5vw, 44px);
  line-height: 1.2;
  font-weight: 700;
}

仕組みとカスタマイズHow It Works & Customization

仕組みHow it works

paintScene() draws the same seeded bokeh twice: blurBg with large faint glows and sharpBg with small crisp ones. Each frame draws blurBg, then every drop clips to an ellipse and draws a patch of sharpBg about five radii wide, shrunk and flipped with scale(1, -1), followed by a dark rim gradient and a white highlight. Beads that land on each other merge to √(r² + r'²); past a size threshold they start running after a short hold. Running drops ease toward a speed proportional to their radius, occasionally stall, wobble with sin, leave small beads behind and absorb smaller beads in their path.

paintScene() は同じシードの街明かりを2回描きます。大きく淡い光の blurBg と、小さくくっきりした sharpBg です。毎フレーム blurBg を描いたあと、各雨粒を楕円でクリップし、半径の約5倍の範囲の sharpBg を scale(1, -1) で上下反転・縮小して描き、暗い縁のグラデーションと白いハイライトを重ねます。重なった水滴は √(r² + r'²) に合体し、一定の大きさを超えると少し待ってから流れ出します。流れる雨粒は半径に比例した速度へ近づき、ときどき引っかかり、sin で揺れ、後ろに小さな水滴を残し、進路上の小さな水滴を吸収します。

カスタマイズ方法Customization

opts.rain sets how much rain falls and opts.size scales the big drops. Edit HUES and the sky gradient in paintScene() for the city colours, span in drawDrop() for how wide each lens looks, and the maxDrops formula in resize() for density. To use a photo, draw it into blurBg (blurred) and sharpBg instead of calling paintScene().

opts.rain で雨の量、opts.size で大きな雨粒の大きさを変えます。paintScene() 内の HUES と空のグラデーションで街の色、drawDrop() の span でレンズに映る範囲、resize() の maxDrops の式で密度を調整します。写真を使う場合は paintScene() の代わりに、ぼかした画像を blurBg、元の画像を sharpBg に描いてください。

注意点Caveats

Every drop costs a clip and a drawImage, so the bead count is the load: keep the maxDrops cap on mobile. The device pixel ratio is capped at 2. The canvas is decorative, so mark it aria-hidden, keep the copy on a separate layer with a contrast plate, and draw a single still frame under prefers-reduced-motion.

雨粒ごとに clip と drawImage を行うため、水滴の数がそのまま負荷になります。モバイルでは maxDrops の上限を保ってください。デバイスピクセル比は2までに制限しています。Canvas は装飾なので aria-hidden を付け、文字はコントラスト用の下地を敷いた別レイヤーに置き、prefers-reduced-motion では静止した1フレームだけを描いてください。

よくある質問 Frequently Asked Questions

Why is the scene inside each drop upside down? なぜ雨粒の中の景色は逆さまなのですか?

A real water droplet on glass works like a tiny convex lens, so it shows the scene behind it inverted and in sharper focus than the blurred view around it. Flipping a crisp patch with scale(1, -1) reproduces that cheaply.

ガラスに付いた本物の水滴は小さな凸レンズとして働くため、背後の景色を上下逆さまに、周りのぼけた景色よりくっきり映します。くっきりした画像の一部を scale(1, -1) で反転すると、それを軽い処理で再現できます。

Can I use a photo instead of the generated city lights? 生成した街明かりの代わりに写真を使えますか?

Yes. Draw the photo into sharpBg as-is and into blurBg with ctx.filter = 'blur(12px)' (or a pre-blurred image for Safari), then skip paintScene().

使えます。写真をそのまま sharpBg に、ctx.filter = 'blur(12px)' でぼかして(Safari向けには事前にぼかした画像で)blurBg に描き、paintScene() を呼ばないようにしてください。

Is it heavy on mobile? モバイルで重くなりませんか?

A 390×220 hero holds about 40 beads, which is light. The cost grows with the bead count, so keep the maxDrops cap; the loop already pauses offscreen and in hidden tabs.

390×220 のヒーローなら水滴は約40個で軽く動きます。負荷は水滴の数に比例するので maxDrops の上限を保ってください。画面外や非表示タブではループが自動で止まります。

Can I put text on top? 上に文字を載せられますか?

Yes. The copy sits in a pointer-events: none layer with a left-to-right dark gradient and a text-shadow, so taps still reach the glass and the heading stays readable over bright lights.

可能です。文字は pointer-events: none のレイヤーに置き、左から右へ薄くなる暗いグラデーションと text-shadow を付けています。タップはガラスに届き、明るい光の上でも見出しが読めます。

AIへの指示テンプレート AI Prompt Template

以下をAIに貼り付けると実装を依頼できます。 Paste the following into your AI assistant to request implementation.