特殊エフェクト

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);
  });
})();