前回の記事では、怪談師が恐怖を演出する「速度」や「間」と、私たちが普段実装しているアニメーションの共通点についてご紹介しました👻

今回は、その「間」や「不気味さ」を、実際にCSSでどう表現できるのかを紹介します!

じわっと現れるテキスト(イージングと文字間の収束)

まずは基本の出現系から。

ポイントは opacityblur だけでなく、letter-spacing を広い状態から縮めることです。

blurだけだと単なるフェードインになりますが、拡散していた文字間が収束することで「霧が凝縮して形になる」質感が出ます。

イージングは ease-out を使用しています。

速く始まってゆっくり定着する曲線で、減速には「到着した」という意思が宿ります。

無生物に人格を持たせたいときは、ease-out 系の長尺が効きます。

@keyframes yurei {
  from {
    opacity: 0;
    filter: blur(14px);
    letter-spacing: 0.6em;
  }
  to {
    opacity: 1;
    filter: blur(0);
    letter-spacing: 0.18em;
  }
}

.yurei {
  animation: yurei 4.5s ease-out forwards;
}

この表現では、letter-spacing をあえて演出として使っていますが、letter-spacing はレイアウトに影響するため、アニメーション対象として大量のテキストに適用する場合はパフォーマンスに注意が必要です。

本文などの長文で使用するのは避け、見出しや短文などの局所的な演出にとどめるのがコツです。

また、filter: blur() は再描画や合成のコストが大きくなる場合があるため、特に大きな要素や大量の要素をアニメーションさせる場合は注意が必要です。

neccoでのデザインでもblurを使用することが多いのですが、コードで表現すると体感がかなり重くなることがあり、場合によっては、コードで再現できる場合でも、画像(WebP/AVIFなど)に差し替えて対応することもあります。

蛍光灯フリッカー(不規則なキーフレーム)

深夜のオフィス、最後の一人。天井の蛍光灯が、不規則に点滅します。

この表現は、技術的にはただの opacity アニメーションです。キーフレームの打ち方を工夫して、不規則な表現を作っています。

ちなみに現在のCSSには、random() プロパティはあるものの、まだ完全に使うことができません。

https://caniuse.com/?search=random()

今回はAIに「不規則で不穏なタイミングでキーフレームを組んで」と指示を出して、出力してもらいました。AIも怪談の「間」を理解してくれる時代です。笑

キーフレームでは、以下のように不規則に opacity を変化させています。

@keyframes flicker {
  0%, 100% { opacity: 1; }
  3%  { opacity: 0.2; }
  6%  { opacity: 0.9; }
  7%  { opacity: 0.1; }
  9%  { opacity: 1; }
  38% { opacity: 1; }
  39% { opacity: 0.3; }
  40% { opacity: 1; }
  71% { opacity: 1; }
  72% { opacity: 0.05; }
  74% { opacity: 0.7; }
  75% { opacity: 0.1; }
  77% { opacity: 1; }
}

.lamp {
  animation: flicker 4s infinite;
}

3% → 6% → 7% → 9% のように不等間隔で密集させ、38〜40%で一瞬だけ落とし、71〜77%で暴れさせています。

規則的な点滅にはすぐ慣れますが、怖く感じるのは、「いつ消えるか予測できない」ことです。キーフレーム全体を見ると、4秒周期のうち暗転しているのは合計でもごく短時間で、大半は普通に点灯しています。

静寂が長いほど、乱れの一瞬が効きます。

グリッチテキスト(疑似要素の重ね合わせとsteps)

グリッチテキストを使って、機械的なノイズを演出しています。

壊れて見えますが、中身は完全に規律のある3層構造です。

「静寂 → 一瞬の乱れ → また静寂」にする。これも蛍光灯と同じ、「間」の実装です。

1つのテキストを3枚重ねる

1つの要素に疑似要素を使って、カラーの異なる文字列を重ねています。

重ねているカラーは赤とシアンです。これは、RGBチャンネル分離(色収差)の再現です。

シアン = G+B なので、mix-blend-mode: screen で重ねると、ぴったり重なった部分は元の白に合成復元され、ズレた部分だけ色が露出します。

疑似要素には、要素の中身のテキストを直接コピーすることができないので、data-text 属性に同じ文字列を持たせて content: attr(data-text) で読み込んでいます。

これでHTMLを汚さず1要素で「本体・赤・シアン」の3枚重ねができます。

<p class="glitch" data-text="ERROR">ERROR</p>
.glitch {
  position: relative;
  color: #e8e4dc;
}

.glitch::before,
.glitch::after {
  content: attr(data-text);
  position: absolute;
  inset: 0 auto auto 0;
  width: 100%;
}

.glitch::before {
  color: #ff2a4d;
  mix-blend-mode: screen;
}

.glitch::after {
  color: #2af5ff;
  mix-blend-mode: screen;
}

ブラウン管の色ズレやVHSのトラッキングエラーと同じ原理なので、見る側の「壊れた映像の記憶」に直接刺さります。

実在する故障の模倣が、恐怖感をさらに掻き立てます。

clip-path で水平にスライスして、タイミングをズラす

@keyframes gl-r {
  0%, 86%, 100% {
    clip-path: inset(0 0 0 0);
    transform: none;
  }

  88% {
    clip-path: inset(15% 0 60% 0);
    transform: translate(4px, 0);
  }

  91% {
    clip-path: inset(70% 0 5% 0);
    transform: translate(-4px, 0);
  }

  94% {
    clip-path: inset(40% 0 40% 0);
    transform: translate(3px, 0);
  }
}

.glitch::before {
  animation: gl-r 3s infinite steps(1);
}

.glitch::after {
  animation: gl-r 3s infinite steps(1) reverse;
}

疑似要素は、inset (上 右 下 左)で表示領域を切り抜きます。

上下だけ削れば水平の帯になり、translate と組み合わせることで、「その帯だけが横にズレる」乱れを作っています。

::after(シアン)側には同じアニメーションを reverse で当てているので、赤とシアンが互いに逆のタイミング・逆方向にズレます。

1本のキーフレームで2つのズレを作ります。グリッチの怖さは1枚のズレではなく、レイヤー間の不一致から生まれます。

steps(1)を利用して補間を無くす

ここが一番のポイントです!

animation-timing-function: steps(1);

ease だと、clip-pathtransform も滑らかに補間されてしまい、「ぬるぬると移動・伸縮する」ようになります。壊れた機械ではなく水中で揺れる文字のような質感になってしまいます。

easeの場合、ぬるぬるした表現になり機械感が損なわれる

グリッチ表現の技術的ポイント

演出としては派手なグリッチ表現ですが、実務で組み込む際は、パフォーマンスとアクセシビリティへの配慮も重要です。

パフォーマンス

clip-path はレイアウトを直接変更しないため、今回のような表現では比較的扱いやすいプロパティです。

ただし、実際の負荷はブラウザや要素のサイズなどによって変わるため、必要に応じて確認が必要です。

重なり順の事故を防ぐ

mix-blend-mode を指定した要素は、自動的に新しいスタッキングコンテキスト(重なり順の管理単位)を生成します。そのため、意図しない重なり方になることがあります。

グリッチ要素の親要素に isolation: isolate を指定し、影響範囲を制限しておくと安全です。

普段の実装でもmix-blend-modeをよく使用しますが、どうしても難しい場合があります。その場合は、デザイナーメンバーに相談して、できる限りデザインに近い状態で実装方法を検討・変更する場合もあります。

スクリーンリーダー重複対策(アクセシビリティ)

疑似要素で content: attr(data-text) を使用すると、スクリーンリーダーの環境によっては本文と合わせてテキストが3回連続で読み上げられてしまう可能性があります。

読み上げを1回に抑える場合は、CSSの代替テキスト構文 content: attr(data-text) / ""(代替テキストを空にする仕様)を使う方法があります。

HTMLに余分なマークアップを増やすことなく、読み上げへの影響を抑えられます。

.glitch::before,
.glitch::after {
  content: attr(data-text) / ""; /* 読み上げをスキップ */
  position: absolute;
  inset: 0 auto auto 0;
  width: 100%;
}

関連:content - CSS | MDN

さいごに

今回は、怪談の「間」や「不気味さ」を、CSSアニメーションとして実装してみました。

普段使っているCSSの技法でも、組み合わせ方やタイミングを変えることで、心地よいUIとはまったく違う表現を作ることができます。

怪談師が「間」ひとつで会場の空気を変えるように、アニメーションも速度やタイミングひとつで、見る人の感じ方を変えることができます。

自分の好きな怪談をフロントエンドの視点から改めて観察してみると、新しい発見があってとても楽しかったです!これまで以上に怪談や心霊を観たくなりましたし、「もっと面白いホラー表現やアニメーションを実装してみたい!」という気持ちも芽生えました。

今後も大好きな怪談や心霊をたくさん浴びつつ、日々の実装に生かしていきたいと思います👻