特殊エフェクト

Three.js 背景やマウス演出など、サイトを一段格上げするパーツ

Three.js パーティクル背景

光の粒子がゆっくり漂うセクション背景に、キャッチコピーを重ねて表示できます。

コード・使い方を見る
<div class="tp-wrap">
  <canvas class="tp-canvas" aria-hidden="true"></canvas>
  <div class="tp-content">
    <p class="tp-eyebrow">みなと総合クリニック</p>
    <h2 class="tp-title">安心と信頼を、<br class="tp-br">これからも。</h2>
    <p class="tp-lead">地域のみなさまに寄り添う診療を、これからも大切にしてまいります。</p>
  </div>
</div>
.tp-wrap {
  --tp-bg-from: #0f3f47;   /* 背景グラデーション開始色 */
  --tp-bg-to: #114b52;     /* 背景グラデーション終了色 */
  --tp-text: #ffffff;      /* 見出し・本文の文字色 */
  position: relative;
  width: 100%;
  min-height: 420px;
  overflow: hidden;
  background: linear-gradient(150deg, var(--tp-bg-from), var(--tp-bg-to));
  border-radius: 14px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.tp-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.tp-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 420px;
  padding: 2.5em 1.5em;
  color: var(--tp-text);
}
.tp-eyebrow {
  margin: 0 0 .6em;
  font-size: .82rem;
  letter-spacing: .2em;
  opacity: .8;
}
.tp-title {
  margin: 0 0 .7em;
  font-size: clamp(1.5rem, 4vw, 2.4rem);
  font-weight: 700;
  line-height: 1.5;
}
.tp-lead {
  margin: 0;
  font-size: .95rem;
  line-height: 1.9;
  opacity: .9;
  max-width: 32em;
}
@media (max-width: 480px) {
  .tp-wrap, .tp-content { min-height: 340px; }
  .tp-content { padding: 2em 1.2em; }
  .tp-br { display: none; }
}

/* THREE.js が読み込めない環境向けの静的フォールバック(script.js が .tp-static を付与) */
.tp-wrap.tp-static .tp-canvas { display: none; }
(function () {
  var wraps = document.querySelectorAll('.tp-wrap');
  if (!wraps.length) return;

  if (typeof THREE === 'undefined') {
    console.warn('threejs-particles: THREE が読み込まれていないため、静的背景で表示します。');
    wraps.forEach(function (wrap) { wrap.classList.add('tp-static'); });
    return;
  }

  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  wraps.forEach(function (wrap) {
    var canvas = wrap.querySelector('.tp-canvas');
    if (!canvas) return;

    var width = wrap.clientWidth || 1;
    var height = wrap.clientHeight || 1;

    var renderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: true, antialias: true });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    renderer.setSize(width, height);

    var scene = new THREE.Scene();
    var camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 1000);
    camera.position.z = 60;

    var COUNT = 260;
    var positions = new Float32Array(COUNT * 3);
    var speeds = new Float32Array(COUNT);
    for (var i = 0; i < COUNT; i++) {
      positions[i * 3] = (Math.random() - 0.5) * 90;
      positions[i * 3 + 1] = (Math.random() - 0.5) * 60;
      positions[i * 3 + 2] = (Math.random() - 0.5) * 60;
      speeds[i] = 0.05 + Math.random() * 0.12;
    }

    var geometry = new THREE.BufferGeometry();
    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));

    var material = new THREE.PointsMaterial({
      color: 0xbfe9e4,       /* 粒子の色(クリニックのテーマ色に合わせて変更可) */
      size: 1.6,
      transparent: true,
      opacity: 0.75,
      depthWrite: false,
    });

    var points = new THREE.Points(geometry, material);
    scene.add(points);

    function renderStatic() {
      renderer.render(scene, camera);
    }

    var rafId = null;
    var running = false;

    function animate() {
      if (!running) return;
      var pos = geometry.attributes.position.array;
      for (var j = 0; j < COUNT; j++) {
        pos[j * 3 + 1] += speeds[j];
        if (pos[j * 3 + 1] > 30) pos[j * 3 + 1] = -30;
      }
      geometry.attributes.position.needsUpdate = true;
      renderer.render(scene, camera);
      rafId = requestAnimationFrame(animate);
    }

    function start() {
      if (running || reduceMotion) { renderStatic(); return; }
      running = true;
      animate();
    }

    function stop() {
      running = false;
      if (rafId) cancelAnimationFrame(rafId);
      rafId = null;
    }

    function handleResize() {
      var w = wrap.clientWidth || 1;
      var h = wrap.clientHeight || 1;
      renderer.setSize(w, h);
      camera.aspect = w / h;
      camera.updateProjectionMatrix();
      if (!running) renderStatic();
    }

    window.addEventListener('resize', handleResize);

    if ('IntersectionObserver' in window) {
      var observer = new IntersectionObserver(function (entries) {
        entries.forEach(function (entry) {
          if (entry.isIntersecting) start(); else stop();
        });
      }, { threshold: 0.05 });
      observer.observe(wrap);
    } else {
      start();
    }

    if (reduceMotion) {
      renderStatic();
    }
  });
})();

Three.js 波背景

淡い青緑のワイヤーフレームがゆるやかに波打つ、クリニック向けの背景演出です。

コード・使い方を見る
<div class="tw-wrap">
  <canvas class="tw-canvas" aria-hidden="true"></canvas>
  <div class="tw-content">
    <p class="tw-eyebrow">みなと総合クリニック</p>
    <h2 class="tw-title">穏やかな時間の中で、<br class="tw-br">丁寧な診療を。</h2>
    <p class="tw-lead">一人ひとりに向き合う診療で、皆さまの毎日を支えます。</p>
  </div>
</div>
.tw-wrap {
  --tw-bg-from: #eaf6f4;   /* 背景グラデーション開始色 */
  --tw-bg-to: #d5eeea;     /* 背景グラデーション終了色 */
  --tw-text: #1f4d4a;      /* 見出し・本文の文字色 */
  position: relative;
  width: 100%;
  min-height: 420px;
  overflow: hidden;
  background: linear-gradient(160deg, var(--tw-bg-from), var(--tw-bg-to));
  border-radius: 14px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.tw-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.tw-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 420px;
  padding: 2.5em 1.5em;
  color: var(--tw-text);
}
.tw-eyebrow {
  margin: 0 0 .6em;
  font-size: .82rem;
  letter-spacing: .2em;
  opacity: .75;
}
.tw-title {
  margin: 0 0 .7em;
  font-size: clamp(1.5rem, 4vw, 2.4rem);
  font-weight: 700;
  line-height: 1.5;
}
.tw-lead {
  margin: 0;
  font-size: .95rem;
  line-height: 1.9;
  opacity: .9;
  max-width: 32em;
}
@media (max-width: 480px) {
  .tw-wrap, .tw-content { min-height: 340px; }
  .tw-content { padding: 2em 1.2em; }
  .tw-br { display: none; }
}

/* THREE.js が読み込めない環境向けの静的フォールバック(script.js が .tw-static を付与) */
.tw-wrap.tw-static .tw-canvas { display: none; }
(function () {
  var wraps = document.querySelectorAll('.tw-wrap');
  if (!wraps.length) return;

  if (typeof THREE === 'undefined') {
    console.warn('threejs-waves: THREE が読み込まれていないため、静的背景で表示します。');
    wraps.forEach(function (wrap) { wrap.classList.add('tw-static'); });
    return;
  }

  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  wraps.forEach(function (wrap) {
    var canvas = wrap.querySelector('.tw-canvas');
    if (!canvas) return;

    var width = wrap.clientWidth || 1;
    var height = wrap.clientHeight || 1;

    var renderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: true, antialias: true });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    renderer.setSize(width, height);

    var scene = new THREE.Scene();
    var camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
    camera.position.set(0, 28, 46);
    camera.lookAt(0, 0, 0);

    var SEGMENTS = 46;
    var geometry = new THREE.PlaneGeometry(90, 60, SEGMENTS, SEGMENTS);
    geometry.rotateX(-Math.PI / 2.4);

    var material = new THREE.MeshBasicMaterial({
      color: 0x2f8f9d,      /* 波線の色(クリニックのテーマ色に合わせて変更可) */
      wireframe: true,
      transparent: true,
      opacity: 0.55,
    });

    var mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);

    var basePositions = geometry.attributes.position.array.slice();

    function renderStatic() {
      renderer.render(scene, camera);
    }

    var rafId = null;
    var running = false;
    var clockStart = Date.now();

    function animate() {
      if (!running) return;
      var t = (Date.now() - clockStart) / 1000;
      var pos = geometry.attributes.position.array;
      for (var i = 0; i < pos.length; i += 3) {
        var x = basePositions[i];
        var z = basePositions[i + 2];
        pos[i + 1] = Math.sin(x * 0.12 + t) * 2.2 + Math.cos(z * 0.15 + t * 0.8) * 1.6;
      }
      geometry.attributes.position.needsUpdate = true;
      renderer.render(scene, camera);
      rafId = requestAnimationFrame(animate);
    }

    function start() {
      if (running || reduceMotion) { renderStatic(); return; }
      running = true;
      animate();
    }

    function stop() {
      running = false;
      if (rafId) cancelAnimationFrame(rafId);
      rafId = null;
    }

    function handleResize() {
      var w = wrap.clientWidth || 1;
      var h = wrap.clientHeight || 1;
      renderer.setSize(w, h);
      camera.aspect = w / h;
      camera.updateProjectionMatrix();
      if (!running) renderStatic();
    }

    window.addEventListener('resize', handleResize);

    if ('IntersectionObserver' in window) {
      var observer = new IntersectionObserver(function (entries) {
        entries.forEach(function (entry) {
          if (entry.isIntersecting) start(); else stop();
        });
      }, { threshold: 0.05 });
      observer.observe(wrap);
    } else {
      start();
    }

    if (reduceMotion) {
      renderStatic();
    }
  });
})();

マウス追従エフェクト

カーソルに柔らかい円がふわっと遅れて追従する、サイトの上質感を演出するパーツです。

コード・使い方を見る
<div class="mt-wrap">
  <div class="mt-dot" aria-hidden="true"></div>
  <div class="mt-demo-area">
    <p class="mt-demo-text">みなと総合クリニックのサイト内を、カーソルがふわっと追いかけます。</p>
  </div>
</div>
.mt-wrap {
  --mt-color: #2f8f9d;  /* 追従する円の色(クリニックのテーマ色に変更してください) */
  --mt-size: 32px;      /* 円の大きさ */
  position: relative;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
}
.mt-dot {
  position: fixed;
  top: 0;
  left: 0;
  width: var(--mt-size);
  height: var(--mt-size);
  margin-left: calc(var(--mt-size) / -2);
  margin-top: calc(var(--mt-size) / -2);
  border-radius: 50%;
  background: var(--mt-color);
  opacity: .28;
  pointer-events: none;
  z-index: 9999;
  will-change: transform;
  transform: translate(-100px, -100px);
}
.mt-wrap.mt-disabled .mt-dot { display: none; }
.mt-demo-area {
  padding: 3em 1.5em;
  text-align: center;
  background: #f4faf9;
  border-radius: 12px;
}
.mt-demo-text {
  margin: 0;
  color: #2f6f77;
  font-size: 1rem;
  line-height: 1.9;
}
@media (max-width: 480px) {
  .mt-demo-area { padding: 2.2em 1.2em; }
  .mt-demo-text { font-size: .9rem; }
}
(function () {
  var wraps = document.querySelectorAll('.mt-wrap');
  if (!wraps.length) return;

  var isTouch = window.matchMedia && window.matchMedia('(hover: none), (pointer: coarse)').matches;
  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  if (isTouch || reduceMotion) {
    wraps.forEach(function (wrap) { wrap.classList.add('mt-disabled'); });
    return;
  }

  wraps.forEach(function (wrap) {
    var dot = wrap.querySelector('.mt-dot');
    if (!dot) return;

    var targetX = window.innerWidth / 2;
    var targetY = window.innerHeight / 2;
    var currentX = targetX;
    var currentY = targetY;
    var rafId = null;
    var active = true;

    function onMove(e) {
      targetX = e.clientX;
      targetY = e.clientY;
    }
    window.addEventListener('mousemove', onMove, { passive: true });

    function loop() {
      if (!active) return;
      currentX += (targetX - currentX) * 0.15;
      currentY += (targetY - currentY) * 0.15;
      dot.style.transform = 'translate(' + currentX + 'px, ' + currentY + 'px)';
      rafId = requestAnimationFrame(loop);
    }
    loop();

    document.addEventListener('visibilitychange', function () {
      if (document.hidden) {
        active = false;
        if (rafId) cancelAnimationFrame(rafId);
      } else if (!active) {
        active = true;
        loop();
      }
    });
  });
})();

テキスト出現アニメーション

見出しの文字が1文字ずつふわっと立ち上がって表示される、印象的な演出パーツです。

コード・使い方を見る
<div class="ta-wrap">
  <h2 class="ta-title" data-ta-text>みなと総合クリニック</h2>
  <p class="ta-sub" data-ta-text data-ta-delay="0.5">安心してかかれる、地域のかかりつけ医。</p>
</div>
.ta-wrap {
  --ta-color: #1f4d4a;       /* 文字色(クリニックのテーマ色に変更してください) */
  --ta-stagger: 0.035s;      /* 1文字ごとの遅延間隔 */
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  text-align: center;
  padding: 2.5em 1.5em;
  box-sizing: border-box;
}
.ta-title {
  margin: 0 0 .5em;
  font-size: clamp(1.4rem, 4vw, 2.2rem);
  font-weight: 700;
  color: var(--ta-color);
}
.ta-sub {
  margin: 0;
  font-size: 1rem;
  color: var(--ta-color);
  opacity: .85;
}
.ta-char {
  display: inline-block;
  opacity: 0;
  transform: translateY(.6em);
  transition: opacity .55s ease, transform .55s ease;
}
.ta-char.ta-in {
  opacity: 1;
  transform: translateY(0);
}
/* prefers-reduced-motion の場合は文字を分割せずそのまま表示 */
.ta-wrap.ta-reduced .ta-char {
  opacity: 1;
  transform: none;
  transition: none;
}
@media (max-width: 480px) {
  .ta-wrap { padding: 2em 1.2em; }
}
(function () {
  var wraps = document.querySelectorAll('.ta-wrap');
  if (!wraps.length) return;

  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  wraps.forEach(function (wrap) {
    var targets = wrap.querySelectorAll('[data-ta-text]');
    if (!targets.length) return;

    if (reduceMotion) {
      wrap.classList.add('ta-reduced');
      return;
    }

    var staggerRaw = getComputedStyle(wrap).getPropertyValue('--ta-stagger').trim();
    var stagger = parseFloat(staggerRaw) || 0.035;
    var NBSP = String.fromCharCode(160);

    targets.forEach(function (el) {
      var text = el.textContent;
      var baseDelay = parseFloat(el.getAttribute('data-ta-delay') || '0') || 0;
      el.textContent = '';
      var frag = document.createDocumentFragment();
      var chars = Array.from(text);
      chars.forEach(function (ch, i) {
        var span = document.createElement('span');
        span.className = 'ta-char';
        span.textContent = ch === ' ' ? NBSP : ch;
        span.style.transitionDelay = (baseDelay + i * stagger) + 's';
        frag.appendChild(span);
      });
      el.appendChild(frag);
    });

    function reveal() {
      wrap.querySelectorAll('.ta-char').forEach(function (span) {
        span.classList.add('ta-in');
      });
    }

    if ('IntersectionObserver' in window) {
      var observer = new IntersectionObserver(function (entries) {
        entries.forEach(function (entry) {
          if (entry.isIntersecting) {
            reveal();
            observer.disconnect();
          }
        });
      }, { threshold: 0.3 });
      observer.observe(wrap);
    } else {
      reveal();
    }
  });
})();

パララックスセクション

背景がゆっくり視差でスクロールする、CSSのみで動く奥行きのあるセクションです。

コード・使い方を見る
<div class="px-section">
  <div class="px-inner">
    <p class="px-eyebrow">みなと総合クリニック</p>
    <h2 class="px-title">通い続けたくなる、<br class="px-br">やさしい診療を。</h2>
    <p class="px-lead">背景画像はゆっくりとした視差でスクロールし、奥行きのある印象を演出します。</p>
  </div>
</div>
.px-section {
  /* 背景画像を差し替える場合は下の linear-gradient(...) の部分を
     url("お好きな画像のURLやパス") に書き換えてください */
  --px-image: linear-gradient(160deg, #0f3f47, #2f8f9d 60%, #bfe9e4);
  position: relative;
  min-height: 380px;
  background-image: var(--px-image);
  background-attachment: fixed;
  background-position: center;
  background-size: cover;
  background-repeat: no-repeat;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 14px;
  overflow: hidden;
  box-sizing: border-box;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
}
.px-section::before {
  /* 文字を読みやすくするための暗幕オーバーレイ */
  content: "";
  position: absolute;
  inset: 0;
  background: rgba(10, 30, 32, .35);
}
.px-inner {
  position: relative;
  z-index: 1;
  text-align: center;
  color: #ffffff;
  padding: 3em 1.5em;
  max-width: 36em;
}
.px-eyebrow {
  margin: 0 0 .6em;
  font-size: .82rem;
  letter-spacing: .2em;
  opacity: .85;
}
.px-title {
  margin: 0 0 .7em;
  font-size: clamp(1.5rem, 4vw, 2.4rem);
  font-weight: 700;
  line-height: 1.5;
}
.px-lead {
  margin: 0;
  font-size: .95rem;
  line-height: 1.9;
  opacity: .95;
}

/* iOS Safari は background-attachment: fixed を正しく扱えないため、
   通常のスクロール表示にフォールバックします */
@supports (-webkit-touch-callout: none) {
  .px-section {
    background-attachment: scroll;
  }
}
@media (max-width: 480px) {
  .px-section { min-height: 300px; background-attachment: scroll; }
  .px-inner { padding: 2.2em 1.2em; }
  .px-br { display: none; }
}

アニメグラデ背景ヒーロー

淡い青緑〜白のグラデーションがゆっくり流れる、依存ゼロ・CSSのみのヒーローセクションです。

コード・使い方を見る
<div class="gh-hero">
  <div class="gh-content">
    <p class="gh-eyebrow">みなと総合クリニック</p>
    <h2 class="gh-catch">やさしい診療で、<br class="gh-br">毎日をすこやかに。</h2>
    <p class="gh-lead">地域のみなさまが安心して通える医院を目指しています。</p>
    <a class="gh-cta" href="#reserve">診療予約はこちら</a>
  </div>
</div>
.gh-hero {
  --gh-color1: #eafaf6; /* グラデーション開始色(淡い青緑) */
  --gh-color2: #eaf4fb; /* グラデーション終了色(淡い白青) */
  --gh-accent: #2f8f9d; /* CTAボタンの色(医院のテーマ色に変更してください) */
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 440px;
  overflow: hidden;
  border-radius: 14px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  text-align: center;
  background: linear-gradient(
    120deg,
    var(--gh-color1),
    #ffffff,
    var(--gh-color2),
    #ffffff,
    var(--gh-color1)
  );
  background-size: 300% 300%;
  animation: gh-flow 18s ease-in-out infinite;
}
@keyframes gh-flow {
  0% { background-position: 0% 50%; }
  50% { background-position: 100% 50%; }
  100% { background-position: 0% 50%; }
}
.gh-content {
  position: relative;
  z-index: 1;
  padding: 3em 1.5em;
  max-width: 640px;
}
.gh-eyebrow {
  margin: 0 0 .8em;
  font-size: .85rem;
  font-weight: 600;
  letter-spacing: .18em;
  color: #2f8f9d;
}
.gh-catch {
  margin: 0 0 .8em;
  font-size: clamp(1.5rem, 4vw, 2.2rem);
  font-weight: 800;
  line-height: 1.6;
  letter-spacing: .03em;
  color: #2d3339;
}
.gh-lead {
  margin: 0 0 1.8em;
  font-size: .92rem;
  line-height: 1.8;
  color: #6b7280;
}
.gh-cta {
  display: inline-block;
  background: var(--gh-accent);
  color: #fff;
  font-weight: 700;
  font-size: .95rem;
  padding: .9em 2.4em;
  border-radius: 999px;
  text-decoration: none;
  letter-spacing: .05em;
  box-shadow: 0 6px 18px rgba(47, 143, 157, .25);
  transition: transform .15s ease, box-shadow .15s ease, opacity .15s ease;
}
.gh-cta:hover,
.gh-cta:focus-visible {
  transform: translateY(-2px);
  box-shadow: 0 10px 22px rgba(47, 143, 157, .32);
  opacity: .95;
  outline: none;
}

@media (max-width: 480px) {
  .gh-hero { min-height: 360px; border-radius: 10px; }
  .gh-content { padding: 2.2em 1.3em; }
  .gh-lead { font-size: .84rem; }
  .gh-cta { font-size: .88rem; padding: .8em 2em; }
}

@media (prefers-reduced-motion: reduce) {
  .gh-hero {
    animation: none;
    background-position: 50% 50%;
  }
  .gh-cta {
    transition: none;
  }
}

桜が舞う季節背景

Canvas に淡いピンクの花びらがふわふわ舞う、依存ゼロの季節演出セクションです。画面外にスクロールすると自動で一時停止します。

コード・使い方を見る
<div class="sk-wrap">
  <canvas class="sk-canvas" aria-hidden="true"></canvas>
  <div class="sk-content">
    <p class="sk-eyebrow">みなと総合クリニック</p>
    <h2 class="sk-title">桜の季節も、<br class="sk-br">みなさまのそばに。</h2>
    <p class="sk-lead">季節の移ろいとともに、これからも地域のみなさまの健康を支えてまいります。</p>
  </div>
</div>
.sk-wrap {
  --sk-petal: #f6c6d1;    /* 花びらの色(クリニックのテーマ色に合わせて変更可) */
  --sk-bg-from: #fdf6f3;  /* 背景グラデーション開始色 */
  --sk-bg-to: #fbeef1;    /* 背景グラデーション終了色 */
  position: relative;
  width: 100%;
  min-height: 420px;
  overflow: hidden;
  background: linear-gradient(160deg, var(--sk-bg-from), var(--sk-bg-to));
  border-radius: 14px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.sk-wrap * { box-sizing: border-box; }
.sk-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.sk-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 420px;
  padding: 2.5em 1.5em;
}
.sk-eyebrow {
  margin: 0 0 .6em;
  font-size: .82rem;
  letter-spacing: .2em;
  color: #c9788a;
}
.sk-title {
  margin: 0 0 .7em;
  font-size: clamp(1.5rem, 4vw, 2.3rem);
  font-weight: 700;
  line-height: 1.6;
  color: #2d3339;
}
.sk-lead {
  margin: 0;
  font-size: .92rem;
  line-height: 1.9;
  color: #6b7280;
  max-width: 32em;
}

@media (max-width: 480px) {
  .sk-wrap, .sk-content { min-height: 340px; }
  .sk-content { padding: 2em 1.2em; }
  .sk-br { display: none; }
}

/* 花びら数枚を静止配置するフォールバック(reduced-motion または Canvas 未対応時に script.js が付与) */
.sk-wrap.sk-static .sk-canvas { display: none; }
.sk-static-petal {
  position: absolute;
  border-radius: 100% 0 100% 0;
  background: var(--sk-petal);
  opacity: .55;
  pointer-events: none;
}
(function () {
  var wraps = document.querySelectorAll('.sk-wrap');
  if (!wraps.length) return;

  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  function randomBetween(min, max) {
    return min + Math.random() * (max - min);
  }

  function Petal(width, height) {
    this.reset(width, height, true);
  }

  Petal.prototype.reset = function (width, height, initial) {
    this.x = randomBetween(0, width);
    this.y = initial ? randomBetween(-height, height) : -20;
    this.size = randomBetween(6, 13);
    this.speedY = randomBetween(0.35, 0.9);
    this.speedX = randomBetween(-0.5, 0.5);
    this.rotation = randomBetween(0, Math.PI * 2);
    this.rotationSpeed = randomBetween(-0.02, 0.02);
    this.sway = randomBetween(0.5, 1.6);
    this.swaySpeed = randomBetween(0.01, 0.025);
    this.swayOffset = randomBetween(0, Math.PI * 2);
    this.opacity = randomBetween(0.55, 0.9);
  };

  function SakuraScene(wrap) {
    this.wrap = wrap;
    this.canvas = wrap.querySelector('.sk-canvas');
    if (!this.canvas) return;
    this.ctx = this.canvas.getContext('2d');
    if (!this.ctx) {
      this.showStaticFallback();
      return;
    }

    this.petals = [];
    this.running = false;
    this.rafId = null;
    this.width = 0;
    this.height = 0;

    this.handleResize = this.handleResize.bind(this);
    this.animate = this.animate.bind(this);

    this.init();
  }

  // レイアウト確定前(幅0)に初期化してしまうのを防ぐため、
  // 必要なら requestAnimationFrame で1フレーム待ってから初期化する
  SakuraScene.prototype.init = function () {
    if (this.wrap.clientWidth === 0 && this.retryCount === undefined) {
      this.retryCount = 0;
    }
    if (this.wrap.clientWidth === 0 && this.retryCount < 10) {
      this.retryCount++;
      var self = this;
      requestAnimationFrame(function () { self.init(); });
      return;
    }

    this.resize();
    this.createPetals();

    if (reduceMotion) {
      this.showStaticFallback();
      return;
    }

    window.addEventListener('resize', this.handleResize);

    if ('IntersectionObserver' in window) {
      var self = this;
      this.observer = new IntersectionObserver(function (entries) {
        entries.forEach(function (entry) {
          if (entry.isIntersecting) self.start(); else self.stop();
        });
      }, { threshold: 0.05 });
      this.observer.observe(this.wrap);
    } else {
      this.start();
    }
  };

  SakuraScene.prototype.showStaticFallback = function () {
    this.wrap.classList.add('sk-static');
    var count = 5;
    var width = this.wrap.clientWidth || 320;
    var height = this.wrap.clientHeight || 420;
    for (var i = 0; i < count; i++) {
      var petal = document.createElement('div');
      petal.className = 'sk-static-petal';
      var size = randomBetween(8, 15);
      petal.style.width = size + 'px';
      petal.style.height = size + 'px';
      petal.style.left = randomBetween(0, width - size) + 'px';
      petal.style.top = randomBetween(0, height - size) + 'px';
      petal.style.transform = 'rotate(' + randomBetween(0, 360) + 'deg)';
      this.wrap.appendChild(petal);
    }
  };

  SakuraScene.prototype.createPetals = function () {
    var count = this.width < 480 ? 16 : 26;
    this.petals = [];
    for (var i = 0; i < count; i++) {
      this.petals.push(new Petal(this.width, this.height));
    }
  };

  SakuraScene.prototype.resize = function () {
    var width = this.wrap.clientWidth || 1;
    var height = this.wrap.clientHeight || 1;
    var ratio = Math.min(window.devicePixelRatio || 1, 2);
    this.canvas.width = width * ratio;
    this.canvas.height = height * ratio;
    this.canvas.style.width = width + 'px';
    this.canvas.style.height = height + 'px';
    this.ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
    this.width = width;
    this.height = height;
  };

  SakuraScene.prototype.handleResize = function () {
    this.resize();
    this.createPetals();
    if (!this.running) this.draw();
  };

  SakuraScene.prototype.draw = function () {
    var ctx = this.ctx;
    ctx.clearRect(0, 0, this.width, this.height);
    this.petals.forEach(function (p) {
      ctx.save();
      ctx.translate(p.x, p.y);
      ctx.rotate(p.rotation);
      ctx.globalAlpha = p.opacity;
      ctx.fillStyle = getPetalColor(this.wrap);
      ctx.beginPath();
      ctx.moveTo(0, -p.size);
      ctx.bezierCurveTo(p.size * 0.8, -p.size * 0.6, p.size * 0.8, p.size * 0.6, 0, p.size);
      ctx.bezierCurveTo(-p.size * 0.8, p.size * 0.6, -p.size * 0.8, -p.size * 0.6, 0, -p.size);
      ctx.fill();
      ctx.restore();
    }, this);
  };

  function getPetalColor(wrap) {
    if (!wrap._skPetalColor) {
      wrap._skPetalColor = getComputedStyle(wrap).getPropertyValue('--sk-petal').trim() || '#f6c6d1';
    }
    return wrap._skPetalColor;
  }

  SakuraScene.prototype.step = function () {
    var self = this;
    this.petals.forEach(function (p) {
      p.y += p.speedY;
      p.x += p.speedX + Math.sin(p.y * p.swaySpeed + p.swayOffset) * 0.4 * p.sway;
      p.rotation += p.rotationSpeed;
      if (p.y > self.height + 20) {
        p.reset(self.width, self.height, false);
      }
      if (p.x < -20) p.x = self.width + 20;
      if (p.x > self.width + 20) p.x = -20;
    });
    this.draw();
    if (this.running) {
      this.rafId = requestAnimationFrame(this.animate);
    }
  };

  SakuraScene.prototype.animate = function () {
    this.step();
  };

  SakuraScene.prototype.start = function () {
    if (this.running || reduceMotion) return;
    this.running = true;
    this.rafId = requestAnimationFrame(this.animate);
  };

  SakuraScene.prototype.stop = function () {
    this.running = false;
    if (this.rafId) cancelAnimationFrame(this.rafId);
    this.rafId = null;
  };

  wraps.forEach(function (wrap) {
    new SakuraScene(wrap);
  });
})();

3Dチルトカード

マウスの位置に合わせてカードがなめらかに傾く、上質感を演出する紹介カードです。

コード・使い方を見る
<div class="tk-wrap">
  <div class="tk-card">
    <div class="tk-card-inner">
      <p class="tk-eyebrow">みなと総合クリニック</p>
      <h3 class="tk-title">やさしい内科診療</h3>
      <p class="tk-lead">マウスを近づけると、カードがふわりと傾きます。院内紹介やメニューの見出しにどうぞ。</p>
    </div>
  </div>
</div>
.tk-wrap {
  --tk-bg-from: #ffffff;  /* カードの背景グラデーション開始色 */
  --tk-bg-to: #eef6f5;    /* カードの背景グラデーション終了色 */
  --tk-accent: #2f8f9d;   /* タイトル・アクセントの色 */
  display: flex;
  justify-content: center;
  padding: 1.5em;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.tk-wrap * { box-sizing: border-box; }

.tk-card {
  width: 100%;
  max-width: 360px;
  perspective: 900px;
}
.tk-card-inner {
  position: relative;
  padding: 2.4em 2em;
  background: linear-gradient(155deg, var(--tk-bg-from), var(--tk-bg-to));
  border: 1px solid #e3ecec;
  border-radius: 16px;
  box-shadow: 0 10px 24px rgba(30, 60, 60, .08);
  transform-style: preserve-3d;
  transition: transform .12s ease-out, box-shadow .3s ease;
  will-change: transform;
}
.tk-wrap.tk-active .tk-card-inner {
  box-shadow: 0 20px 34px rgba(30, 60, 60, .14);
}
.tk-eyebrow {
  margin: 0 0 .6em;
  font-size: .78rem;
  letter-spacing: .18em;
  color: #7a8a89;
}
.tk-title {
  margin: 0 0 .6em;
  font-size: 1.3rem;
  font-weight: 700;
  color: var(--tk-accent);
  transform: translateZ(30px);
}
.tk-lead {
  margin: 0;
  font-size: .92rem;
  line-height: 1.9;
  color: #5c6b6a;
  transform: translateZ(18px);
}

/* タッチ端末では傾き無効化し、通常カードとして表示 */
.tk-wrap.tk-disabled .tk-card-inner {
  transition: none;
}

@media (max-width: 480px) {
  .tk-card-inner { padding: 2em 1.6em; }
  .tk-title { font-size: 1.15rem; }
  .tk-lead { font-size: .88rem; }
}

@media (prefers-reduced-motion: reduce) {
  .tk-card-inner { transition: none; }
}
(function () {
  var wraps = document.querySelectorAll('.tk-wrap');
  if (!wraps.length) return;

  var isTouch = window.matchMedia && window.matchMedia('(hover: none), (pointer: coarse)').matches;
  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  if (isTouch || reduceMotion) {
    wraps.forEach(function (wrap) { wrap.classList.add('tk-disabled'); });
    return;
  }

  var MAX_TILT = 10; // 傾きの最大角度(度)

  wraps.forEach(function (wrap) {
    var card = wrap.querySelector('.tk-card');
    var inner = wrap.querySelector('.tk-card-inner');
    if (!card || !inner) return;

    var targetX = 0;
    var targetY = 0;
    var currentX = 0;
    var currentY = 0;
    var rafId = null;
    var hovering = false;

    function onMove(e) {
      var rect = card.getBoundingClientRect();
      var px = (e.clientX - rect.left) / rect.width;  // 0〜1
      var py = (e.clientY - rect.top) / rect.height;   // 0〜1
      targetY = (px - 0.5) * MAX_TILT * 2;  // 左右 → Y軸回転
      targetX = (0.5 - py) * MAX_TILT * 2;  // 上下 → X軸回転
    }

    function loop() {
      currentX += (targetX - currentX) * 0.15;
      currentY += (targetY - currentY) * 0.15;
      inner.style.transform = 'rotateX(' + currentX.toFixed(2) + 'deg) rotateY(' + currentY.toFixed(2) + 'deg)';
      if (hovering || Math.abs(currentX) > 0.05 || Math.abs(currentY) > 0.05) {
        rafId = requestAnimationFrame(loop);
      } else {
        rafId = null;
      }
    }

    function start() {
      if (!rafId) rafId = requestAnimationFrame(loop);
    }

    card.addEventListener('mouseenter', function () {
      hovering = true;
      wrap.classList.add('tk-active');
      start();
    });
    card.addEventListener('mousemove', function (e) {
      onMove(e);
      start();
    });
    card.addEventListener('mouseleave', function () {
      hovering = false;
      wrap.classList.remove('tk-active');
      targetX = 0;
      targetY = 0;
      start();
    });
  });
})();

グラデーション見出しテキスト

文字の色がやわらかく流れ続ける、印象的な見出し用テキストパーツです。CSSのみで動作します。

コード・使い方を見る
<div class="gt-wrap">
  <p class="gt-eyebrow">みなと総合クリニック</p>
  <h2 class="gt-heading">まいにちの健康を、<br class="gt-br">やさしくサポート。</h2>
  <p class="gt-lead">やわらかな色の流れで、見出しに目を留めてもらいたいときに使えるパーツです。</p>
</div>
.gt-wrap {
  --gt-color-1: #2f8f9d;  /* グラデーション色1 */
  --gt-color-2: #6bb7a8;  /* グラデーション色2 */
  --gt-color-3: #8ecfc2;  /* グラデーション色3 */
  --gt-duration: 6s;      /* 流れる速さ(大きいほどゆっくり) */
  text-align: center;
  padding: 2.5em 1.5em;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.gt-wrap * { box-sizing: border-box; }

.gt-eyebrow {
  margin: 0 0 .8em;
  font-size: .8rem;
  letter-spacing: .2em;
  color: #7a8a89;
}

.gt-heading {
  margin: 0 0 .7em;
  font-size: clamp(1.6rem, 4.2vw, 2.6rem);
  font-weight: 700;
  line-height: 1.5;
  background: linear-gradient(90deg, var(--gt-color-1), var(--gt-color-2), var(--gt-color-3), var(--gt-color-1));
  background-size: 300% auto;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  color: transparent;
  animation: gt-flow var(--gt-duration) linear infinite;
}

.gt-lead {
  margin: 0 auto;
  max-width: 32em;
  font-size: .95rem;
  line-height: 1.9;
  color: #6b7280;
}

@keyframes gt-flow {
  0% { background-position: 0% 50%; }
  100% { background-position: 300% 50%; }
}

@media (max-width: 480px) {
  .gt-wrap { padding: 2em 1.2em; }
  .gt-lead { font-size: .88rem; }
  .gt-br { display: none; }
}

/* 動きを抑えたい環境では静止したグラデーションに切り替え */
@media (prefers-reduced-motion: reduce) {
  .gt-heading {
    animation: none;
    background-position: 0% 50%;
  }
}

ブロブアニメーション背景

有機的な形がゆっくりモーフィングする、柔らかい印象の背景演出パーツです。CSSのみで動作します。

コード・使い方を見る
<div class="bl-wrap">
  <div class="bl-blob bl-blob-a" aria-hidden="true"></div>
  <div class="bl-blob bl-blob-b" aria-hidden="true"></div>
  <div class="bl-content">
    <p class="bl-eyebrow">みなと総合クリニック</p>
    <h2 class="bl-title">やわらかな気持ちで、<br class="bl-br">ご来院ください。</h2>
    <p class="bl-lead">ゆらゆらと形を変える背景で、緊張をほぐすような柔らかい雰囲気を演出します。</p>
  </div>
</div>
.bl-wrap {
  --bl-bg: #f4faf9;      /* 背景色 */
  --bl-blob-1: #bfe3dc;  /* ブロブの色1 */
  --bl-blob-2: #d7ecd6;  /* ブロブの色2 */
  --bl-duration: 14s;    /* モーフィングの速さ(大きいほどゆっくり) */
  position: relative;
  overflow: hidden;
  min-height: 380px;
  background: var(--bl-bg);
  border-radius: 16px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.bl-wrap * { box-sizing: border-box; }

.bl-blob {
  position: absolute;
  filter: blur(2px);
  opacity: .75;
  animation: bl-morph var(--bl-duration) ease-in-out infinite;
  will-change: border-radius, transform;
}
.bl-blob-a {
  width: 320px;
  height: 320px;
  left: -90px;
  top: -70px;
  background: var(--bl-blob-1);
  border-radius: 42% 58% 65% 35% / 45% 40% 60% 55%;
}
.bl-blob-b {
  width: 260px;
  height: 260px;
  right: -80px;
  bottom: -90px;
  background: var(--bl-blob-2);
  border-radius: 60% 40% 35% 65% / 55% 60% 40% 45%;
  animation-duration: calc(var(--bl-duration) * 1.2);
  animation-direction: reverse;
}

@keyframes bl-morph {
  0%   { border-radius: 42% 58% 65% 35% / 45% 40% 60% 55%; transform: rotate(0deg) scale(1); }
  33%  { border-radius: 60% 40% 30% 70% / 60% 55% 45% 40%; transform: rotate(8deg) scale(1.05); }
  66%  { border-radius: 35% 65% 55% 45% / 40% 60% 40% 60%; transform: rotate(-6deg) scale(.97); }
  100% { border-radius: 42% 58% 65% 35% / 45% 40% 60% 55%; transform: rotate(0deg) scale(1); }
}

.bl-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 380px;
  padding: 2.5em 1.5em;
}
.bl-eyebrow {
  margin: 0 0 .6em;
  font-size: .8rem;
  letter-spacing: .2em;
  color: #5c8a7f;
}
.bl-title {
  margin: 0 0 .7em;
  font-size: clamp(1.5rem, 4vw, 2.2rem);
  font-weight: 700;
  line-height: 1.6;
  color: #2d3b38;
}
.bl-lead {
  margin: 0;
  max-width: 30em;
  font-size: .92rem;
  line-height: 1.9;
  color: #5c6b6a;
}

@media (max-width: 480px) {
  .bl-wrap, .bl-content { min-height: 320px; }
  .bl-content { padding: 2em 1.2em; }
  .bl-blob-a { width: 220px; height: 220px; }
  .bl-blob-b { width: 180px; height: 180px; }
  .bl-br { display: none; }
}

/* 動きを抑えたい環境では形を静止させる */
@media (prefers-reduced-motion: reduce) {
  .bl-blob { animation: none; }
}

雪が降る冬の季節背景

Canvas に白い雪がしんしんと降り積もる、依存ゼロの冬の季節演出セクションです。画面外にスクロールすると自動で一時停止します。

コード・使い方を見る
<div class="sn-wrap">
  <canvas class="sn-canvas" aria-hidden="true"></canvas>
  <div class="sn-content">
    <p class="sn-eyebrow">みなと総合クリニック</p>
    <h2 class="sn-title">寒い季節も、<br class="sn-br">あたたかい診療を。</h2>
    <p class="sn-lead">静かに降り積もる雪のように、落ち着いた冬の雰囲気をお届けします。</p>
  </div>
</div>
.sn-wrap {
  --sn-flake: #ffffff;     /* 雪の粒の色 */
  --sn-bg-from: #3c5a68;   /* 背景グラデーション開始色 */
  --sn-bg-to: #24394a;     /* 背景グラデーション終了色 */
  position: relative;
  width: 100%;
  min-height: 420px;
  overflow: hidden;
  background: linear-gradient(160deg, var(--sn-bg-from), var(--sn-bg-to));
  border-radius: 14px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.sn-wrap * { box-sizing: border-box; }
.sn-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.sn-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 420px;
  padding: 2.5em 1.5em;
}
.sn-eyebrow {
  margin: 0 0 .6em;
  font-size: .82rem;
  letter-spacing: .2em;
  color: #bcd3de;
}
.sn-title {
  margin: 0 0 .7em;
  font-size: clamp(1.5rem, 4vw, 2.3rem);
  font-weight: 700;
  line-height: 1.6;
  color: #ffffff;
}
.sn-lead {
  margin: 0;
  font-size: .92rem;
  line-height: 1.9;
  color: #d8e4ea;
  max-width: 32em;
}

@media (max-width: 480px) {
  .sn-wrap, .sn-content { min-height: 340px; }
  .sn-content { padding: 2em 1.2em; }
  .sn-br { display: none; }
}

/* 雪を静止配置するフォールバック(reduced-motion または Canvas 未対応時に script.js が付与) */
.sn-wrap.sn-static .sn-canvas { display: none; }
.sn-static-flake {
  position: absolute;
  border-radius: 50%;
  background: var(--sn-flake);
  opacity: .8;
  pointer-events: none;
}
(function () {
  var wraps = document.querySelectorAll('.sn-wrap');
  if (!wraps.length) return;

  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  function randomBetween(min, max) {
    return min + Math.random() * (max - min);
  }

  function Flake(width, height) {
    this.reset(width, height, true);
  }

  Flake.prototype.reset = function (width, height, initial) {
    this.x = randomBetween(0, width);
    this.y = initial ? randomBetween(-height, height) : -10;
    this.size = randomBetween(1.5, 4.5);
    this.speedY = randomBetween(.3, .9);
    this.sway = randomBetween(.4, 1.3);
    this.swaySpeed = randomBetween(.006, .018);
    this.swayOffset = randomBetween(0, Math.PI * 2);
    this.opacity = randomBetween(.5, .95);
  };

  function SnowScene(wrap) {
    this.wrap = wrap;
    this.canvas = wrap.querySelector('.sn-canvas');
    if (!this.canvas) return;
    this.ctx = this.canvas.getContext('2d');
    if (!this.ctx) {
      this.showStaticFallback();
      return;
    }

    this.flakes = [];
    this.running = false;
    this.rafId = null;
    this.width = 0;
    this.height = 0;

    this.handleResize = this.handleResize.bind(this);
    this.animate = this.animate.bind(this);

    this.init();
  }

  // レイアウト確定前(幅0)に初期化してしまうのを防ぐため、
  // 必要なら requestAnimationFrame で1フレーム待ってから初期化する
  SnowScene.prototype.init = function () {
    if (this.wrap.clientWidth === 0 && this.retryCount === undefined) {
      this.retryCount = 0;
    }
    if (this.wrap.clientWidth === 0 && this.retryCount < 10) {
      this.retryCount++;
      var self = this;
      requestAnimationFrame(function () { self.init(); });
      return;
    }

    this.resize();
    this.createFlakes();

    if (reduceMotion) {
      this.showStaticFallback();
      return;
    }

    window.addEventListener('resize', this.handleResize);

    if ('IntersectionObserver' in window) {
      var self = this;
      this.observer = new IntersectionObserver(function (entries) {
        entries.forEach(function (entry) {
          if (entry.isIntersecting) self.start(); else self.stop();
        });
      }, { threshold: 0.05 });
      this.observer.observe(this.wrap);
    } else {
      this.start();
    }
  };

  SnowScene.prototype.showStaticFallback = function () {
    this.wrap.classList.add('sn-static');
    var count = 24;
    var width = this.wrap.clientWidth || 320;
    var height = this.wrap.clientHeight || 420;
    for (var i = 0; i < count; i++) {
      var flake = document.createElement('div');
      flake.className = 'sn-static-flake';
      var size = randomBetween(2, 5);
      flake.style.width = size + 'px';
      flake.style.height = size + 'px';
      flake.style.left = randomBetween(0, width - size) + 'px';
      flake.style.top = randomBetween(0, height - size) + 'px';
      this.wrap.appendChild(flake);
    }
  };

  SnowScene.prototype.createFlakes = function () {
    var count = this.width < 480 ? 40 : 70;
    this.flakes = [];
    for (var i = 0; i < count; i++) {
      this.flakes.push(new Flake(this.width, this.height));
    }
  };

  SnowScene.prototype.resize = function () {
    var width = this.wrap.clientWidth || 1;
    var height = this.wrap.clientHeight || 1;
    var ratio = Math.min(window.devicePixelRatio || 1, 2);
    this.canvas.width = width * ratio;
    this.canvas.height = height * ratio;
    this.canvas.style.width = width + 'px';
    this.canvas.style.height = height + 'px';
    this.ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
    this.width = width;
    this.height = height;
  };

  SnowScene.prototype.handleResize = function () {
    this.resize();
    this.createFlakes();
    if (!this.running) this.draw();
  };

  SnowScene.prototype.draw = function () {
    var ctx = this.ctx;
    ctx.clearRect(0, 0, this.width, this.height);
    ctx.fillStyle = getFlakeColor(this.wrap);
    this.flakes.forEach(function (f) {
      ctx.globalAlpha = f.opacity;
      ctx.beginPath();
      ctx.arc(f.x, f.y, f.size, 0, Math.PI * 2);
      ctx.fill();
    });
    ctx.globalAlpha = 1;
  };

  function getFlakeColor(wrap) {
    if (!wrap._snFlakeColor) {
      wrap._snFlakeColor = getComputedStyle(wrap).getPropertyValue('--sn-flake').trim() || '#ffffff';
    }
    return wrap._snFlakeColor;
  }

  SnowScene.prototype.step = function () {
    var self = this;
    this.flakes.forEach(function (f) {
      f.y += f.speedY;
      f.x += Math.sin(f.y * f.swaySpeed + f.swayOffset) * 0.3 * f.sway;
      if (f.y > self.height + 10) {
        f.reset(self.width, self.height, false);
      }
      if (f.x < -10) f.x = self.width + 10;
      if (f.x > self.width + 10) f.x = -10;
    });
    this.draw();
    if (this.running) {
      this.rafId = requestAnimationFrame(this.animate);
    }
  };

  SnowScene.prototype.animate = function () {
    this.step();
  };

  SnowScene.prototype.start = function () {
    if (this.running || reduceMotion) return;
    this.running = true;
    this.rafId = requestAnimationFrame(this.animate);
  };

  SnowScene.prototype.stop = function () {
    this.running = false;
    if (this.rafId) cancelAnimationFrame(this.rafId);
    this.rafId = null;
  };

  wraps.forEach(function (wrap) {
    new SnowScene(wrap);
  });
})();

スポットライトカーソル

暗い背景の上を、カーソルに合わせて光の輪がやわらかく追いかける演出セクションです。

コード・使い方を見る
<div class="sp-wrap">
  <div class="sp-glow" aria-hidden="true"></div>
  <div class="sp-content">
    <p class="sp-eyebrow">みなと総合クリニック</p>
    <h2 class="sp-title">カーソルを動かして<br class="sp-br">みてください。</h2>
    <p class="sp-lead">暗い背景に、光がやわらかく追従します。特別感を出したいセクションにどうぞ。</p>
  </div>
</div>
.sp-wrap {
  --sp-bg: #1c2a2e;       /* 背景色(暗めの色を推奨) */
  --sp-glow: 107, 199, 184;  /* スポットライトの光の色(R, G, B の数値。例: 47, 143, 157) */
  --sp-glow-size: 420px;  /* 光の広がりの大きさ */
  position: relative;
  overflow: hidden;
  min-height: 380px;
  background: var(--sp-bg);
  border-radius: 16px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.sp-wrap * { box-sizing: border-box; }

.sp-glow {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(var(--sp-glow-size) circle at var(--sp-x, 50%) var(--sp-y, 40%), rgba(var(--sp-glow), .28), transparent 70%);
  transition: opacity .3s ease;
}
.sp-wrap.sp-disabled .sp-glow {
  background: radial-gradient(var(--sp-glow-size) circle at 50% 30%, rgba(var(--sp-glow), .22), transparent 70%);
}

.sp-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 380px;
  padding: 2.5em 1.5em;
}
.sp-eyebrow {
  margin: 0 0 .6em;
  font-size: .8rem;
  letter-spacing: .2em;
  color: #9fd6c9;
}
.sp-title {
  margin: 0 0 .7em;
  font-size: clamp(1.5rem, 4vw, 2.2rem);
  font-weight: 700;
  line-height: 1.6;
  color: #ffffff;
}
.sp-lead {
  margin: 0;
  max-width: 30em;
  font-size: .92rem;
  line-height: 1.9;
  color: #c3d3d0;
}

@media (max-width: 480px) {
  .sp-wrap, .sp-content { min-height: 320px; }
  .sp-content { padding: 2em 1.2em; }
  .sp-br { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .sp-glow { transition: none; }
}
(function () {
  var wraps = document.querySelectorAll('.sp-wrap');
  if (!wraps.length) return;

  var isTouch = window.matchMedia && window.matchMedia('(hover: none), (pointer: coarse)').matches;
  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  if (isTouch || reduceMotion) {
    wraps.forEach(function (wrap) { wrap.classList.add('sp-disabled'); });
    return;
  }

  wraps.forEach(function (wrap) {
    var glow = wrap.querySelector('.sp-glow');
    if (!glow) return;

    var targetX = 50;
    var targetY = 40;
    var currentX = targetX;
    var currentY = targetY;
    var rafId = null;
    var hovering = false;

    function onMove(e) {
      var rect = wrap.getBoundingClientRect();
      targetX = ((e.clientX - rect.left) / rect.width) * 100;
      targetY = ((e.clientY - rect.top) / rect.height) * 100;
    }

    function loop() {
      currentX += (targetX - currentX) * 0.18;
      currentY += (targetY - currentY) * 0.18;
      wrap.style.setProperty('--sp-x', currentX.toFixed(2) + '%');
      wrap.style.setProperty('--sp-y', currentY.toFixed(2) + '%');
      if (hovering || Math.abs(targetX - currentX) > 0.1 || Math.abs(targetY - currentY) > 0.1) {
        rafId = requestAnimationFrame(loop);
      } else {
        rafId = null;
      }
    }

    function start() {
      if (!rafId) rafId = requestAnimationFrame(loop);
    }

    wrap.addEventListener('mouseenter', function () {
      hovering = true;
      start();
    });
    wrap.addEventListener('mousemove', function (e) {
      onMove(e);
      start();
    });
    wrap.addEventListener('mouseleave', function () {
      hovering = false;
    });
  });
})();

波形セクション区切り

セクションとセクションの間を、インラインSVGの波でやさしく区切る静的パーツです。

コード・使い方を見る
<div class="wd-wrap">
  <div class="wd-section wd-section-top">
    <p class="wd-text">みなと総合クリニックのご案内</p>
  </div>
  <div class="wd-divider" aria-hidden="true">
    <svg class="wd-svg" viewBox="0 0 1200 100" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
      <path class="wd-path" d="M0,40 C150,90 350,0 600,40 C850,80 1050,10 1200,50 L1200,100 L0,100 Z"></path>
    </svg>
  </div>
  <div class="wd-section wd-section-bottom">
    <p class="wd-text">波形の区切りで、セクションの切り替わりをやさしく演出します。</p>
  </div>
</div>
.wd-wrap {
  --wd-top-bg: #ffffff;   /* 上のセクションの背景色 */
  --wd-wave: #eaf5f3;     /* 波の色(下のセクションの背景色と合わせるのがおすすめ) */
  --wd-bottom-bg: #eaf5f3; /* 下のセクションの背景色 */
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.wd-wrap * { box-sizing: border-box; }

.wd-section {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2.6em 1.5em;
  text-align: center;
}
.wd-section-top {
  background: var(--wd-top-bg);
}
.wd-section-bottom {
  background: var(--wd-bottom-bg);
}
.wd-text {
  margin: 0;
  max-width: 34em;
  font-size: .98rem;
  line-height: 1.9;
  color: #445251;
}

.wd-divider {
  display: block;
  line-height: 0;
  background: var(--wd-top-bg);
}
.wd-svg {
  display: block;
  width: 100%;
  height: 70px;
}
.wd-path {
  fill: var(--wd-wave);
}

/* 上下反転させたい場合は、この divider の要素に .wd-flip を追加してください */
.wd-divider.wd-flip {
  transform: scaleY(-1);
}

@media (max-width: 480px) {
  .wd-section { padding: 2em 1.2em; }
  .wd-svg { height: 44px; }
  .wd-text { font-size: .9rem; }
}

ガラスモーフィズムカード

グラデーション背景の上に、すりガラスのような半透明カードを重ねる静的パーツです。

コード・使い方を見る
<div class="gc2-wrap">
  <div class="gc2-card">
    <p class="gc2-eyebrow">みなと総合クリニック</p>
    <h3 class="gc2-title">オンライン予約受付中</h3>
    <p class="gc2-lead">すりガラスのような半透明の質感で、上品さと軽やかさを両立したカードです。</p>
    <a class="gc2-btn" href="#">予約はこちら</a>
  </div>
</div>
.gc2-wrap {
  --gc2-bg-from: #2f8f9d;  /* 背景グラデーション開始色 */
  --gc2-bg-to: #6bb7a8;    /* 背景グラデーション終了色 */
  --gc2-card-tint: 255, 255, 255; /* カードの半透明色(RGB値のみ) */
  --gc2-accent: #ffffff;   /* ボタン文字色・見出し色 */
  display: flex;
  justify-content: center;
  padding: 3em 1.5em;
  background: linear-gradient(135deg, var(--gc2-bg-from), var(--gc2-bg-to));
  border-radius: 16px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.gc2-wrap * { box-sizing: border-box; }

.gc2-card {
  width: 100%;
  max-width: 360px;
  padding: 2.4em 2em;
  background: rgba(var(--gc2-card-tint), .16);
  border: 1px solid rgba(var(--gc2-card-tint), .35);
  border-radius: 18px;
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  box-shadow: 0 12px 30px rgba(0, 0, 0, .12);
  text-align: center;
}
.gc2-eyebrow {
  margin: 0 0 .6em;
  font-size: .78rem;
  letter-spacing: .18em;
  color: rgba(255, 255, 255, .85);
}
.gc2-title {
  margin: 0 0 .6em;
  font-size: 1.35rem;
  font-weight: 700;
  color: var(--gc2-accent);
}
.gc2-lead {
  margin: 0 0 1.6em;
  font-size: .92rem;
  line-height: 1.9;
  color: rgba(255, 255, 255, .9);
}
.gc2-btn {
  display: inline-block;
  padding: .8em 2.2em;
  background: #ffffff;
  color: var(--gc2-bg-from);
  font-size: .92rem;
  font-weight: 700;
  text-decoration: none;
  border-radius: 999px;
  transition: transform .2s ease, box-shadow .2s ease;
}
.gc2-btn:hover {
  transform: translateY(-2px);
  box-shadow: 0 8px 18px rgba(0, 0, 0, .18);
}

@media (max-width: 480px) {
  .gc2-wrap { padding: 2.2em 1.2em; }
  .gc2-card { padding: 2em 1.6em; }
  .gc2-title { font-size: 1.2rem; }
  .gc2-lead { font-size: .88rem; }
}

@media (prefers-reduced-motion: reduce) {
  .gc2-btn { transition: none; }
}

/* backdrop-filter 非対応ブラウザ向けのフォールバック(半透明の単色背景になります) */
@supports not (backdrop-filter: blur(1px)) {
  .gc2-card { background: rgba(var(--gc2-card-tint), .32); }
}

気泡が上昇する背景

Canvas に淡い水色の気泡がゆっくり上昇していく、控えめで清潔感のある演出セクションです。画面外にスクロールすると自動で一時停止します。

コード・使い方を見る
<div class="bb-wrap">
  <canvas class="bb-canvas" aria-hidden="true"></canvas>
  <div class="bb-content">
    <p class="bb-eyebrow">みなと総合クリニック</p>
    <h2 class="bb-title">清潔で、静かな<br class="bb-br">安心を。</h2>
    <p class="bb-lead">泡のようにやさしく、日々の診療を丁寧にお届けします。</p>
  </div>
</div>
.bb-wrap {
  --bb-bubble: #bfe3e0;   /* 気泡の色(クリニックのテーマ色に合わせて変更可) */
  --bb-bg-from: #f3fbfa;  /* 背景グラデーション開始色 */
  --bb-bg-to: #e9f6f5;    /* 背景グラデーション終了色 */
  position: relative;
  width: 100%;
  min-height: 420px;
  overflow: hidden;
  background: linear-gradient(160deg, var(--bb-bg-from), var(--bb-bg-to));
  border-radius: 14px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.bb-wrap * { box-sizing: border-box; }
.bb-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.bb-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 420px;
  padding: 2.5em 1.5em;
}
.bb-eyebrow {
  margin: 0 0 .6em;
  font-size: .82rem;
  letter-spacing: .2em;
  color: #4d9c96;
}
.bb-title {
  margin: 0 0 .7em;
  font-size: clamp(1.5rem, 4vw, 2.3rem);
  font-weight: 700;
  line-height: 1.6;
  color: #22343a;
}
.bb-lead {
  margin: 0;
  font-size: .92rem;
  line-height: 1.9;
  color: #5f6b6d;
  max-width: 32em;
}

@media (max-width: 480px) {
  .bb-wrap, .bb-content { min-height: 340px; }
  .bb-content { padding: 2em 1.2em; }
  .bb-br { display: none; }
}

/* 気泡数個を静止配置するフォールバック(reduced-motion または Canvas 未対応時に script.js が付与) */
.bb-wrap.bb-static .bb-canvas { display: none; }
.bb-static-bubble {
  position: absolute;
  border-radius: 50%;
  border: 1.5px solid var(--bb-bubble);
  background: transparent;
  opacity: .5;
  pointer-events: none;
}
(function () {
  var wraps = document.querySelectorAll('.bb-wrap');
  if (!wraps.length) return;

  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  function randomBetween(min, max) {
    return min + Math.random() * (max - min);
  }

  function Bubble(width, height) {
    this.reset(width, height, true);
  }

  Bubble.prototype.reset = function (width, height, initial) {
    this.x = randomBetween(0, width);
    this.y = initial ? randomBetween(0, height) : height + 20;
    this.radius = randomBetween(4, 15);
    this.speedY = randomBetween(0.3, 0.9);
    this.sway = randomBetween(0.4, 1.4);
    this.swaySpeed = randomBetween(0.008, 0.02);
    this.swayOffset = randomBetween(0, Math.PI * 2);
    this.opacity = randomBetween(0.25, 0.55);
  };

  function BubbleScene(wrap) {
    this.wrap = wrap;
    this.canvas = wrap.querySelector('.bb-canvas');
    if (!this.canvas) return;
    this.ctx = this.canvas.getContext('2d');
    if (!this.ctx) {
      this.showStaticFallback();
      return;
    }

    this.bubbles = [];
    this.running = false;
    this.rafId = null;
    this.width = 0;
    this.height = 0;
    this.retryCount = undefined;

    this.handleResize = this.handleResize.bind(this);
    this.animate = this.animate.bind(this);

    this.init();
  }

  // レイアウト確定前(幅0)に初期化してしまうのを防ぐため、
  // 必要なら requestAnimationFrame で1フレーム待ってから初期化する
  BubbleScene.prototype.init = function () {
    if (this.wrap.clientWidth === 0 && this.retryCount === undefined) {
      this.retryCount = 0;
    }
    if (this.wrap.clientWidth === 0 && this.retryCount < 10) {
      this.retryCount++;
      var self = this;
      requestAnimationFrame(function () { self.init(); });
      return;
    }

    this.resize();
    this.createBubbles();

    if (reduceMotion) {
      this.showStaticFallback();
      return;
    }

    window.addEventListener('resize', this.handleResize);

    if ('IntersectionObserver' in window) {
      var self = this;
      this.observer = new IntersectionObserver(function (entries) {
        entries.forEach(function (entry) {
          if (entry.isIntersecting) self.start(); else self.stop();
        });
      }, { threshold: 0.05 });
      this.observer.observe(this.wrap);
    } else {
      this.start();
    }
  };

  BubbleScene.prototype.showStaticFallback = function () {
    this.wrap.classList.add('bb-static');
    var count = 6;
    var width = this.wrap.clientWidth || 320;
    var height = this.wrap.clientHeight || 420;
    for (var i = 0; i < count; i++) {
      var bubble = document.createElement('div');
      bubble.className = 'bb-static-bubble';
      var size = randomBetween(10, 26);
      bubble.style.width = size + 'px';
      bubble.style.height = size + 'px';
      bubble.style.left = randomBetween(0, Math.max(width - size, 0)) + 'px';
      bubble.style.top = randomBetween(0, Math.max(height - size, 0)) + 'px';
      this.wrap.appendChild(bubble);
    }
  };

  BubbleScene.prototype.createBubbles = function () {
    var count = this.width < 480 ? 14 : 22;
    this.bubbles = [];
    for (var i = 0; i < count; i++) {
      this.bubbles.push(new Bubble(this.width, this.height));
    }
  };

  BubbleScene.prototype.resize = function () {
    var width = this.wrap.clientWidth || 1;
    var height = this.wrap.clientHeight || 1;
    var ratio = Math.min(window.devicePixelRatio || 1, 2);
    this.canvas.width = width * ratio;
    this.canvas.height = height * ratio;
    this.canvas.style.width = width + 'px';
    this.canvas.style.height = height + 'px';
    this.ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
    this.width = width;
    this.height = height;
  };

  BubbleScene.prototype.handleResize = function () {
    if (this.wrap.clientWidth === 0) return;
    this.resize();
    this.createBubbles();
    if (!this.running) this.draw();
  };

  function getBubbleColor(wrap) {
    if (!wrap._bbColor) {
      wrap._bbColor = getComputedStyle(wrap).getPropertyValue('--bb-bubble').trim() || '#bfe3e0';
    }
    return wrap._bbColor;
  }

  BubbleScene.prototype.draw = function () {
    var ctx = this.ctx;
    var color = getBubbleColor(this.wrap);
    ctx.clearRect(0, 0, this.width, this.height);
    this.bubbles.forEach(function (b) {
      ctx.save();
      ctx.globalAlpha = b.opacity;
      ctx.strokeStyle = color;
      ctx.lineWidth = 1.4;
      ctx.beginPath();
      ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
      ctx.stroke();
      // 内側にごく淡いハイライトを加え、清潔感のある泡らしさを出す
      ctx.globalAlpha = b.opacity * 0.4;
      ctx.fillStyle = color;
      ctx.fill();
      ctx.restore();
    });
  };

  BubbleScene.prototype.step = function () {
    var self = this;
    this.bubbles.forEach(function (b) {
      b.y -= b.speedY;
      b.x += Math.sin(b.y * b.swaySpeed + b.swayOffset) * 0.4 * b.sway;
      if (b.y < -20) {
        b.reset(self.width, self.height, false);
      }
      if (b.x < -20) b.x = self.width + 20;
      if (b.x > self.width + 20) b.x = -20;
    });
    this.draw();
    if (this.running) {
      this.rafId = requestAnimationFrame(this.animate);
    }
  };

  BubbleScene.prototype.animate = function () {
    this.step();
  };

  BubbleScene.prototype.start = function () {
    if (this.running || reduceMotion) return;
    if (this.wrap.clientWidth === 0) return;
    this.running = true;
    this.rafId = requestAnimationFrame(this.animate);
  };

  BubbleScene.prototype.stop = function () {
    this.running = false;
    if (this.rafId) cancelAnimationFrame(this.rafId);
    this.rafId = null;
  };

  wraps.forEach(function (wrap) {
    new BubbleScene(wrap);
  });
})();

浮遊する幾何図形背景

淡い幾何図形がゆっくりふわふわと浮遊する、CSSのみで動く軽量な背景演出セクションです。

コード・使い方を見る
<div class="fs2-wrap">
  <span class="fs2-shape fs2-shape-1" aria-hidden="true"></span>
  <span class="fs2-shape fs2-shape-2" aria-hidden="true"></span>
  <span class="fs2-shape fs2-shape-3" aria-hidden="true"></span>
  <span class="fs2-shape fs2-shape-4" aria-hidden="true"></span>
  <span class="fs2-shape fs2-shape-5" aria-hidden="true"></span>
  <div class="fs2-content">
    <p class="fs2-eyebrow">みなと総合クリニック</p>
    <h2 class="fs2-title">やわらかな空気の中で、<br class="fs2-br">診療を。</h2>
    <p class="fs2-lead">ふわりと浮かぶ図形のように、緊張をほどく院内を目指しています。</p>
  </div>
</div>
.fs2-wrap {
  --fs2-shape: #cfe3e8;   /* 図形の色(クリニックのテーマ色に合わせて変更可) */
  --fs2-bg-from: #fbfdfd; /* 背景グラデーション開始色 */
  --fs2-bg-to: #f2f7f8;   /* 背景グラデーション終了色 */
  position: relative;
  width: 100%;
  min-height: 420px;
  overflow: hidden;
  background: linear-gradient(160deg, var(--fs2-bg-from), var(--fs2-bg-to));
  border-radius: 14px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.fs2-wrap * { box-sizing: border-box; }

.fs2-shape {
  position: absolute;
  display: block;
  background: var(--fs2-shape);
  opacity: .55;
  pointer-events: none;
  will-change: transform;
  animation: fs2-float 16s ease-in-out infinite;
}
.fs2-shape-1 {
  top: 8%;
  left: 6%;
  width: 90px;
  height: 90px;
  border-radius: 50%;
  animation-duration: 14s;
}
.fs2-shape-2 {
  top: 55%;
  left: 12%;
  width: 60px;
  height: 60px;
  border-radius: 30% 70% 60% 40% / 50% 40% 60% 50%;
  animation-duration: 18s;
  animation-delay: -3s;
}
.fs2-shape-3 {
  top: 18%;
  right: 8%;
  width: 110px;
  height: 110px;
  border-radius: 42%;
  animation-duration: 20s;
  animation-delay: -7s;
}
.fs2-shape-4 {
  bottom: 12%;
  right: 16%;
  width: 46px;
  height: 46px;
  border-radius: 50%;
  animation-duration: 12s;
  animation-delay: -5s;
}
.fs2-shape-5 {
  bottom: 8%;
  left: 42%;
  width: 34px;
  height: 34px;
  border-radius: 30%;
  animation-duration: 15s;
  animation-delay: -9s;
}

@keyframes fs2-float {
  0%   { transform: translate(0, 0) rotate(0deg); }
  25%  { transform: translate(12px, -18px) rotate(8deg); }
  50%  { transform: translate(-6px, 10px) rotate(-6deg); }
  75%  { transform: translate(-14px, -8px) rotate(4deg); }
  100% { transform: translate(0, 0) rotate(0deg); }
}

.fs2-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 420px;
  padding: 2.5em 1.5em;
}
.fs2-eyebrow {
  margin: 0 0 .6em;
  font-size: .82rem;
  letter-spacing: .2em;
  color: #5c8b93;
}
.fs2-title {
  margin: 0 0 .7em;
  font-size: clamp(1.5rem, 4vw, 2.3rem);
  font-weight: 700;
  line-height: 1.6;
  color: #263238;
}
.fs2-lead {
  margin: 0;
  font-size: .92rem;
  line-height: 1.9;
  color: #64707a;
  max-width: 32em;
}

@media (max-width: 480px) {
  .fs2-wrap, .fs2-content { min-height: 340px; }
  .fs2-content { padding: 2em 1.2em; }
  .fs2-br { display: none; }
  .fs2-shape-3 { width: 70px; height: 70px; }
  .fs2-shape-1 { width: 60px; height: 60px; }
}

/* 動きを抑えたい利用者への配慮:アニメーションを停止して静止表示にする */
@media (prefers-reduced-motion: reduce) {
  .fs2-shape {
    animation: none;
  }
}

タイプライター風フレーズ切り替え

複数のフレーズを1文字ずつタイプライターのように表示し、点滅するカーソルとともにループさせる演出パーツです。

コード・使い方を見る
<div class="tw2-wrap">
  <p class="tw2-eyebrow">みなと総合クリニック</p>
  <p class="tw2-line">
    <span class="tw2-text" data-tw2-phrases="やさしい内科診療。,地域に根ざした小児科。,土曜も診療しています。">やさしい内科診療。</span><span class="tw2-cursor" aria-hidden="true"></span>
  </p>
</div>
.tw2-wrap {
  --tw2-color: #2a4744;  /* 文字色(クリニックのテーマ色に変更してください) */
  --tw2-cursor: #4d9c96; /* カーソルの色 */
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  text-align: center;
  padding: 2.8em 1.5em;
  box-sizing: border-box;
}
.tw2-wrap * { box-sizing: border-box; }
.tw2-eyebrow {
  margin: 0 0 .7em;
  font-size: .82rem;
  letter-spacing: .2em;
  color: #8a97a0;
}
.tw2-line {
  margin: 0;
  min-height: 1.9em;
  display: flex;
  align-items: baseline;
  justify-content: center;
}
.tw2-text {
  font-size: clamp(1.2rem, 3.6vw, 1.8rem);
  font-weight: 700;
  color: var(--tw2-color);
  white-space: pre;
}
.tw2-cursor {
  display: inline-block;
  width: .09em;
  height: 1.1em;
  margin-left: .1em;
  background: var(--tw2-cursor);
  animation: tw2-blink 0.9s steps(1) infinite;
  vertical-align: text-bottom;
}
@keyframes tw2-blink {
  0%, 49%  { opacity: 1; }
  50%, 100% { opacity: 0; }
}

/* 動きを抑えたい利用者への配慮:カーソル点滅を止め、文章はJS側で全文即表示にする */
@media (prefers-reduced-motion: reduce) {
  .tw2-cursor { animation: none; opacity: 1; }
}

@media (max-width: 480px) {
  .tw2-wrap { padding: 2.2em 1.2em; }
}
(function () {
  var wraps = document.querySelectorAll('.tw2-wrap');
  if (!wraps.length) return;

  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  var TYPE_SPEED = 90;    // 1文字を打つ間隔(ミリ秒)
  var DELETE_SPEED = 45;  // 1文字を消す間隔(ミリ秒)
  var HOLD_TIME = 1800;   // 1フレーズを表示し続ける時間(ミリ秒)

  function TypewriterInstance(el) {
    this.el = el;
    var raw = el.getAttribute('data-tw2-phrases') || el.textContent;
    this.phrases = raw.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
    if (!this.phrases.length) this.phrases = [el.textContent];
    this.index = 0;
    this.timerId = null;

    if (reduceMotion) {
      // 動きを減らす設定の場合は最初のフレーズを全文即表示して終わる
      this.el.textContent = this.phrases[0];
      return;
    }

    this.el.textContent = '';
    this.typePhrase();
  }

  TypewriterInstance.prototype.typePhrase = function () {
    var self = this;
    var phrase = this.phrases[this.index];
    var chars = Array.from(phrase);
    var pos = 0;

    function typeStep() {
      pos++;
      self.el.textContent = chars.slice(0, pos).join('');
      if (pos < chars.length) {
        self.timerId = setTimeout(typeStep, TYPE_SPEED);
      } else {
        self.timerId = setTimeout(function () { self.deletePhrase(chars); }, HOLD_TIME);
      }
    }
    typeStep();
  };

  TypewriterInstance.prototype.deletePhrase = function (chars) {
    var self = this;
    var pos = chars.length;

    function deleteStep() {
      pos--;
      self.el.textContent = chars.slice(0, pos).join('');
      if (pos > 0) {
        self.timerId = setTimeout(deleteStep, DELETE_SPEED);
      } else {
        self.index = (self.index + 1) % self.phrases.length;
        self.timerId = setTimeout(function () { self.typePhrase(); }, 250);
      }
    }
    deleteStep();
  };

  wraps.forEach(function (wrap) {
    var target = wrap.querySelector('.tw2-text');
    if (!target) return;
    new TypewriterInstance(target);
  });
})();

円形プログレスのカウントアップ

画面にスクロールで入ったタイミングで、SVGの円グラフが0から目標の割合まで滑らかに伸びていく実績表示です。

コード・使い方を見る
<div class="cr2-wrap">
  <p class="cr2-heading">みなと総合クリニック の取り組み</p>
  <ul class="cr2-grid">
    <li class="cr2-item">
      <div class="cr2-ring" data-cr2-target="92" data-cr2-suffix="%">
        <svg class="cr2-svg" viewBox="0 0 120 120" aria-hidden="true">
          <circle class="cr2-track" cx="60" cy="60" r="52"></circle>
          <circle class="cr2-bar" cx="60" cy="60" r="52"></circle>
        </svg>
        <span class="cr2-value">0%</span>
      </div>
      <p class="cr2-label">患者満足度</p>
    </li>
    <li class="cr2-item">
      <div class="cr2-ring" data-cr2-target="80" data-cr2-suffix="%">
        <svg class="cr2-svg" viewBox="0 0 120 120" aria-hidden="true">
          <circle class="cr2-track" cx="60" cy="60" r="52"></circle>
          <circle class="cr2-bar" cx="60" cy="60" r="52"></circle>
        </svg>
        <span class="cr2-value">0%</span>
      </div>
      <p class="cr2-label">再診率</p>
    </li>
    <li class="cr2-item">
      <div class="cr2-ring" data-cr2-target="98" data-cr2-suffix="%">
        <svg class="cr2-svg" viewBox="0 0 120 120" aria-hidden="true">
          <circle class="cr2-track" cx="60" cy="60" r="52"></circle>
          <circle class="cr2-bar" cx="60" cy="60" r="52"></circle>
        </svg>
        <span class="cr2-value">0%</span>
      </div>
      <p class="cr2-label">予約時間の遵守率</p>
    </li>
  </ul>
</div>
.cr2-wrap {
  --cr2-track: #e6ebe9;  /* 円の背景トラック色 */
  --cr2-bar: #3f8f86;    /* 円の進捗(アクセント)色。医院のテーマ色に変更してください */
  --cr2-text: #223132;   /* 数値の文字色 */
  max-width: 960px;
  margin: 0 auto;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  text-align: center;
  box-sizing: border-box;
}
.cr2-wrap * { box-sizing: border-box; }
.cr2-heading {
  margin: 0 0 1.8rem;
  font-size: 1.15rem;
  font-weight: 700;
  color: #1f2937;
  letter-spacing: .04em;
}
.cr2-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1.2rem;
  list-style: none;
  margin: 0;
  padding: 0;
}
.cr2-item {
  background: #fff;
  border: 1px solid #e5e7eb;
  border-radius: 14px;
  padding: 1.6rem 1rem;
  box-shadow: 0 2px 12px rgba(0, 0, 0, .05);
}
.cr2-ring {
  position: relative;
  width: 104px;
  height: 104px;
  margin: 0 auto .8rem;
}
.cr2-svg {
  width: 100%;
  height: 100%;
  transform: rotate(-90deg);
}
.cr2-track {
  fill: none;
  stroke: var(--cr2-track);
  stroke-width: 10;
}
.cr2-bar {
  fill: none;
  stroke: var(--cr2-bar);
  stroke-width: 10;
  stroke-linecap: round;
  stroke-dasharray: 326.7256;   /* 2πr(r=52)の円周長。半径を変える場合はここも要調整 */
  stroke-dashoffset: 326.7256; /* JSが進捗に応じて書き換える初期値(0%相当) */
  transition: stroke-dashoffset 0.1s linear;
}
.cr2-value {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.3rem;
  font-weight: 800;
  color: var(--cr2-text);
  font-feature-settings: "tnum";
}
.cr2-label {
  margin: 0;
  font-size: .82rem;
  color: #6b7280;
  letter-spacing: .02em;
}

/* 動きを抑えたい利用者への配慮:JS側で即座に目標値まで進める(トランジションのみ無効化) */
@media (prefers-reduced-motion: reduce) {
  .cr2-bar { transition: none; }
}

@media (max-width: 640px) {
  .cr2-grid { gap: .6rem; }
  .cr2-item { padding: 1.1rem .4rem; }
  .cr2-ring { width: 80px; height: 80px; }
  .cr2-value { font-size: 1rem; }
  .cr2-label { font-size: .7rem; }
}
@media (max-width: 360px) {
  .cr2-ring { width: 68px; height: 68px; }
  .cr2-value { font-size: .86rem; }
}
(function () {
  var RING_SELECTOR = '.cr2-ring';
  var DURATION = 1400; // カウントアップにかける時間(ミリ秒)
  var CIRCUMFERENCE = 326.7256; // 2πr(r=52)。style.css の stroke-dasharray と揃えること

  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  var rings = document.querySelectorAll(RING_SELECTOR);
  if (!rings.length) return;

  function animate(ring, target, suffix) {
    var bar = ring.querySelector('.cr2-bar');
    var value = ring.querySelector('.cr2-value');
    if (!bar || !value) return;

    if (reduceMotion) {
      bar.style.strokeDashoffset = CIRCUMFERENCE * (1 - target / 100);
      value.textContent = Math.round(target) + suffix;
      return;
    }

    var start = performance.now();
    function step(now) {
      var progress = Math.min((now - start) / DURATION, 1);
      var eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
      var current = target * eased;
      bar.style.strokeDashoffset = CIRCUMFERENCE * (1 - current / 100);
      value.textContent = Math.round(current) + suffix;
      if (progress < 1) {
        requestAnimationFrame(step);
      } else {
        bar.style.strokeDashoffset = CIRCUMFERENCE * (1 - target / 100);
        value.textContent = Math.round(target) + suffix;
      }
    }
    requestAnimationFrame(step);
  }

  function setup(ring) {
    var bar = ring.querySelector('.cr2-bar');
    if (bar) bar.style.strokeDashoffset = CIRCUMFERENCE;
  }

  rings.forEach(setup);

  if (!('IntersectionObserver' in window)) {
    rings.forEach(function (ring) {
      var target = Number(ring.getAttribute('data-cr2-target')) || 0;
      var suffix = ring.getAttribute('data-cr2-suffix') || '';
      animate(ring, target, suffix);
    });
    return;
  }

  var observer = new IntersectionObserver(
    function (entries) {
      entries.forEach(function (entry) {
        if (!entry.isIntersecting) return;
        var ring = entry.target;
        var target = Number(ring.getAttribute('data-cr2-target')) || 0;
        var suffix = ring.getAttribute('data-cr2-suffix') || '';
        animate(ring, target, suffix);
        observer.unobserve(ring);
      });
    },
    { threshold: 0.4 }
  );

  rings.forEach(function (ring) {
    observer.observe(ring);
  });
})();

ロゴ横スクロールマーキー

提携施設や取扱ブランドのロゴが途切れることなく横に流れ続ける、CSSのみで動くマーキー帯です。マウスを乗せると停止します。

コード・使い方を見る
<div class="ml-wrap">
  <p class="ml-heading">連携・提携施設</p>
  <div class="ml-track">
    <div class="ml-marquee">
      <ul class="ml-list">
        <li class="ml-item"><img class="ml-logo" src="https://placehold.co/140x56?text=Logo+1" alt="提携施設ロゴ1" loading="lazy"></li>
        <li class="ml-item"><img class="ml-logo" src="https://placehold.co/140x56?text=Logo+2" alt="提携施設ロゴ2" loading="lazy"></li>
        <li class="ml-item"><img class="ml-logo" src="https://placehold.co/140x56?text=Logo+3" alt="提携施設ロゴ3" loading="lazy"></li>
        <li class="ml-item"><img class="ml-logo" src="https://placehold.co/140x56?text=Logo+4" alt="提携施設ロゴ4" loading="lazy"></li>
        <li class="ml-item"><img class="ml-logo" src="https://placehold.co/140x56?text=Logo+5" alt="提携施設ロゴ5" loading="lazy"></li>
        <li class="ml-item"><img class="ml-logo" src="https://placehold.co/140x56?text=Logo+6" alt="提携施設ロゴ6" loading="lazy"></li>
      </ul>
      <ul class="ml-list" aria-hidden="true">
        <li class="ml-item"><img class="ml-logo" src="https://placehold.co/140x56?text=Logo+1" alt="" loading="lazy"></li>
        <li class="ml-item"><img class="ml-logo" src="https://placehold.co/140x56?text=Logo+2" alt="" loading="lazy"></li>
        <li class="ml-item"><img class="ml-logo" src="https://placehold.co/140x56?text=Logo+3" alt="" loading="lazy"></li>
        <li class="ml-item"><img class="ml-logo" src="https://placehold.co/140x56?text=Logo+4" alt="" loading="lazy"></li>
        <li class="ml-item"><img class="ml-logo" src="https://placehold.co/140x56?text=Logo+5" alt="" loading="lazy"></li>
        <li class="ml-item"><img class="ml-logo" src="https://placehold.co/140x56?text=Logo+6" alt="" loading="lazy"></li>
      </ul>
    </div>
  </div>
</div>
.ml-wrap {
  --ml-speed: 28s;       /* 1周にかかる時間(長いほどゆっくり) */
  max-width: 960px;
  margin: 0 auto;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  text-align: center;
  box-sizing: border-box;
}
.ml-wrap * { box-sizing: border-box; }
.ml-heading {
  margin: 0 0 1.4rem;
  font-size: 1.05rem;
  font-weight: 700;
  color: #1f2937;
  letter-spacing: .04em;
}
.ml-track {
  overflow: hidden;
  /* 左右端をなめらかにフェードさせるマスク(背景色を問わず自然になじむ) */
  -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 6%, #000 94%, transparent 100%);
  mask-image: linear-gradient(90deg, transparent 0, #000 6%, #000 94%, transparent 100%);
}
/* 同じリストを2本横に並べた帯を、幅ちょうど半分ぶん動かすことで継ぎ目なくループさせる */
.ml-marquee {
  display: flex;
  width: max-content;
  animation: ml-scroll var(--ml-speed) linear infinite;
}
.ml-list {
  display: flex;
  align-items: center;
  gap: 2.4rem;
  list-style: none;
  margin: 0;
  padding: .6rem 1.2rem;
}
.ml-item {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
}
.ml-logo {
  display: block;
  height: 40px;
  width: auto;
  max-width: none;
  filter: grayscale(1) opacity(.55);
  transition: filter .25s ease;
}

/* トラック全体にマウスを乗せている間は一時停止し、ロゴにフォーカスも当てられるように */
.ml-track:hover .ml-marquee,
.ml-track:focus-within .ml-marquee {
  animation-play-state: paused;
}
.ml-track:hover .ml-logo,
.ml-track:focus-within .ml-logo {
  filter: grayscale(0) opacity(1);
}

@keyframes ml-scroll {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

/* 動きを抑えたい利用者への配慮:スクロールを止めて静止表示にする */
@media (prefers-reduced-motion: reduce) {
  .ml-marquee {
    animation: none;
  }
  .ml-marquee .ml-list + .ml-list {
    display: none;
  }
  .ml-track {
    overflow-x: auto;
  }
}

@media (max-width: 480px) {
  .ml-logo { height: 32px; }
  .ml-list { gap: 1.6rem; padding: .5rem .8rem; }
}

光沢が走るCTAボタン

ホバーすると斜めの光沢がボタンを一度だけ横切る、CSSのみで動く単色ベースの予約誘導ボタンです。

コード・使い方を見る
<div class="sb-wrap">
  <p class="sb-lead">みなと総合クリニックのWEB予約はこちらから</p>
  <a class="sb-btn" href="#">
    <span class="sb-btn-label">WEBで予約する</span>
  </a>
</div>
.sb-wrap {
  --sb-bg: #2f8f7f;      /* ボタンの背景色(クリニックのテーマ色に変更してください) */
  --sb-text: #ffffff;    /* ボタンの文字色 */
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  text-align: center;
  padding: 2em 1.5em;
  box-sizing: border-box;
}
.sb-wrap * { box-sizing: border-box; }
.sb-lead {
  margin: 0 0 1.1em;
  font-size: .95rem;
  color: #4b5563;
}
.sb-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: .95em 2.4em;
  background: var(--sb-bg);
  color: var(--sb-text);
  font-weight: 700;
  font-size: 1rem;
  letter-spacing: .04em;
  text-decoration: none;
  border-radius: 999px;
  overflow: hidden;
  isolation: isolate;
  transition: transform .2s ease, box-shadow .2s ease;
  box-shadow: 0 4px 14px rgba(0, 0, 0, .12);
}
.sb-btn:hover,
.sb-btn:focus-visible {
  transform: translateY(-2px);
  box-shadow: 0 8px 20px rgba(0, 0, 0, .16);
}
.sb-btn-label {
  position: relative;
  z-index: 1;
}
/* 光沢部分:疑似要素で斜めの帯を作り、ホバー時だけボタンを横切らせる */
.sb-btn::before {
  content: "";
  position: absolute;
  top: 0;
  left: -60%;
  width: 40%;
  height: 100%;
  background: linear-gradient(
    75deg,
    transparent 0%,
    rgba(255, 255, 255, .55) 50%,
    transparent 100%
  );
  transform: skewX(-20deg);
  transition: left .75s ease;
  pointer-events: none;
}
.sb-btn:hover::before,
.sb-btn:focus-visible::before {
  left: 130%;
}

/* 動きを抑えたい利用者への配慮:光沢の移動アニメーションを無効化 */
@media (prefers-reduced-motion: reduce) {
  .sb-btn::before { transition: none; left: -60%; }
  .sb-btn { transition: none; }
}

@media (max-width: 480px) {
  .sb-btn { width: 100%; padding: .95em 1.4em; }
}

おさかなの群れ(水中背景)

青い小魚の群れがゆったり回遊する水中背景に、1匹だけ色のちがう魚がまじっています。小児科やキッズスペースのセクションにぴったりです。

コード・使い方を見る
<div class="fsh-wrap">
  <canvas class="fsh-canvas" aria-hidden="true"></canvas>
  <div class="fsh-content">
    <p class="fsh-eyebrow">みなと総合クリニック 小児科</p>
    <h2 class="fsh-title">こどもたちが、<br class="fsh-br">えがおになれるばしょ。</h2>
    <p class="fsh-lead">みんなとちがう1匹も、なかまのひとり。ひとりひとりに寄り添う診療を大切にしています。</p>
  </div>
</div>
.fsh-wrap {
  --fsh-bg1: #dff3f6;   /* 水の色(グラデーション開始・水面側) */
  --fsh-bg2: #a9d8e6;   /* 水の色(グラデーション終了・深い側) */
  --fsh-accent: #ef8c7d; /* 1匹だけちがう魚の色 */
  position: relative;
  width: 100%;
  min-height: 420px;
  overflow: hidden;
  background: linear-gradient(160deg, var(--fsh-bg1), var(--fsh-bg2));
  border-radius: 14px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.fsh-wrap * { box-sizing: border-box; }
.fsh-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.fsh-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 420px;
  padding: 2.5em 1.5em;
}
.fsh-eyebrow {
  margin: 0 0 .6em;
  font-size: .82rem;
  letter-spacing: .2em;
  color: #2f6f7d;
}
.fsh-title {
  margin: 0 0 .7em;
  font-size: clamp(1.5rem, 4vw, 2.3rem);
  font-weight: 700;
  line-height: 1.6;
  color: #244047;
}
.fsh-lead {
  margin: 0;
  font-size: .92rem;
  line-height: 1.9;
  color: #3f5b62;
  max-width: 32em;
}

@media (max-width: 480px) {
  .fsh-wrap, .fsh-content { min-height: 340px; }
  .fsh-content { padding: 2em 1.2em; }
  .fsh-br { display: none; }
}

/* THREE.js が読み込めない環境向けの静的フォールバック(script.js が .fsh-static を付与) */
.fsh-wrap.fsh-static .fsh-canvas { display: none; }
(function () {
  var wraps = document.querySelectorAll('.fsh-wrap');
  if (!wraps.length) return;

  if (typeof THREE === 'undefined') {
    console.warn('fish-school: THREE が読み込まれていないため、静的背景で表示します。');
    wraps.forEach(function (wrap) { wrap.classList.add('fsh-static'); });
    return;
  }

  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  function randomBetween(min, max) {
    return min + Math.random() * (max - min);
  }

  function makeFishGeometry() {
    // 簡易な魚シルエット:胴体+尾びれをひとつづきの輪郭として作る
    // (ShapeGeometry は shape 内の2つ目の moveTo を独立した面として扱わないため、
    //   胴体→尾びれ→胴体 と1本の輪郭でつなぐ)
    var shape = new THREE.Shape();
    shape.moveTo(1.6, 0);
    shape.lineTo(-1.0, 0.7);
    shape.lineTo(-0.6, 0);
    shape.lineTo(-1.5, 0.5);
    shape.lineTo(-1.5, -0.5);
    shape.lineTo(-0.6, 0);
    shape.lineTo(-1.0, -0.7);
    shape.lineTo(1.6, 0);
    return new THREE.ShapeGeometry(shape);
  }

  function Fish(isAccent) {
    this.isAccent = !!isAccent;
    this.position = new THREE.Vector3(randomBetween(-40, 40), randomBetween(-18, 18), randomBetween(-15, 15));
    this.velocity = new THREE.Vector3(randomBetween(-0.06, 0.06), randomBetween(-0.03, 0.03), 0);
    this.wobbleOffset = randomBetween(0, Math.PI * 2);
  }

  function FishScene(wrap) {
    this.wrap = wrap;
    this.canvas = wrap.querySelector('.fsh-canvas');
    if (!this.canvas) return;
    this.retryCount = 0;
    this.init();
  }

  FishScene.prototype.init = function () {
    if (this.wrap.clientWidth === 0 && this.retryCount < 10) {
      this.retryCount++;
      var self = this;
      requestAnimationFrame(function () { self.init(); });
      return;
    }

    var width = this.wrap.clientWidth || 1;
    var height = this.wrap.clientHeight || 1;

    this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, alpha: true, antialias: true });
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    this.renderer.setSize(width, height);

    this.scene = new THREE.Scene();
    this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
    this.camera.position.z = 55;

    var bubbleStyle = getComputedStyle(this.wrap);

    // 泡(点スプライト)
    var BUBBLE_COUNT = 18;
    var bubblePositions = new Float32Array(BUBBLE_COUNT * 3);
    this.bubbleSpeeds = [];
    for (var b = 0; b < BUBBLE_COUNT; b++) {
      bubblePositions[b * 3] = randomBetween(-40, 40);
      bubblePositions[b * 3 + 1] = randomBetween(-20, 20);
      bubblePositions[b * 3 + 2] = randomBetween(-20, -5);
      this.bubbleSpeeds.push(randomBetween(0.02, 0.05));
    }
    var bubbleGeometry = new THREE.BufferGeometry();
    bubbleGeometry.setAttribute('position', new THREE.BufferAttribute(bubblePositions, 3));
    var bubbleMaterial = new THREE.PointsMaterial({
      color: 0xffffff,
      size: 1.1,
      transparent: true,
      opacity: 0.55,
      depthWrite: false,
    });
    this.bubblePoints = new THREE.Points(bubbleGeometry, bubbleMaterial);
    this.scene.add(this.bubblePoints);

    // 魚の群れ:青系20〜30匹のうち1匹だけ赤
    var FISH_COUNT = 24;
    var geometry = makeFishGeometry();
    var blueColor = new THREE.Color(0x3f7fae);
    var accentColor = new THREE.Color(bubbleStyle.getPropertyValue('--fsh-accent').trim() || '#ef8c7d');

    this.fishes = [];
    this.meshes = [];
    for (var i = 0; i < FISH_COUNT; i++) {
      var isAccent = i === 0;
      var fish = new Fish(isAccent);
      var hueShift = isAccent ? 0 : randomBetween(-0.08, 0.08);
      var color = isAccent ? accentColor.clone() : blueColor.clone().offsetHSL(hueShift, 0, randomBetween(-0.08, 0.08));
      var material = new THREE.MeshBasicMaterial({
        color: color,
        transparent: true,
        opacity: 0.92,
        side: THREE.DoubleSide,
      });
      var mesh = new THREE.Mesh(geometry, material);
      var scale = isAccent ? 1.3 : randomBetween(0.85, 1.15);
      mesh.scale.set(scale, scale, scale);
      mesh.position.copy(fish.position);
      this.scene.add(mesh);
      this.fishes.push(fish);
      this.meshes.push(mesh);
    }

    this.running = false;
    this.rafId = null;
    this.time = 0;

    this.handleResize = this.handleResize.bind(this);
    this.animate = this.animate.bind(this);

    window.addEventListener('resize', this.handleResize);

    if (reduceMotion) {
      this.renderStatic();
      return;
    }

    if ('IntersectionObserver' in window) {
      var self = this;
      this.observer = new IntersectionObserver(function (entries) {
        entries.forEach(function (entry) {
          if (entry.isIntersecting) self.start(); else self.stop();
        });
      }, { threshold: 0.05 });
      this.observer.observe(this.wrap);
    } else {
      this.start();
    }
  };

  FishScene.prototype.renderStatic = function () {
    this.renderer.render(this.scene, this.camera);
  };

  FishScene.prototype.step = function () {
    this.time += 0.016;

    // 群れの中心を計算(ボイド風:中心へ弱く引き寄せ)
    var center = new THREE.Vector3();
    for (var c = 0; c < this.fishes.length; c++) {
      center.add(this.fishes[c].position);
    }
    center.divideScalar(this.fishes.length);

    for (var i = 0; i < this.fishes.length; i++) {
      var fish = this.fishes[i];
      var mesh = this.meshes[i];

      var toCenter = center.clone().sub(fish.position).multiplyScalar(0.0006);
      fish.velocity.add(toCenter);

      // ゆらぎ
      fish.velocity.x += Math.sin(this.time * 0.5 + fish.wobbleOffset) * 0.0025;
      fish.velocity.y += Math.cos(this.time * 0.4 + fish.wobbleOffset) * 0.0018;

      // 速度制限
      var speed = fish.velocity.length();
      var maxSpeed = fish.isAccent ? 0.09 : 0.07;
      if (speed > maxSpeed) fish.velocity.multiplyScalar(maxSpeed / speed);

      fish.position.add(fish.velocity);

      // 画面外に出たら反対側から回遊
      if (fish.position.x > 45) fish.position.x = -45;
      if (fish.position.x < -45) fish.position.x = 45;
      if (fish.position.y > 20) fish.position.y = -20;
      if (fish.position.y < -20) fish.position.y = 20;

      mesh.position.copy(fish.position);
      mesh.position.y += Math.sin(this.time * 2 + fish.wobbleOffset) * 0.4;

      // 進行方向を向く
      var angle = Math.atan2(fish.velocity.y, fish.velocity.x);
      mesh.rotation.z = angle;
      mesh.scale.x = Math.abs(mesh.scale.x) * (fish.velocity.x < 0 ? -1 : 1);
    }

    // 泡をゆらゆら上昇
    var bubblePos = this.bubblePoints.geometry.attributes.position.array;
    for (var j = 0; j < this.bubbleSpeeds.length; j++) {
      bubblePos[j * 3 + 1] += this.bubbleSpeeds[j];
      bubblePos[j * 3] += Math.sin(this.time + j) * 0.01;
      if (bubblePos[j * 3 + 1] > 20) bubblePos[j * 3 + 1] = -20;
    }
    this.bubblePoints.geometry.attributes.position.needsUpdate = true;

    this.renderer.render(this.scene, this.camera);
  };

  FishScene.prototype.animate = function () {
    if (!this.running) return;
    this.step();
    this.rafId = requestAnimationFrame(this.animate);
  };

  FishScene.prototype.start = function () {
    if (this.running || reduceMotion) { this.renderStatic(); return; }
    this.running = true;
    this.animate();
  };

  FishScene.prototype.stop = function () {
    this.running = false;
    if (this.rafId) cancelAnimationFrame(this.rafId);
    this.rafId = null;
  };

  FishScene.prototype.handleResize = function () {
    var w = this.wrap.clientWidth || 1;
    var h = this.wrap.clientHeight || 1;
    this.renderer.setSize(w, h);
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
    if (!this.running) this.renderStatic();
  };

  wraps.forEach(function (wrap) {
    new FishScene(wrap);
  });
})();

ふわふわ風船背景

パステルカラーの風船が画面下からふわふわ上昇して消えていく、やさしい雰囲気の背景演出です。小児科・キッズスペースの案内に。

コード・使い方を見る
<div class="bal-wrap">
  <canvas class="bal-canvas" aria-hidden="true"></canvas>
  <div class="bal-content">
    <p class="bal-eyebrow">みなと総合クリニック 小児科</p>
    <h2 class="bal-title">ようこそ、<br class="bal-br">みなとキッズクリニックへ。</h2>
    <p class="bal-lead">お子さまもご家族も、笑顔で通っていただけるクリニックを目指しています。</p>
  </div>
</div>
.bal-wrap {
  --bal-bg: #fdf7ef;    /* 背景の色 */
  position: relative;
  width: 100%;
  min-height: 420px;
  overflow: hidden;
  background: var(--bal-bg);
  border-radius: 14px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.bal-wrap * { box-sizing: border-box; }
.bal-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.bal-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 420px;
  padding: 2.5em 1.5em;
}
.bal-eyebrow {
  margin: 0 0 .6em;
  font-size: .82rem;
  letter-spacing: .2em;
  color: #c98a63;
}
.bal-title {
  margin: 0 0 .7em;
  font-size: clamp(1.5rem, 4vw, 2.3rem);
  font-weight: 700;
  line-height: 1.6;
  color: #2d3339;
}
.bal-lead {
  margin: 0;
  font-size: .92rem;
  line-height: 1.9;
  color: #6b7280;
  max-width: 32em;
}

@media (max-width: 480px) {
  .bal-wrap, .bal-content { min-height: 340px; }
  .bal-content { padding: 2em 1.2em; }
  .bal-br { display: none; }
}

/* THREE.js が読み込めない環境向けの静的フォールバック(script.js が .bal-static を付与) */
.bal-wrap.bal-static .bal-canvas { display: none; }
(function () {
  var wraps = document.querySelectorAll('.bal-wrap');
  if (!wraps.length) return;

  if (typeof THREE === 'undefined') {
    console.warn('balloons: THREE が読み込まれていないため、静的背景で表示します。');
    wraps.forEach(function (wrap) { wrap.classList.add('bal-static'); });
    return;
  }

  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 風船の色(パステル5色)。ここを書き換えると色味を調整できます。
  var COLORS = [0xf6b8c1, 0xffd9a0, 0xbfe3ff, 0xc9e9c6, 0xe3c9f0];

  function randomBetween(min, max) {
    return min + Math.random() * (max - min);
  }

  // ワールド座標の可視範囲(カメラのFOV/位置から算出。ピクセル寸法とは独立させる)
  var WORLD_HALF_HEIGHT = 26;

  function Balloon(halfWidth, halfHeight, group) {
    this.group = group;
    this.reset(halfWidth, halfHeight, true);
  }

  Balloon.prototype.reset = function (halfWidth, halfHeight, initial) {
    this.x = randomBetween(-halfWidth, halfWidth);
    this.y = initial ? randomBetween(-halfHeight, halfHeight) : -halfHeight - randomBetween(5, 20);
    this.speed = randomBetween(0.04, 0.09);
    this.swaySpeed = randomBetween(0.008, 0.02);
    this.swayOffset = randomBetween(0, Math.PI * 2);
    this.sway = randomBetween(3, 8);
  };

  function makeBalloonGroup(color) {
    var group = new THREE.Group();

    var bodyGeometry = new THREE.SphereGeometry(3, 16, 16);
    var bodyMaterial = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.92 });
    var body = new THREE.Mesh(bodyGeometry, bodyMaterial);
    body.scale.set(1, 1.15, 1);
    group.add(body);

    // 結び目
    var knotGeometry = new THREE.ConeGeometry(0.5, 0.8, 8);
    var knot = new THREE.Mesh(knotGeometry, bodyMaterial);
    knot.position.y = -3.6;
    knot.rotation.x = Math.PI;
    group.add(knot);

    // ひも
    var stringGeometry = new THREE.BufferGeometry().setFromPoints([
      new THREE.Vector3(0, -4, 0),
      new THREE.Vector3(0, -9, 0),
    ]);
    var stringMaterial = new THREE.LineBasicMaterial({ color: 0xbfae9c, transparent: true, opacity: 0.6 });
    var line = new THREE.Line(stringGeometry, stringMaterial);
    group.add(line);

    return group;
  }

  function BalloonScene(wrap) {
    this.wrap = wrap;
    this.canvas = wrap.querySelector('.bal-canvas');
    if (!this.canvas) return;
    this.retryCount = 0;
    this.init();
  }

  BalloonScene.prototype.init = function () {
    if (this.wrap.clientWidth === 0 && this.retryCount < 10) {
      this.retryCount++;
      var self = this;
      requestAnimationFrame(function () { self.init(); });
      return;
    }

    var width = this.wrap.clientWidth || 1;
    var height = this.wrap.clientHeight || 1;

    this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, alpha: true, antialias: true });
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    this.renderer.setSize(width, height);

    this.scene = new THREE.Scene();
    this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
    this.camera.position.z = 60;

    this.halfHeight = WORLD_HALF_HEIGHT;
    this.halfWidth = WORLD_HALF_HEIGHT * (width / height);

    var COUNT = 14;
    this.balloons = [];
    this.groups = [];
    for (var i = 0; i < COUNT; i++) {
      var color = COLORS[i % COLORS.length];
      var group = makeBalloonGroup(color);
      var balloon = new Balloon(this.halfWidth, this.halfHeight, group);
      group.position.set(balloon.x, balloon.y, randomBetween(-10, 10));
      var scale = randomBetween(0.8, 1.3);
      group.scale.set(scale, scale, scale);
      this.scene.add(group);
      this.balloons.push(balloon);
      this.groups.push(group);
    }

    this.running = false;
    this.rafId = null;
    this.time = 0;

    this.handleResize = this.handleResize.bind(this);
    this.animate = this.animate.bind(this);

    window.addEventListener('resize', this.handleResize);

    if (reduceMotion) {
      this.renderStatic();
      return;
    }

    if ('IntersectionObserver' in window) {
      var self = this;
      this.observer = new IntersectionObserver(function (entries) {
        entries.forEach(function (entry) {
          if (entry.isIntersecting) self.start(); else self.stop();
        });
      }, { threshold: 0.05 });
      this.observer.observe(this.wrap);
    } else {
      this.start();
    }
  };

  BalloonScene.prototype.renderStatic = function () {
    this.renderer.render(this.scene, this.camera);
  };

  BalloonScene.prototype.step = function () {
    this.time += 0.016;

    for (var i = 0; i < this.balloons.length; i++) {
      var balloon = this.balloons[i];
      var group = this.groups[i];

      balloon.y += balloon.speed;
      var sway = Math.sin(this.time * balloon.swaySpeed * 10 + balloon.swayOffset) * balloon.sway * 0.05;
      group.position.x = balloon.x + sway;
      group.position.y = balloon.y;

      if (balloon.y > this.halfHeight + 20) {
        balloon.reset(this.halfWidth, this.halfHeight, false);
      }
    }

    this.renderer.render(this.scene, this.camera);
  };

  BalloonScene.prototype.animate = function () {
    if (!this.running) return;
    this.step();
    this.rafId = requestAnimationFrame(this.animate);
  };

  BalloonScene.prototype.start = function () {
    if (this.running || reduceMotion) { this.renderStatic(); return; }
    this.running = true;
    this.animate();
  };

  BalloonScene.prototype.stop = function () {
    this.running = false;
    if (this.rafId) cancelAnimationFrame(this.rafId);
    this.rafId = null;
  };

  BalloonScene.prototype.handleResize = function () {
    var w = this.wrap.clientWidth || 1;
    var h = this.wrap.clientHeight || 1;
    this.renderer.setSize(w, h);
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
    this.halfWidth = WORLD_HALF_HEIGHT * (w / h);
    if (!this.running) this.renderStatic();
  };

  wraps.forEach(function (wrap) {
    new BalloonScene(wrap);
  });
})();

やさしい夜空背景

星がまたたき、ときどき流れ星がすーっと流れる夜空の背景です。三日月がひとつ浮かび、夜間診療のご案内などに使えます。

コード・使い方を見る
<div class="sky-wrap">
  <canvas class="sky-canvas" aria-hidden="true"></canvas>
  <div class="sky-content">
    <p class="sky-eyebrow">みなと総合クリニック 小児科</p>
    <h2 class="sky-title">おやすみ前も、<br class="sky-br">あんしんできるように。</h2>
    <p class="sky-lead">夜間診療のご案内やお子さまの体調が気になる夜も、私たちがそっと寄り添います。</p>
  </div>
</div>
.sky-wrap {
  --sky-bg1: #1c2444;   /* 夜空グラデーション開始色(上側) */
  --sky-bg2: #0a0e24;   /* 夜空グラデーション終了色(下側) */
  position: relative;
  width: 100%;
  min-height: 420px;
  overflow: hidden;
  background: linear-gradient(180deg, var(--sky-bg1), var(--sky-bg2));
  border-radius: 14px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.sky-wrap * { box-sizing: border-box; }
.sky-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.sky-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 420px;
  padding: 2.5em 1.5em;
  color: #f4f2fb;
}
.sky-eyebrow {
  margin: 0 0 .6em;
  font-size: .82rem;
  letter-spacing: .2em;
  opacity: .75;
}
.sky-title {
  margin: 0 0 .7em;
  font-size: clamp(1.5rem, 4vw, 2.3rem);
  font-weight: 700;
  line-height: 1.6;
}
.sky-lead {
  margin: 0;
  font-size: .92rem;
  line-height: 1.9;
  opacity: .85;
  max-width: 32em;
}

@media (max-width: 480px) {
  .sky-wrap, .sky-content { min-height: 340px; }
  .sky-content { padding: 2em 1.2em; }
  .sky-br { display: none; }
}

/* THREE.js が読み込めない環境向けの静的フォールバック(script.js が .sky-static を付与) */
.sky-wrap.sky-static .sky-canvas { display: none; }
(function () {
  var wraps = document.querySelectorAll('.sky-wrap');
  if (!wraps.length) return;

  if (typeof THREE === 'undefined') {
    console.warn('night-stars: THREE が読み込まれていないため、静的背景で表示します。');
    wraps.forEach(function (wrap) { wrap.classList.add('sky-static'); });
    return;
  }

  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 流れ星の発生頻度(フレーム数の目安。数字を大きくすると流れ星が減ります)
  var SHOOTING_STAR_INTERVAL_MIN = 220;
  var SHOOTING_STAR_INTERVAL_MAX = 480;

  var WORLD_HALF_HEIGHT = 26;

  function randomBetween(min, max) {
    return min + Math.random() * (max - min);
  }

  function makeMoon() {
    // 三日月:円の平面から小さい円をずらして重ねたシルエットを Shape の穴で表現
    var radius = 3.4;
    var shape = new THREE.Shape();
    shape.absarc(0, 0, radius, Math.PI * 0.5, Math.PI * 1.5, false);
    shape.absarc(radius * 0.7, 0, radius * 0.92, Math.PI * 1.5, Math.PI * 0.5, true);
    var geometry = new THREE.ShapeGeometry(shape);
    var material = new THREE.MeshBasicMaterial({ color: 0xfff6de, transparent: true, opacity: 0.9 });
    return new THREE.Mesh(geometry, material);
  }

  function ShootingStar(scene) {
    var geometry = new THREE.BufferGeometry().setFromPoints([
      new THREE.Vector3(0, 0, 0),
      new THREE.Vector3(-3.2, 1.6, 0),
    ]);
    var material = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 });
    this.line = new THREE.Line(geometry, material);
    this.active = false;
    scene.add(this.line);
  }

  ShootingStar.prototype.fire = function (halfWidth, halfHeight) {
    this.active = true;
    this.progress = 0;
    this.startX = randomBetween(-halfWidth * 0.6, halfWidth);
    this.startY = randomBetween(halfHeight * 0.2, halfHeight);
    this.speed = randomBetween(0.03, 0.05);
    this.line.material.opacity = 0;
  };

  ShootingStar.prototype.update = function () {
    if (!this.active) return;
    this.progress += this.speed;
    var dist = this.progress * 22;
    this.line.position.set(this.startX - dist, this.startY - dist * 0.5, 0);
    this.line.material.opacity = this.progress < 0.15 ? this.progress / 0.15
      : this.progress > 0.75 ? Math.max(0, (1 - this.progress) / 0.25)
      : 1;
    if (this.progress >= 1) {
      this.active = false;
      this.line.material.opacity = 0;
    }
  };

  function NightSkyScene(wrap) {
    this.wrap = wrap;
    this.canvas = wrap.querySelector('.sky-canvas');
    if (!this.canvas) return;
    this.retryCount = 0;
    this.init();
  }

  NightSkyScene.prototype.init = function () {
    if (this.wrap.clientWidth === 0 && this.retryCount < 10) {
      this.retryCount++;
      var self = this;
      requestAnimationFrame(function () { self.init(); });
      return;
    }

    var width = this.wrap.clientWidth || 1;
    var height = this.wrap.clientHeight || 1;

    this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, alpha: true, antialias: true });
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    this.renderer.setSize(width, height);

    this.scene = new THREE.Scene();
    this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
    this.camera.position.z = 60;

    this.halfHeight = WORLD_HALF_HEIGHT;
    this.halfWidth = WORLD_HALF_HEIGHT * (width / height);

    // 星(またたきは頂点ごとの疑似ランダム位相を使い、点サイズを揺らす)
    var STAR_COUNT = 160;
    var positions = new Float32Array(STAR_COUNT * 3);
    this.starPhases = new Float32Array(STAR_COUNT);
    this.starBaseSizes = new Float32Array(STAR_COUNT);
    for (var i = 0; i < STAR_COUNT; i++) {
      positions[i * 3] = randomBetween(-this.halfWidth, this.halfWidth);
      positions[i * 3 + 1] = randomBetween(-this.halfHeight, this.halfHeight);
      positions[i * 3 + 2] = randomBetween(-20, 5);
      this.starPhases[i] = randomBetween(0, Math.PI * 2);
      this.starBaseSizes[i] = randomBetween(0.5, 1.6);
    }
    var starGeometry = new THREE.BufferGeometry();
    starGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    // 頂点カラーで星ごとの明るさを毎フレーム変え、1つ1つが本当にまたたくようにする
    this.starColors = new Float32Array(STAR_COUNT * 3);
    for (var c = 0; c < STAR_COUNT; c++) {
      this.starColors[c * 3] = 0.99;
      this.starColors[c * 3 + 1] = 0.96;
      this.starColors[c * 3 + 2] = 1.0;
    }
    starGeometry.setAttribute('color', new THREE.BufferAttribute(this.starColors, 3));
    var starMaterial = new THREE.PointsMaterial({
      vertexColors: true,
      size: 1.1,
      transparent: true,
      opacity: 0.9,
      depthWrite: false,
      sizeAttenuation: true,
    });
    this.stars = new THREE.Points(starGeometry, starMaterial);
    this.scene.add(this.stars);

    // 月
    this.moon = makeMoon();
    this.moon.position.set(this.halfWidth * 0.55, this.halfHeight * 0.55, -10);
    this.scene.add(this.moon);

    // 流れ星(複数本を使い回す)
    this.shootingStars = [ new ShootingStar(this.scene), new ShootingStar(this.scene) ];
    this.nextShootAt = randomBetween(SHOOTING_STAR_INTERVAL_MIN, SHOOTING_STAR_INTERVAL_MAX);
    this.frameCount = 0;

    this.running = false;
    this.rafId = null;
    this.time = 0;

    this.handleResize = this.handleResize.bind(this);
    this.animate = this.animate.bind(this);

    window.addEventListener('resize', this.handleResize);

    if (reduceMotion) {
      this.renderStatic();
      return;
    }

    if ('IntersectionObserver' in window) {
      var self = this;
      this.observer = new IntersectionObserver(function (entries) {
        entries.forEach(function (entry) {
          if (entry.isIntersecting) self.start(); else self.stop();
        });
      }, { threshold: 0.05 });
      this.observer.observe(this.wrap);
    } else {
      this.start();
    }
  };

  NightSkyScene.prototype.renderStatic = function () {
    this.renderer.render(this.scene, this.camera);
  };

  NightSkyScene.prototype.step = function () {
    this.time += 0.016;
    this.frameCount++;

    // 星ごとに位相のずれた sin で明るさを揺らし、1つ1つ独立してまたたかせる
    for (var i = 0; i < this.starPhases.length; i++) {
      var twinkle = 0.5 + 0.5 * Math.sin(this.time * 1.6 + this.starPhases[i]);
      var brightness = (0.35 + 0.65 * twinkle) * this.starBaseSizes[i] / 1.6;
      this.starColors[i * 3] = 0.99 * brightness;
      this.starColors[i * 3 + 1] = 0.96 * brightness;
      this.starColors[i * 3 + 2] = 1.0 * brightness;
    }
    this.stars.geometry.attributes.color.needsUpdate = true;

    if (this.frameCount >= this.nextShootAt) {
      var idle = this.shootingStars.filter(function (s) { return !s.active; })[0];
      if (idle) {
        idle.fire(this.halfWidth, this.halfHeight);
        this.frameCount = 0;
        this.nextShootAt = randomBetween(SHOOTING_STAR_INTERVAL_MIN, SHOOTING_STAR_INTERVAL_MAX);
      }
    }
    this.shootingStars.forEach(function (s) { s.update(); });

    this.renderer.render(this.scene, this.camera);
  };

  NightSkyScene.prototype.animate = function () {
    if (!this.running) return;
    this.step();
    this.rafId = requestAnimationFrame(this.animate);
  };

  NightSkyScene.prototype.start = function () {
    if (this.running || reduceMotion) { this.renderStatic(); return; }
    this.running = true;
    this.animate();
  };

  NightSkyScene.prototype.stop = function () {
    this.running = false;
    if (this.rafId) cancelAnimationFrame(this.rafId);
    this.rafId = null;
  };

  NightSkyScene.prototype.handleResize = function () {
    var w = this.wrap.clientWidth || 1;
    var h = this.wrap.clientHeight || 1;
    this.renderer.setSize(w, h);
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
    this.halfWidth = WORLD_HALF_HEIGHT * (w / h);
    if (!this.running) this.renderStatic();
  };

  wraps.forEach(function (wrap) {
    new NightSkyScene(wrap);
  });
})();

お祝い紙吹雪(1回だけ)

セクションが画面に入った瞬間、パステルカラーの紙吹雪がぱっと舞って自然に消える演出です(約3秒・再突入しても発火しません)。キャンペーンやオープンのお知らせに。

コード・使い方を見る
<div class="cft-wrap">
  <canvas class="cft-canvas" aria-hidden="true"></canvas>
  <div class="cft-content">
    <p class="cft-eyebrow">みなと総合クリニック 小児科</p>
    <h2 class="cft-title">キッズスペースが<br class="cft-br">オープンしました!</h2>
    <p class="cft-lead">待ち時間もお子さまが楽しく過ごせる、新しいキッズスペースをご用意しました。</p>
  </div>
</div>
.cft-wrap {
  --cft-bg1: #fff8f0;   /* 背景グラデーション開始色 */
  --cft-bg2: #fdeee3;   /* 背景グラデーション終了色 */
  position: relative;
  width: 100%;
  min-height: 420px;
  overflow: hidden;
  background: linear-gradient(160deg, var(--cft-bg1), var(--cft-bg2));
  border-radius: 14px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.cft-wrap * { box-sizing: border-box; }
.cft-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  pointer-events: none;
}
.cft-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 420px;
  padding: 2.5em 1.5em;
}
.cft-eyebrow {
  margin: 0 0 .6em;
  font-size: .82rem;
  letter-spacing: .2em;
  color: #e0806a;
}
.cft-title {
  margin: 0 0 .7em;
  font-size: clamp(1.5rem, 4vw, 2.3rem);
  font-weight: 700;
  line-height: 1.6;
  color: #2d3339;
}
.cft-lead {
  margin: 0;
  font-size: .92rem;
  line-height: 1.9;
  color: #6b7280;
  max-width: 32em;
}

@media (max-width: 480px) {
  .cft-wrap, .cft-content { min-height: 340px; }
  .cft-content { padding: 2em 1.2em; }
  .cft-br { display: none; }
}
(function () {
  var wraps = document.querySelectorAll('.cft-wrap');
  if (!wraps.length) return;

  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // 紙吹雪の色(パステル多色)。ここを書き換えると色味を調整できます。
  var COLORS = ['#f6b8c1', '#ffd9a0', '#bfe3ff', '#c9e9c6', '#e3c9f0', '#fff1a8'];
  var DURATION_MS = 3000; // 紙吹雪が舞う時間(ミリ秒)
  var PIECE_COUNT_DESKTOP = 90;
  var PIECE_COUNT_MOBILE = 45;

  function randomBetween(min, max) {
    return min + Math.random() * (max - min);
  }

  function ConfettiPiece(width) {
    this.x = randomBetween(0, width);
    this.y = randomBetween(-160, -10);
    this.w = randomBetween(6, 11);
    this.h = randomBetween(9, 16);
    this.color = COLORS[Math.floor(Math.random() * COLORS.length)];
    this.speedY = randomBetween(1.4, 3.2);
    this.speedX = randomBetween(-0.6, 0.6);
    this.rotation = randomBetween(0, Math.PI * 2);
    this.rotationSpeed = randomBetween(-0.15, 0.15);
    this.sway = randomBetween(0.4, 1.4);
    this.swaySpeed = randomBetween(0.02, 0.05);
    this.swayOffset = randomBetween(0, Math.PI * 2);
  }

  function ConfettiScene(wrap) {
    this.wrap = wrap;
    this.canvas = wrap.querySelector('.cft-canvas');
    if (!this.canvas) return;
    this.ctx = this.canvas.getContext('2d');
    if (!this.ctx) return;

    this.fired = false;
    this.retryCount = 0;
    this.rafId = null;
    this.startTime = null;

    this.handleResize = this.handleResize.bind(this);
    this.animate = this.animate.bind(this);

    this.init();
  }

  ConfettiScene.prototype.init = function () {
    if (this.wrap.clientWidth === 0 && this.retryCount < 10) {
      this.retryCount++;
      var self = this;
      requestAnimationFrame(function () { self.init(); });
      return;
    }

    this.resize();
    window.addEventListener('resize', this.handleResize);

    // reduced-motion では紙吹雪を発生させない(静かなまま)
    if (reduceMotion) return;

    if ('IntersectionObserver' in window) {
      var self = this;
      this.observer = new IntersectionObserver(function (entries) {
        entries.forEach(function (entry) {
          if (entry.isIntersecting && !self.fired) {
            self.fire();
          }
        });
      }, { threshold: 0.2 });
      this.observer.observe(this.wrap);
    } else {
      this.fire();
    }
  };

  ConfettiScene.prototype.resize = function () {
    var width = this.wrap.clientWidth || 1;
    var height = this.wrap.clientHeight || 1;
    var ratio = Math.min(window.devicePixelRatio || 1, 2);
    this.canvas.width = width * ratio;
    this.canvas.height = height * ratio;
    this.canvas.style.width = width + 'px';
    this.canvas.style.height = height + 'px';
    this.ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
    this.width = width;
    this.height = height;
  };

  ConfettiScene.prototype.handleResize = function () {
    this.resize();
  };

  ConfettiScene.prototype.fire = function () {
    // 1回きりの発生(再突入では再発火しない)
    this.fired = true;
    if (this.observer) {
      this.observer.disconnect();
      this.observer = null;
    }

    var count = this.width < 480 ? PIECE_COUNT_MOBILE : PIECE_COUNT_DESKTOP;
    this.pieces = [];
    for (var i = 0; i < count; i++) {
      this.pieces.push(new ConfettiPiece(this.width));
    }
    this.startTime = null;
    this.rafId = requestAnimationFrame(this.animate);
  };

  ConfettiScene.prototype.animate = function (timestamp) {
    if (this.startTime === null) this.startTime = timestamp;
    var elapsed = timestamp - this.startTime;

    var ctx = this.ctx;
    ctx.clearRect(0, 0, this.width, this.height);

    var fadeStart = DURATION_MS * 0.7;
    var opacity = 1;
    if (elapsed > fadeStart) {
      opacity = Math.max(0, 1 - (elapsed - fadeStart) / (DURATION_MS - fadeStart));
    }

    this.pieces.forEach(function (p) {
      p.y += p.speedY;
      p.x += p.speedX + Math.sin(p.y * p.swaySpeed + p.swayOffset) * p.sway;
      p.rotation += p.rotationSpeed;

      ctx.save();
      ctx.translate(p.x, p.y);
      ctx.rotate(p.rotation);
      ctx.globalAlpha = opacity;
      ctx.fillStyle = p.color;
      ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
      ctx.restore();
    });

    if (elapsed < DURATION_MS) {
      this.rafId = requestAnimationFrame(this.animate);
    } else {
      ctx.clearRect(0, 0, this.width, this.height);
      this.rafId = null;
      this.pieces = [];
    }
  };

  wraps.forEach(function (wrap) {
    new ConfettiScene(wrap);
  });
})();

紙ひこうきの空背景

白い紙ひこうきが3機、雲の浮かぶ淡い空を8の字を描きながらゆったり飛ぶ背景演出です。成長・お子さまの未来をテーマにしたセクションに。

コード・使い方を見る
<div class="ppl-wrap">
  <canvas class="ppl-canvas" aria-hidden="true"></canvas>
  <div class="ppl-content">
    <p class="ppl-eyebrow">みなと総合クリニック 小児科</p>
    <h2 class="ppl-title">のびのび育つ、<br class="ppl-br">たのしい毎日を。</h2>
    <p class="ppl-lead">お子さまの成長を、地域のかかりつけ医として見守ってまいります。</p>
  </div>
</div>
.ppl-wrap {
  --ppl-bg1: #eaf6ff;   /* 空の色(グラデーション開始・上側) */
  --ppl-bg2: #cdeaff;   /* 空の色(グラデーション終了・下側) */
  position: relative;
  width: 100%;
  min-height: 420px;
  overflow: hidden;
  background: linear-gradient(180deg, var(--ppl-bg1), var(--ppl-bg2));
  border-radius: 14px;
  font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", sans-serif;
  box-sizing: border-box;
}
.ppl-wrap * { box-sizing: border-box; }
.ppl-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.ppl-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 420px;
  padding: 2.5em 1.5em;
}
.ppl-eyebrow {
  margin: 0 0 .6em;
  font-size: .82rem;
  letter-spacing: .2em;
  color: #4a90a4;
}
.ppl-title {
  margin: 0 0 .7em;
  font-size: clamp(1.5rem, 4vw, 2.3rem);
  font-weight: 700;
  line-height: 1.6;
  color: #2d3339;
}
.ppl-lead {
  margin: 0;
  font-size: .92rem;
  line-height: 1.9;
  color: #5c6b73;
  max-width: 32em;
}

@media (max-width: 480px) {
  .ppl-wrap, .ppl-content { min-height: 340px; }
  .ppl-content { padding: 2em 1.2em; }
  .ppl-br { display: none; }
}

/* THREE.js が読み込めない環境向けの静的フォールバック(script.js が .ppl-static を付与) */
.ppl-wrap.ppl-static .ppl-canvas { display: none; }
(function () {
  var wraps = document.querySelectorAll('.ppl-wrap');
  if (!wraps.length) return;

  if (typeof THREE === 'undefined') {
    console.warn('paper-planes: THREE が読み込まれていないため、静的背景で表示します。');
    wraps.forEach(function (wrap) { wrap.classList.add('ppl-static'); });
    return;
  }

  var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  var WORLD_HALF_HEIGHT = 26;

  function randomBetween(min, max) {
    return min + Math.random() * (max - min);
  }

  function makePlaneGeometry() {
    // 紙ひこうきのシルエット:先端から左右の主翼を通り、後端でつながる1本の輪郭
    var shape = new THREE.Shape();
    shape.moveTo(2.2, 0);
    shape.lineTo(-1.6, 1.1);
    shape.lineTo(-0.9, 0);
    shape.lineTo(-1.6, -1.1);
    shape.lineTo(2.2, 0);
    return new THREE.ShapeGeometry(shape);
  }

  function makeCloudTexture() {
    var canvas = document.createElement('canvas');
    canvas.width = 128;
    canvas.height = 80;
    var ctx = canvas.getContext('2d');
    ctx.fillStyle = 'rgba(255,255,255,0.9)';
    ctx.beginPath();
    ctx.ellipse(40, 45, 32, 20, 0, 0, Math.PI * 2);
    ctx.ellipse(75, 40, 28, 22, 0, 0, Math.PI * 2);
    ctx.ellipse(95, 50, 20, 16, 0, 0, Math.PI * 2);
    ctx.fill();
    return new THREE.CanvasTexture(canvas);
  }

  function Plane(index) {
    this.angleOffset = (index / 3) * Math.PI * 2;
    this.radiusX = randomBetween(14, 22);
    this.radiusY = randomBetween(6, 11);
    this.speed = randomBetween(0.15, 0.24);
    this.centerX = randomBetween(-6, 6);
    this.centerY = randomBetween(-4, 6);
    this.centerZ = randomBetween(-8, 4);
    this.bobOffset = randomBetween(0, Math.PI * 2);
  }

  function PaperPlaneScene(wrap) {
    this.wrap = wrap;
    this.canvas = wrap.querySelector('.ppl-canvas');
    if (!this.canvas) return;
    this.retryCount = 0;
    this.init();
  }

  PaperPlaneScene.prototype.init = function () {
    if (this.wrap.clientWidth === 0 && this.retryCount < 10) {
      this.retryCount++;
      var self = this;
      requestAnimationFrame(function () { self.init(); });
      return;
    }

    var width = this.wrap.clientWidth || 1;
    var height = this.wrap.clientHeight || 1;

    this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, alpha: true, antialias: true });
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    this.renderer.setSize(width, height);

    this.scene = new THREE.Scene();
    this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
    this.camera.position.z = 60;

    this.halfHeight = WORLD_HALF_HEIGHT;
    this.halfWidth = WORLD_HALF_HEIGHT * (width / height);

    // 雲
    var cloudTexture = makeCloudTexture();
    var CLOUD_COUNT = 6;
    this.clouds = [];
    for (var c = 0; c < CLOUD_COUNT; c++) {
      var material = new THREE.SpriteMaterial({ map: cloudTexture, transparent: true, opacity: randomBetween(0.5, 0.85), depthWrite: false });
      var sprite = new THREE.Sprite(material);
      var scale = randomBetween(9, 16);
      sprite.scale.set(scale * 1.4, scale, 1);
      sprite.position.set(
        randomBetween(-this.halfWidth, this.halfWidth),
        randomBetween(-this.halfHeight, this.halfHeight),
        randomBetween(-25, -15)
      );
      this.scene.add(sprite);
      this.clouds.push({ sprite: sprite, speed: randomBetween(0.01, 0.03) });
    }

    // 紙ひこうき3機(ゆるやかな8の字軌道)
    var geometry = makePlaneGeometry();
    var material = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent: true, opacity: 0.95 });
    var shadeMaterial = new THREE.MeshBasicMaterial({ color: 0xdfe9ef, side: THREE.DoubleSide, transparent: true, opacity: 0.95 });

    this.planes = [];
    this.meshes = [];
    for (var i = 0; i < 3; i++) {
      var plane = new Plane(i);
      var mesh = new THREE.Mesh(geometry, i % 2 === 0 ? material : shadeMaterial);
      var scale2 = randomBetween(1.6, 2.4);
      mesh.scale.set(scale2, scale2, scale2);
      this.scene.add(mesh);
      this.planes.push(plane);
      this.meshes.push(mesh);
    }

    this.running = false;
    this.rafId = null;
    this.time = 0;

    this.handleResize = this.handleResize.bind(this);
    this.animate = this.animate.bind(this);

    window.addEventListener('resize', this.handleResize);

    // 初期フレームを計算しておく
    this.updatePlanes(0);

    if (reduceMotion) {
      this.renderStatic();
      return;
    }

    if ('IntersectionObserver' in window) {
      var self = this;
      this.observer = new IntersectionObserver(function (entries) {
        entries.forEach(function (entry) {
          if (entry.isIntersecting) self.start(); else self.stop();
        });
      }, { threshold: 0.05 });
      this.observer.observe(this.wrap);
    } else {
      this.start();
    }
  };

  PaperPlaneScene.prototype.updatePlanes = function (time) {
    for (var i = 0; i < this.planes.length; i++) {
      var plane = this.planes[i];
      var mesh = this.meshes[i];
      var t = time * plane.speed + plane.angleOffset;

      // 8の字(リサージュ曲線)
      var x = plane.centerX + Math.sin(t) * plane.radiusX;
      var y = plane.centerY + Math.sin(t * 2) * plane.radiusY * 0.5 + Math.sin(time * 0.5 + plane.bobOffset) * 0.6;
      mesh.position.set(x, y, plane.centerZ);

      // 進行方向へ機首を向ける
      var dt = 0.05;
      var t2 = (time + dt) * plane.speed + plane.angleOffset;
      var x2 = plane.centerX + Math.sin(t2) * plane.radiusX;
      var y2 = plane.centerY + Math.sin(t2 * 2) * plane.radiusY * 0.5;
      var angle = Math.atan2(y2 - y, x2 - x);
      mesh.rotation.z = angle;
      mesh.scale.x = Math.abs(mesh.scale.x) * (x2 < x ? -1 : 1);
    }
  };

  PaperPlaneScene.prototype.renderStatic = function () {
    this.renderer.render(this.scene, this.camera);
  };

  PaperPlaneScene.prototype.step = function () {
    this.time += 0.016;
    this.updatePlanes(this.time);

    this.clouds.forEach(function (cloud) {
      cloud.sprite.position.x -= cloud.speed;
      if (cloud.sprite.position.x < -this.halfWidth - 15) {
        cloud.sprite.position.x = this.halfWidth + 15;
      }
    }, this);

    this.renderer.render(this.scene, this.camera);
  };

  PaperPlaneScene.prototype.animate = function () {
    if (!this.running) return;
    this.step();
    this.rafId = requestAnimationFrame(this.animate);
  };

  PaperPlaneScene.prototype.start = function () {
    if (this.running || reduceMotion) { this.renderStatic(); return; }
    this.running = true;
    this.animate();
  };

  PaperPlaneScene.prototype.stop = function () {
    this.running = false;
    if (this.rafId) cancelAnimationFrame(this.rafId);
    this.rafId = null;
  };

  PaperPlaneScene.prototype.handleResize = function () {
    var w = this.wrap.clientWidth || 1;
    var h = this.wrap.clientHeight || 1;
    this.renderer.setSize(w, h);
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
    this.halfWidth = WORLD_HALF_HEIGHT * (w / h);
    if (!this.running) this.renderStatic();
  };

  wraps.forEach(function (wrap) {
    new PaperPlaneScene(wrap);
  });
})();