コピペで簡単!CSSとJSで動く高品質なネオン風ハートアニメーションの実装

ビジュアル

コピペで簡単!CSSとJSで動く高品質なネオン風ハートアニメーションの実装

投稿日2026/01/21

更新日2026/1/16

動画やGIF画像を使わず、プログラム(シェーダー)で描画するため、画質が劣化せず、動作も非常に軽量なのが特徴です。サイバーパンクな雰囲気や、洗練された印象を与えたい時にぴったりのデザインです。

Preview プレビュー

Code コード

<div class="kumonosu-heart">
  <canvas id="kumonosu-canvas"></canvas>
</div>
.kumonosu-heart {
  background-color: #000;    
  margin: 0;
  overflow: hidden;
  background-repeat: no-repeat;
  height: 100%;
}

// ===============================
// 1) Canvas / WebGL 初期化
// ===============================
const kumonosuCanvas = document.getElementById("kumonosu-canvas");
if (!kumonosuCanvas) throw new Error('Canvas "#kumonosu-canvas" not found.');

kumonosuCanvas.style.display = "block";
kumonosuCanvas.style.margin = "0";
kumonosuCanvas.style.backgroundColor = "#000";
kumonosuCanvas.style.borderRadius = "15px";
kumonosuCanvas.style.width = "100%";
kumonosuCanvas.style.height = "100vh"; // 必要に応じてCSS側で調整OK

const kumonosuGl = kumonosuCanvas.getContext("webgl", { antialias: true });
if (!kumonosuGl) throw new Error("WebGL is not supported in this browser.");

let kumonosuTime = 0;

// ===============================
// 2) Shader
// ===============================
const kumonosuVertexSource = `
attribute vec2 position;
void main() {
  gl_Position = vec4(position, 0.0, 1.0);
}
`;

const kumonosuFragmentSource = `
precision highp float;

uniform float width;
uniform float height;
uniform float time;

#define POINT_COUNT 8

vec2 points[POINT_COUNT];
const float speed = -0.5;
const float len = 0.25;

float intensity = 1.3;
float radius = 0.008;

float sdBezier(vec2 pos, vec2 A, vec2 B, vec2 C){
  vec2 a = B - A;
  vec2 b = A - 2.0*B + C;
  vec2 c = a * 2.0;
  vec2 d = A - pos;

  float kk = 1.0 / dot(b,b);
  float kx = kk * dot(a,b);
  float ky = kk * (2.0*dot(a,a)+dot(d,b)) / 3.0;
  float kz = kk * dot(d,a);
  float res = 0.0;

  float p = ky - kx*kx;
  float p3 = p*p*p;
  float q = kx*(2.0*kx*kx - 3.0*ky) + kz;
  float h = q*q + 4.0*p3;

  if(h >= 0.0){
    h = sqrt(h);
    vec2 x = (vec2(h, -h) - q) / 2.0;
    vec2 uv = sign(x)*pow(abs(x), vec2(1.0/3.0));
    float t = uv.x + uv.y - kx;
    t = clamp(t, 0.0, 1.0);
    vec2 qos = d + (c + b*t)*t;
    res = length(qos);
  } else {
    float z = sqrt(-p);
    float v = acos(q/(p*z*2.0)) / 3.0;
    float m = cos(v);
    float n = sin(v)*1.732050808;

    vec3 t = vec3(m + m, -n - m, n - m) * z - kx;
    t = clamp(t, 0.0, 1.0);

    vec2 qos = d + (c + b*t.x)*t.x;
    float dis = dot(qos,qos);
    res = dis;

    qos = d + (c + b*t.y)*t.y;
    dis = dot(qos,qos);
    res = min(res,dis);

    qos = d + (c + b*t.z)*t.z;
    dis = dot(qos,qos);
    res = min(res,dis);

    res = sqrt(res);
  }

  return res;
}

vec2 getHeartPosition(float t){
  return vec2(
    16.0 * sin(t) * sin(t) * sin(t),
    -(13.0 * cos(t) - 5.0 * cos(2.0*t) - 2.0 * cos(3.0*t) - cos(4.0*t))
  );
}

float getGlow(float dist, float radius, float intensity){
  return pow(radius/dist, intensity);
}

float getSegment(float t, vec2 pos, float offset, float scale){
  for(int i = 0; i < POINT_COUNT; i++){
    points[i] = getHeartPosition(offset + float(i)*len + fract(speed * t) * 6.28);
  }

  vec2 c = (points[0] + points[1]) / 2.0;
  vec2 c_prev;
  float dist = 10000.0;

  for(int i = 0; i < POINT_COUNT-1; i++){
    c_prev = c;
    c = (points[i] + points[i+1]) / 2.0;
    dist = min(dist, sdBezier(pos, scale * c_prev, scale * points[i], scale * c));
  }

  return max(0.0, dist);
}

void main(){
  vec2 resolution = vec2(width, height);
  vec2 uv = gl_FragCoord.xy / resolution.xy;

  float ratio = resolution.x / resolution.y;

  vec2 centre = vec2(0.5, 0.5);
  vec2 pos = centre - uv;
  pos.y /= ratio;
  pos.y += 0.02;

  float scale = 0.005;

  float dist = getSegment(time, pos, 0.0, scale);
  float glow = getGlow(dist, radius, intensity);

  vec3 col = vec3(0.0);
  col += 10.0 * vec3(smoothstep(0.003, 0.001, dist));
  col += glow * vec3(1.0, 0.05, 0.3);

  dist = getSegment(time, pos, 3.4, scale);
  glow = getGlow(dist, radius, intensity);

  col += 10.0 * vec3(smoothstep(0.003, 0.001, dist));
  col += glow * vec3(0.1, 0.4, 1.0);

  col = 1.0 - exp(-col);
  col = pow(col, vec3(0.4545)); // ガンマ補正
  gl_FragColor = vec4(col, 1.0);
}
`;

// ===============================
// 3) WebGL ユーティリティ
// ===============================
function kumonosuCompileShader(source, type) {
  const shader = kumonosuGl.createShader(type);
  kumonosuGl.shaderSource(shader, source);
  kumonosuGl.compileShader(shader);

  if (!kumonosuGl.getShaderParameter(shader, kumonosuGl.COMPILE_STATUS)) {
    throw new Error(kumonosuGl.getShaderInfoLog(shader) || "Shader compile error.");
  }
  return shader;
}

function kumonosuGetAttrib(program, name) {
  const loc = kumonosuGl.getAttribLocation(program, name);
  if (loc === -1) throw new Error(`Attrib not found: ${name}`);
  return loc;
}

function kumonosuGetUniform(program, name) {
  const loc = kumonosuGl.getUniformLocation(program, name);
  if (!loc) throw new Error(`Uniform not found: ${name}`);
  return loc;
}

// ===============================
// 4) Program / Buffer セットアップ
// ===============================
const kumonosuProgram = kumonosuGl.createProgram();
kumonosuGl.attachShader(kumonosuProgram, kumonosuCompileShader(kumonosuVertexSource, kumonosuGl.VERTEX_SHADER));
kumonosuGl.attachShader(kumonosuProgram, kumonosuCompileShader(kumonosuFragmentSource, kumonosuGl.FRAGMENT_SHADER));
kumonosuGl.linkProgram(kumonosuProgram);
kumonosuGl.useProgram(kumonosuProgram);

// 全画面用の板ポリ(-1〜1)
const kumonosuVertexData = new Float32Array([-1, 1, -1, -1, 1, 1, 1, -1]);
const kumonosuBuffer = kumonosuGl.createBuffer();
kumonosuGl.bindBuffer(kumonosuGl.ARRAY_BUFFER, kumonosuBuffer);
kumonosuGl.bufferData(kumonosuGl.ARRAY_BUFFER, kumonosuVertexData, kumonosuGl.STATIC_DRAW);

const kumonosuPosition = kumonosuGetAttrib(kumonosuProgram, "position");
kumonosuGl.enableVertexAttribArray(kumonosuPosition);
kumonosuGl.vertexAttribPointer(kumonosuPosition, 2, kumonosuGl.FLOAT, false, 8, 0);

// uniform
const kumonosuTimeHandle = kumonosuGetUniform(kumonosuProgram, "time");
const kumonosuWidthHandle = kumonosuGetUniform(kumonosuProgram, "width");
const kumonosuHeightHandle = kumonosuGetUniform(kumonosuProgram, "height");

// ===============================
// 5) リサイズ(表示サイズに追従)
// ===============================
function kumonosuResize() {
  const rect = kumonosuCanvas.getBoundingClientRect();
  const dpr = window.devicePixelRatio || 1;

  const displayW = Math.max(1, Math.floor(rect.width));
  const displayH = Math.max(1, Math.floor(rect.height));

  const internalW = Math.max(1, Math.floor(displayW * dpr));
  const internalH = Math.max(1, Math.floor(displayH * dpr));

  if (kumonosuCanvas.width !== internalW || kumonosuCanvas.height !== internalH) {
    kumonosuCanvas.width = internalW;
    kumonosuCanvas.height = internalH;

    kumonosuGl.viewport(0, 0, internalW, internalH);
    kumonosuGl.uniform1f(kumonosuWidthHandle, internalW);
    kumonosuGl.uniform1f(kumonosuHeightHandle, internalH);
  }
}

kumonosuResize();
window.addEventListener("resize", kumonosuResize);

// 親要素のサイズ変化(レイアウト変更)にも追従
if ("ResizeObserver" in window) {
  const ro = new ResizeObserver(kumonosuResize);
  ro.observe(kumonosuCanvas);
}

// ===============================
// 6) アニメーションループ
// ===============================
let kumonosuLast = performance.now();

function kumonosuDraw(now) {
  const dt = (now - kumonosuLast) / 1000;
  kumonosuLast = now;
  kumonosuTime += dt;

  kumonosuGl.uniform1f(kumonosuTimeHandle, kumonosuTime);
  kumonosuGl.drawArrays(kumonosuGl.TRIANGLE_STRIP, 0, 4);

  requestAnimationFrame(kumonosuDraw);
}

requestAnimationFrame(kumonosuDraw);

Explanation 詳しい説明

デザインと動きの特徴

このアニメーションは、単なる画像表示ではなく、WebGL(GLSLシェーダー)を使用したプロシージャル(数式ベース)な描画であることが最大の特徴です。

  • 滑らかな追従アニメーション
    2本の光の線(赤・ピンク系と青系)が、ハートの軌道を描くように追いかけ合う動きをしています。ベジェ曲線の距離計算を用いているため、カクつきのない非常に滑らかな曲線美が表現されています。
  • 高品質なネオン発光
    中心から外側に向かって減衰する光の広がりが計算されており、暗い背景に対して高いコントラスト比を持ちます。これにより、サイバーパンクレトロウェーブといったトレンドのデザインスタイルに合致します。
  • 残像と減衰
    光の先端から後方に向かって細くなる「彗星の尾」のような表現があり、スピード感と立体感を演出しています。

SEO上のメリットと使い時

Webサイトにこのアニメーションを導入することで、以下の指標やユーザー体験にポジティブな影響を与える可能性があります。

  • LP(ランディングページ)のファーストビュー
    ユーザーの視線を強く引きつけるため、直帰率の低下(滞在時間の向上)が期待できます。特にキャンペーンサイトやイベント告知ページに適しています。
  • ローディング画面
    重い処理の待機時間に表示することで、ユーザーの体感待ち時間を短縮させる心理的効果があります。
  • ゲーミング・エンタメ系サイト
    高彩度なネオンカラーは、eスポーツ、ガジェット、ナイトライフ関連のブランドイメージを強化します。
  • LCP (Largest Contentful Paint) への配慮
    動画ファイル(mp4/gif)ではなくコードで描画するため、ファイルサイズ自体は非常に軽量です。通信環境が悪いユーザーに対しても素早く表示を開始できます。

実装・運用時の注意点

SEOとパフォーマンスを維持するために、以下の点に注意が必要です。

  • モバイルレスポンシブ対応
    上記のコードは幅946pxを基準にしていますが、CSSで max-width: 946px; width: 100%; を指定しているため、スマートフォンでも画面幅に合わせて自動的に縮小表示されるように調整済みです。
  • バッテリー消費
    WebGLはGPUを常時使用します。非常に美しい描画ですが、スマホでの閲覧時にバッテリーを消費しやすいため、常時表示する要素よりも、アクセントとしての使用が推奨されます。
  • アクセシビリティ
    Canvasタグ内には、検索エンジンやスクリーンリーダー向けに「代替テキスト」を含めることがSEO上推奨されます。必要に応じて <canvas>ここに説明文</canvas> のようにテキストを追加してください。

カスタマイズのテクニック

コード内の変数を少し調整するだけで、サイトの雰囲気に合わせた変更が可能です。

  • ハートの大きさ
    コード内の float scale = 0.005; の数値を変更します。大きくしたい場合は 0.008、小さくしたい場合は 0.003 程度に書き換えてください。
  • スピードの調整
    const float speed = -0.5; の数値を変更します。数値を大きくすると高速化し、プラスの値にすると逆回転します。
  • 色の変更
    vec3(1.0,0.05,0.3) などのRGB値を変更することで、ブランドカラーに合わせたネオン色に変更できます。