CRT・VHSレトロ画面
画像にスキャンライン・色収差(RGBずれ)・微振動・ノイズを重ね、ブラウン管/ビデオ風のレトロ画面に変換します。走査グローやVHS風OSD(REC表示)も入り、エモい・サイバーパンク系のヒーローや作品アーカイブの演出に最適。canvasノイズは取得失敗時もCSSグラデにフォールバックします。
ライブデモ
使用例(お題: SaaS FlowDesk)
この技法を「SaaS FlowDesk」というテーマのダミーサイトで実際に使った例です。
HTML
<!-- FlowDesk:ブランドヒストリー(VHS風アーカイブ画面) -->
<section class="fd-crt-wrap">
<!-- 説明 -->
<div class="fd-crt-wrap__head">
<span class="fd-crt-wrap__tag">SINCE 2018</span>
<h2 class="fd-crt-wrap__title">あの頃の<br>FlowDesk。</h2>
<p class="fd-crt-wrap__lead">創業時のプロトタイプ映像を、当時のテープから。チームの原点をアーカイブで振り返ります。</p>
<a class="fd-crt-wrap__btn" href="#">沿革を見る</a>
</div>
<!-- ブラウン管/VHS風画面 -->
<div class="fd-crt" aria-label="CRT・VHSレトロ画面">
<div class="fd-crt__screen">
<img class="fd-crt__img" src="https://picsum.photos/seed/flowdesk-archive/640/420" alt="" crossorigin="anonymous">
</div>
<div class="fd-crt__lines" aria-hidden="true"></div>
<div class="fd-crt__vignette" aria-hidden="true"></div>
<canvas class="fd-crt__noise" width="160" height="105" aria-hidden="true"></canvas>
<div class="fd-crt__osd" aria-hidden="true">
<span class="fd-crt__rec"><i></i>REC</span>
<span class="fd-crt__time">SP 2018:04</span>
</div>
</div>
</section>
CSS
/* FlowDesk:ブランドヒストリー VHS風アーカイブ画面 */
:root {
--navy: #0f1b34;
--blue: #4f7cff;
}
* { box-sizing: border-box; }
body {
margin: 0;
height: 400px;
display: flex;
align-items: center;
gap: 32px;
padding: 0 32px;
font-family: "Segoe UI", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
background: radial-gradient(120% 120% at 50% -10%, #16223f 0%, var(--navy) 70%);
color: #fff;
overflow: hidden;
}
.fd-crt-wrap__head { flex: 1; }
.fd-crt-wrap__tag { font-size: 10px; letter-spacing: 0.3em; color: #8aa6ff; }
.fd-crt-wrap__title {
margin: 10px 0 12px;
font-size: 27px;
line-height: 1.35;
font-weight: 800;
}
.fd-crt-wrap__lead {
margin: 0 0 22px;
font-size: 13px;
line-height: 1.85;
max-width: 280px;
color: rgba(255,255,255,0.75);
}
.fd-crt-wrap__btn {
display: inline-block;
padding: 11px 22px;
border-radius: 10px;
background: var(--blue);
color: #fff;
font-size: 13px;
font-weight: 700;
text-decoration: none;
box-shadow: 0 10px 24px rgba(79,124,255,0.45);
transition: transform 0.2s ease;
}
.fd-crt-wrap__btn:hover { transform: translateY(-2px); }
/* ブラウン管の外枠(ベゼル) */
.fd-crt {
position: relative;
flex: 0 0 360px;
width: 360px;
height: 300px;
border-radius: 16px;
overflow: hidden;
background: #000;
animation: fdCrtJitter 5.5s steps(1) infinite;
box-shadow:
0 22px 60px rgba(0,0,0,0.6),
inset 0 0 0 2px rgba(255,255,255,0.06),
inset 0 0 40px rgba(0,0,0,0.55);
}
/* 画面(球面感) */
.fd-crt__screen {
position: absolute;
inset: 0;
transform: scale(1.04);
filter: saturate(1.1) contrast(1.08) brightness(1.02);
}
.fd-crt__img,
.fd-crt__screen::before,
.fd-crt__screen::after {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
content: "";
background-image: var(--crt-src);
background-size: cover;
background-position: center;
}
.fd-crt__img { z-index: 1; }
/* R/B チャンネルをずらした色付き複製 */
.fd-crt__screen::before {
z-index: 2;
background-color: #f00;
background-blend-mode: screen;
mix-blend-mode: screen;
opacity: 0.5;
animation: fdCrtShiftR 3.2s ease-in-out infinite;
}
.fd-crt__screen::after {
z-index: 2;
background-color: #00f;
background-blend-mode: screen;
mix-blend-mode: screen;
opacity: 0.5;
animation: fdCrtShiftB 3.2s ease-in-out infinite;
}
/* スキャンライン+走査グロー */
.fd-crt__lines {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
background:
repeating-linear-gradient(to bottom,
rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px,
rgba(0,0,0,0.28) 3px, rgba(0,0,0,0.28) 4px),
linear-gradient(to bottom, rgba(255,255,255,0.06), rgba(255,255,255,0) 8%);
background-size: 100% 100%, 100% 50px;
animation: fdCrtScan 6s linear infinite;
}
/* ビネット */
.fd-crt__vignette {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
background: radial-gradient(120% 120% at 50% 50%, transparent 55%, rgba(0,0,0,0.55) 100%);
}
/* ノイズ */
.fd-crt__noise {
position: absolute;
inset: 0;
z-index: 3;
width: 100%;
height: 100%;
opacity: 0.12;
mix-blend-mode: overlay;
pointer-events: none;
}
/* VHS風OSD */
.fd-crt__osd {
position: absolute;
inset: 12px 14px auto 14px;
z-index: 6;
display: flex;
justify-content: space-between;
font-family: "Courier New", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
letter-spacing: 0.18em;
color: #eafff0;
text-shadow: 0 0 6px rgba(120,255,170,0.8), 0 1px 2px #000;
pointer-events: none;
}
.fd-crt__rec { display: inline-flex; align-items: center; gap: 6px; }
.fd-crt__rec i {
width: 9px;
height: 9px;
border-radius: 50%;
background: #ff3b3b;
box-shadow: 0 0 8px #ff3b3b;
animation: fdCrtBlink 1.2s steps(1) infinite;
}
/* アニメーション */
@keyframes fdCrtScan { to { background-position: 0 0, 0 50px; } }
@keyframes fdCrtShiftR { 0%,100% { transform: translateX(-1.5px); } 50% { transform: translateX(1.5px); } }
@keyframes fdCrtShiftB { 0%,100% { transform: translateX(1.5px); } 50% { transform: translateX(-1.5px); } }
@keyframes fdCrtJitter {
0%, 92%, 100% { transform: translateY(0); }
93% { transform: translateY(-1px) skewX(0.3deg); }
95% { transform: translateY(2px); }
97% { transform: translateY(-1px); }
}
@keyframes fdCrtBlink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0.15; } }
@media (prefers-reduced-motion: reduce) {
.fd-crt,
.fd-crt__lines,
.fd-crt__screen::before,
.fd-crt__screen::after,
.fd-crt__rec i { animation: none; }
.fd-crt__screen::before { transform: translateX(-1.5px); }
.fd-crt__screen::after { transform: translateX(1.5px); }
.fd-crt-wrap__btn { transition: none; }
}
JavaScript
// CRT・VHSレトロ画面:色収差レイヤーへの画像供給+canvasノイズ+安全なフォールバック
(() => {
const crt = document.querySelector(".fd-crt");
const img = document.querySelector(".fd-crt__img");
const noise = document.querySelector(".fd-crt__noise");
if (!crt) return;
// フォールバック背景(取得失敗・CORS事故でも破綻させない)
const fallback =
"linear-gradient(135deg, #0f1b34 0%, #2a3a6a 50%, #4f7cff 100%)," +
"repeating-linear-gradient(45deg, rgba(255,255,255,.06) 0 10px, transparent 10px 22px)";
// 画像URLを CSS変数へ流し込む(::before/::after の色収差レイヤーが参照)
const applySrc = (cssBg) => { crt.style.setProperty("--crt-src", cssBg); };
applySrc(fallback); // 画像が来る前から見える
if (img) img.style.background = fallback;
// picsum 画像を CORS 安全に読み込み、成功したら本画像を採用
if (img && img.getAttribute("src")) {
const probe = new Image();
probe.crossOrigin = "anonymous";
probe.onload = () => {
applySrc(`url("${img.src}")`);
img.style.background = "none";
};
probe.onerror = () => {
img.removeAttribute("src");
img.style.background = fallback;
img.style.backgroundSize = "cover";
};
probe.src = img.src;
}
// canvasノイズ(毎フレーム軽量生成)
const ctx = noise && noise.getContext ? noise.getContext("2d") : null;
let raf = 0;
let running = false;
const drawNoise = () => {
if (!ctx) return;
const w = noise.width, h = noise.height;
const imageData = ctx.createImageData(w, h);
const buf = imageData.data;
for (let i = 0; i < buf.length; i += 4) {
const v = (Math.random() * 255) | 0;
buf[i] = buf[i + 1] = buf[i + 2] = v;
buf[i + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
};
// 30fps 程度に間引いてループ
let last = 0;
const loop = (now) => {
if (!running) return;
if (now - last > 33) { drawNoise(); last = now; }
raf = requestAnimationFrame(loop);
};
const start = () => { if (running || !ctx) return; running = true; raf = requestAnimationFrame(loop); };
const stop = () => { running = false; if (raf) cancelAnimationFrame(raf); raf = 0; };
document.addEventListener("visibilitychange", () => {
document.hidden ? stop() : start();
});
drawNoise();
start();
})();
コード
HTML
<!-- ブラウン管/VHS風レトロ画面:スキャンライン+色収差+微振動+ノイズ -->
<div class="crt-stage">
<div class="crt" aria-label="CRT・VHSレトロ画面">
<!-- 画像本体(RGBずれは ::before/::after の複製レイヤーで表現) -->
<div class="crt__screen">
<img class="crt__img" src="https://picsum.photos/seed/crtvhs/640/420" alt="" crossorigin="anonymous">
</div>
<!-- スキャンライン+ビネット+走査グロー -->
<div class="crt__lines" aria-hidden="true"></div>
<div class="crt__vignette" aria-hidden="true"></div>
<!-- ノイズ(canvasで毎フレーム生成) -->
<canvas class="crt__noise" width="160" height="105" aria-hidden="true"></canvas>
<!-- VHS風のオンスクリーン表示 -->
<div class="crt__osd" aria-hidden="true">
<span class="crt__rec"><i></i>REC</span>
<span class="crt__time">SP 0:00:14</span>
</div>
</div>
</div>
CSS
/* CRT・VHSレトロ画面 */
:root {
--crt-w: min(80vw, 460px);
--crt-radius: 16px;
}
body {
background:
radial-gradient(120% 120% at 50% -10%, #1a1f2b 0%, #07090e 70%);
}
.crt-stage { padding: 22px; }
/* 画面の外枠(ベゼル)。樽型のふくらみと角丸でブラウン管らしく */
.crt {
position: relative;
width: var(--crt-w);
aspect-ratio: 4 / 3;
border-radius: var(--crt-radius);
overflow: hidden;
background: #000;
/* 微振動(フレーム全体)。reduce時は止まる */
animation: crt-jitter 5.5s steps(1) infinite;
box-shadow:
0 22px 60px -20px rgba(0, 0, 0, .9),
inset 0 0 0 2px rgba(255, 255, 255, .06),
inset 0 0 40px rgba(0, 0, 0, .55);
}
/* 画面(わずかに膨らませて球面感を出す) */
.crt__screen {
position: absolute;
inset: 0;
transform: scale(1.04);
filter: saturate(1.15) contrast(1.08) brightness(1.02);
}
/* 元画像。複製を作れないので drop-shadow で軽い色収差を補助しつつ、
実際のRGBずれは ::before/::after の同一画像レイヤーで表現する */
.crt__img,
.crt__screen::before,
.crt__screen::after {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
content: "";
background-image: var(--crt-src);
background-size: cover;
background-position: center;
}
.crt__img { z-index: 1; }
/* R/Bチャンネルをずらした色付き複製(mix-blend:screenで加算合成風) */
.crt__screen::before {
z-index: 2;
background-color: #f00;
background-blend-mode: screen;
mix-blend-mode: screen;
opacity: .5;
animation: crt-shift-r 3.2s ease-in-out infinite;
}
.crt__screen::after {
z-index: 2;
background-color: #00f;
background-blend-mode: screen;
mix-blend-mode: screen;
opacity: .5;
animation: crt-shift-b 3.2s ease-in-out infinite;
}
/* スキャンライン+ゆっくり流れる走査グロー */
.crt__lines {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
background:
repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 0) 2px,
rgba(0, 0, 0, .28) 3px,
rgba(0, 0, 0, .28) 4px
),
linear-gradient(
to bottom,
rgba(255, 255, 255, .06),
rgba(255, 255, 255, 0) 8%
);
background-size: 100% 100%, 100% 50px;
animation: crt-scan 6s linear infinite;
}
/* ビネット+上下の暗がり */
.crt__vignette {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
background: radial-gradient(120% 120% at 50% 50%, transparent 55%, rgba(0, 0, 0, .55) 100%);
}
/* ノイズcanvas(薄く重ねる) */
.crt__noise {
position: absolute;
inset: 0;
z-index: 3;
width: 100%;
height: 100%;
opacity: .12;
mix-blend-mode: overlay;
pointer-events: none;
}
/* VHS風OSD */
.crt__osd {
position: absolute;
inset: 12px 14px auto auto;
left: 14px;
z-index: 6;
display: flex;
justify-content: space-between;
font-family: "Courier New", ui-monospace, monospace;
font-weight: 700;
font-size: clamp(11px, 2.6vw, 15px);
letter-spacing: .18em;
color: #eafff0;
text-shadow: 0 0 6px rgba(120, 255, 170, .8), 0 1px 2px #000;
pointer-events: none;
}
.crt__rec { display: inline-flex; align-items: center; gap: 6px; }
.crt__rec i {
width: 9px;
height: 9px;
border-radius: 50%;
background: #ff3b3b;
box-shadow: 0 0 8px #ff3b3b;
animation: crt-blink 1.2s steps(1) infinite;
}
/* --- アニメーション --- */
@keyframes crt-scan { to { background-position: 0 0, 0 50px; } }
/* RGBずれ(左右に小さく揺れる) */
@keyframes crt-shift-r {
0%, 100% { transform: translateX(-1.5px); }
50% { transform: translateX(1.5px); }
}
@keyframes crt-shift-b {
0%, 100% { transform: translateX(1.5px); }
50% { transform: translateX(-1.5px); }
}
/* 微振動:たまにガクッと画面が縦ずれするVHSトラッキング風 */
@keyframes crt-jitter {
0%, 92%, 100% { transform: translateY(0); }
93% { transform: translateY(-1px) skewX(.3deg); }
95% { transform: translateY(2px); }
97% { transform: translateY(-1px); }
}
@keyframes crt-blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: .15; } }
/* 動きが苦手な人向けに静止(merge側で除去されるが単体表示の保険) */
@media (prefers-reduced-motion: reduce) {
.crt,
.crt__lines,
.crt__screen::before,
.crt__screen::after,
.crt__rec i { animation: none; }
.crt__screen::before { transform: translateX(-1.5px); }
.crt__screen::after { transform: translateX(1.5px); }
}
JavaScript
// CRT・VHSレトロ画面:色収差レイヤーの画像供給+canvasノイズ+安全なフォールバック
(() => {
const crt = document.querySelector(".crt");
const img = document.querySelector(".crt__img");
const noise = document.querySelector(".crt__noise");
if (!crt) return;
// フォールバック用のグラデ/パターン背景(取得失敗・CORS事故でも破綻させない)
const fallback =
"linear-gradient(135deg, #2a3a6a 0%, #6a2f7a 45%, #c2466b 100%)," +
"repeating-linear-gradient(45deg, rgba(255,255,255,.06) 0 10px, transparent 10px 22px)";
// 画像URLを CSS変数へ流し込む(::before/::after の色収差レイヤーが参照)
const applySrc = (cssBg) => {
crt.style.setProperty("--crt-src", cssBg);
};
// まずフォールバックを敷く(画像が来る前から見える)
applySrc(fallback);
if (img) img.style.background = fallback;
// picsum 画像を CORS 安全に読み込み、成功したら本画像を採用
if (img && img.getAttribute("src")) {
const probe = new Image();
probe.crossOrigin = "anonymous";
probe.onload = () => {
const url = `url("${img.src}")`;
applySrc(url); // 色収差レイヤーも本画像に
img.style.background = "none"; // <img>本体はそのまま表示
};
probe.onerror = () => {
// 失敗時はフォールバックを画像にも適用(imgは透過させる)
img.removeAttribute("src");
img.style.background = fallback;
img.style.backgroundSize = "cover";
};
probe.src = img.src;
}
// --- canvasノイズ(毎フレーム軽量生成) ---
const ctx = noise && noise.getContext ? noise.getContext("2d") : null;
let raf = 0;
let running = false;
const drawNoise = () => {
if (!ctx) return;
const w = noise.width, h = noise.height;
const imageData = ctx.createImageData(w, h);
const buf = imageData.data;
// モノクロのランダムノイズを敷き詰める
for (let i = 0; i < buf.length; i += 4) {
const v = (Math.random() * 255) | 0;
buf[i] = buf[i + 1] = buf[i + 2] = v;
buf[i + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
};
// 30fps程度に間引いてループ(負荷軽減)
let last = 0;
const loop = (now) => {
if (!running) return;
if (now - last > 33) { drawNoise(); last = now; }
raf = requestAnimationFrame(loop);
};
const start = () => {
if (running || !ctx) return;
running = true;
raf = requestAnimationFrame(loop);
};
const stop = () => {
running = false;
if (raf) cancelAnimationFrame(raf);
raf = 0;
};
// タブ非表示で停止/復帰で再開
document.addEventListener("visibilitychange", () => {
document.hidden ? stop() : start();
});
// 初期ノイズを1枚描いてからループ開始
drawNoise();
start();
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「CRT・VHSレトロ画面」の効果を追加してください。
# 追加してほしい効果
CRT・VHSレトロ画面(画像エフェクト)
画像にスキャンライン・色収差(RGBずれ)・微振動・ノイズを重ね、ブラウン管/ビデオ風のレトロ画面に変換します。走査グローやVHS風OSD(REC表示)も入り、エモい・サイバーパンク系のヒーローや作品アーカイブの演出に最適。canvasノイズは取得失敗時もCSSグラデにフォールバックします。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- ブラウン管/VHS風レトロ画面:スキャンライン+色収差+微振動+ノイズ -->
<div class="crt-stage">
<div class="crt" aria-label="CRT・VHSレトロ画面">
<!-- 画像本体(RGBずれは ::before/::after の複製レイヤーで表現) -->
<div class="crt__screen">
<img class="crt__img" src="https://picsum.photos/seed/crtvhs/640/420" alt="" crossorigin="anonymous">
</div>
<!-- スキャンライン+ビネット+走査グロー -->
<div class="crt__lines" aria-hidden="true"></div>
<div class="crt__vignette" aria-hidden="true"></div>
<!-- ノイズ(canvasで毎フレーム生成) -->
<canvas class="crt__noise" width="160" height="105" aria-hidden="true"></canvas>
<!-- VHS風のオンスクリーン表示 -->
<div class="crt__osd" aria-hidden="true">
<span class="crt__rec"><i></i>REC</span>
<span class="crt__time">SP 0:00:14</span>
</div>
</div>
</div>
【CSS】
/* CRT・VHSレトロ画面 */
:root {
--crt-w: min(80vw, 460px);
--crt-radius: 16px;
}
body {
background:
radial-gradient(120% 120% at 50% -10%, #1a1f2b 0%, #07090e 70%);
}
.crt-stage { padding: 22px; }
/* 画面の外枠(ベゼル)。樽型のふくらみと角丸でブラウン管らしく */
.crt {
position: relative;
width: var(--crt-w);
aspect-ratio: 4 / 3;
border-radius: var(--crt-radius);
overflow: hidden;
background: #000;
/* 微振動(フレーム全体)。reduce時は止まる */
animation: crt-jitter 5.5s steps(1) infinite;
box-shadow:
0 22px 60px -20px rgba(0, 0, 0, .9),
inset 0 0 0 2px rgba(255, 255, 255, .06),
inset 0 0 40px rgba(0, 0, 0, .55);
}
/* 画面(わずかに膨らませて球面感を出す) */
.crt__screen {
position: absolute;
inset: 0;
transform: scale(1.04);
filter: saturate(1.15) contrast(1.08) brightness(1.02);
}
/* 元画像。複製を作れないので drop-shadow で軽い色収差を補助しつつ、
実際のRGBずれは ::before/::after の同一画像レイヤーで表現する */
.crt__img,
.crt__screen::before,
.crt__screen::after {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
content: "";
background-image: var(--crt-src);
background-size: cover;
background-position: center;
}
.crt__img { z-index: 1; }
/* R/Bチャンネルをずらした色付き複製(mix-blend:screenで加算合成風) */
.crt__screen::before {
z-index: 2;
background-color: #f00;
background-blend-mode: screen;
mix-blend-mode: screen;
opacity: .5;
animation: crt-shift-r 3.2s ease-in-out infinite;
}
.crt__screen::after {
z-index: 2;
background-color: #00f;
background-blend-mode: screen;
mix-blend-mode: screen;
opacity: .5;
animation: crt-shift-b 3.2s ease-in-out infinite;
}
/* スキャンライン+ゆっくり流れる走査グロー */
.crt__lines {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
background:
repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 0) 2px,
rgba(0, 0, 0, .28) 3px,
rgba(0, 0, 0, .28) 4px
),
linear-gradient(
to bottom,
rgba(255, 255, 255, .06),
rgba(255, 255, 255, 0) 8%
);
background-size: 100% 100%, 100% 50px;
animation: crt-scan 6s linear infinite;
}
/* ビネット+上下の暗がり */
.crt__vignette {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
background: radial-gradient(120% 120% at 50% 50%, transparent 55%, rgba(0, 0, 0, .55) 100%);
}
/* ノイズcanvas(薄く重ねる) */
.crt__noise {
position: absolute;
inset: 0;
z-index: 3;
width: 100%;
height: 100%;
opacity: .12;
mix-blend-mode: overlay;
pointer-events: none;
}
/* VHS風OSD */
.crt__osd {
position: absolute;
inset: 12px 14px auto auto;
left: 14px;
z-index: 6;
display: flex;
justify-content: space-between;
font-family: "Courier New", ui-monospace, monospace;
font-weight: 700;
font-size: clamp(11px, 2.6vw, 15px);
letter-spacing: .18em;
color: #eafff0;
text-shadow: 0 0 6px rgba(120, 255, 170, .8), 0 1px 2px #000;
pointer-events: none;
}
.crt__rec { display: inline-flex; align-items: center; gap: 6px; }
.crt__rec i {
width: 9px;
height: 9px;
border-radius: 50%;
background: #ff3b3b;
box-shadow: 0 0 8px #ff3b3b;
animation: crt-blink 1.2s steps(1) infinite;
}
/* --- アニメーション --- */
@keyframes crt-scan { to { background-position: 0 0, 0 50px; } }
/* RGBずれ(左右に小さく揺れる) */
@keyframes crt-shift-r {
0%, 100% { transform: translateX(-1.5px); }
50% { transform: translateX(1.5px); }
}
@keyframes crt-shift-b {
0%, 100% { transform: translateX(1.5px); }
50% { transform: translateX(-1.5px); }
}
/* 微振動:たまにガクッと画面が縦ずれするVHSトラッキング風 */
@keyframes crt-jitter {
0%, 92%, 100% { transform: translateY(0); }
93% { transform: translateY(-1px) skewX(.3deg); }
95% { transform: translateY(2px); }
97% { transform: translateY(-1px); }
}
@keyframes crt-blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: .15; } }
/* 動きが苦手な人向けに静止(merge側で除去されるが単体表示の保険) */
@media (prefers-reduced-motion: reduce) {
.crt,
.crt__lines,
.crt__screen::before,
.crt__screen::after,
.crt__rec i { animation: none; }
.crt__screen::before { transform: translateX(-1.5px); }
.crt__screen::after { transform: translateX(1.5px); }
}
【JavaScript】
// CRT・VHSレトロ画面:色収差レイヤーの画像供給+canvasノイズ+安全なフォールバック
(() => {
const crt = document.querySelector(".crt");
const img = document.querySelector(".crt__img");
const noise = document.querySelector(".crt__noise");
if (!crt) return;
// フォールバック用のグラデ/パターン背景(取得失敗・CORS事故でも破綻させない)
const fallback =
"linear-gradient(135deg, #2a3a6a 0%, #6a2f7a 45%, #c2466b 100%)," +
"repeating-linear-gradient(45deg, rgba(255,255,255,.06) 0 10px, transparent 10px 22px)";
// 画像URLを CSS変数へ流し込む(::before/::after の色収差レイヤーが参照)
const applySrc = (cssBg) => {
crt.style.setProperty("--crt-src", cssBg);
};
// まずフォールバックを敷く(画像が来る前から見える)
applySrc(fallback);
if (img) img.style.background = fallback;
// picsum 画像を CORS 安全に読み込み、成功したら本画像を採用
if (img && img.getAttribute("src")) {
const probe = new Image();
probe.crossOrigin = "anonymous";
probe.onload = () => {
const url = `url("${img.src}")`;
applySrc(url); // 色収差レイヤーも本画像に
img.style.background = "none"; // <img>本体はそのまま表示
};
probe.onerror = () => {
// 失敗時はフォールバックを画像にも適用(imgは透過させる)
img.removeAttribute("src");
img.style.background = fallback;
img.style.backgroundSize = "cover";
};
probe.src = img.src;
}
// --- canvasノイズ(毎フレーム軽量生成) ---
const ctx = noise && noise.getContext ? noise.getContext("2d") : null;
let raf = 0;
let running = false;
const drawNoise = () => {
if (!ctx) return;
const w = noise.width, h = noise.height;
const imageData = ctx.createImageData(w, h);
const buf = imageData.data;
// モノクロのランダムノイズを敷き詰める
for (let i = 0; i < buf.length; i += 4) {
const v = (Math.random() * 255) | 0;
buf[i] = buf[i + 1] = buf[i + 2] = v;
buf[i + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
};
// 30fps程度に間引いてループ(負荷軽減)
let last = 0;
const loop = (now) => {
if (!running) return;
if (now - last > 33) { drawNoise(); last = now; }
raf = requestAnimationFrame(loop);
};
const start = () => {
if (running || !ctx) return;
running = true;
raf = requestAnimationFrame(loop);
};
const stop = () => {
running = false;
if (raf) cancelAnimationFrame(raf);
raf = 0;
};
// タブ非表示で停止/復帰で再開
document.addEventListener("visibilitychange", () => {
document.hidden ? stop() : start();
});
// 初期ノイズを1枚描いてからループ開始
drawNoise();
start();
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。