css 画像 フィルター 9パターン — 値ひとつで色が変わる
css 画像 フィルターは、値ひとつで印象が大きく変わります。フィルターは写真を台無しにするものだと思っていた — 値ひとつを触るまでは。1番は色の混合値で二色調を立て、5番はノイズの値でフィルムグレインを散らし、9番はカーソルが乗った瞬間その彩度の値を戻す。
01 filter-duotone-colormap
02 filter-chromatic-split-glitch
03 filter-halftone-dot-print
04 filter-frosted-glass-noise
05 filter-vintage-film-grain
06 filter-thermal-heatmap-tone
07 filter-chromatic-edge-aberration
08 filter-dominant-color-swatch
09 filter-grayscale-hover-reveal
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 二色マッピング値で立てる二色調
- 02 チャンネルずれ距離値で裂けるグリッチ
- 03 点サイズ値が印刷質感を作る網点フィルター
- 04 ぼかし値とノイズ値が重なる磨りガラスフィルター
- 05 ノイズ強度値が散らすヴィンテージフィルムグレイン
- 06 明るさ順の値を色に変える熱画像トーン
- 07 縁の値が色収差を作るレンズフィルター
- 08 ピクセル値を読み取る代表色スウォッチ
- 09 彩度の値が戻るモノクロホバーリビール
9つとも同じ手続き的な場面から始まります。buildPhoto()が空とグラデーション太陽、いくつかの図形をseedRand()(シード固定の疑似乱数)で描くだけなので、実在する写真は一枚も使っていません。並び順はそのフレームをどれだけ塗り替えたかの多い順で、1番の二色調(73.1%)が先頭、8番の代表色スウォッチ(6.7%)が最後です。
01二色マッピング値で立てる二色調
1番は各ピクセルの明るさを暗い色と明るい色の間の一点にマッピングし、写真全体をブランドの二色に寄せます。マーケティングLPのヒーロー画像で、写真の色味をブランドカラーに合わせたいときに向きます。この値はDUOTONE_MIX_PERCENTで、キャンバスを1周する毎フレームのループの中、ピクセルごとにmapDuotone()が読み取ります。

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<input class="vd__range" id="vdRange" type="range" min="0" max="100" value="30" aria-label="duotone mix">
<p class="vd__caption">2色マッピング値で作るデュオトーン</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__range {
position: absolute;
left: $sp-3;
right: $sp-3;
top: $sp-3;
margin: 0;
accent-color: $subject-blue;
cursor: pointer;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__range {
position: absolute;
left: 12px;
right: 12px;
top: 12px;
margin: 0;
accent-color: #2f6df6;
cursor: pointer;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var range = document.getElementById('vdRange');
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var DUOTONE_MIX_PERCENT = 30;
var DUOTONE_DARK = [10, 79, 232];
var DUOTONE_LIGHT = [255, 210, 63];
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 414011);
var baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
var baseBuf = baseImg.data;
var out = document.createElement('canvas');
out.width = CALC_W; out.height = CALC_H;
var outCtx = out.getContext('2d');
var outImg = outCtx.createImageData(CALC_W, CALC_H);
var outBuf = outImg.data;
function mapDuotone(lum, mix) {
var t = mix / 100;
var dr = DUOTONE_DARK[0] + (DUOTONE_LIGHT[0] - DUOTONE_DARK[0]) * lum;
var dg = DUOTONE_DARK[1] + (DUOTONE_LIGHT[1] - DUOTONE_DARK[1]) * lum;
var db = DUOTONE_DARK[2] + (DUOTONE_LIGHT[2] - DUOTONE_DARK[2]) * lum;
return [dr, dg, db, t];
}
var userMix = DUOTONE_MIX_PERCENT;
if (range) {
range.value = String(userMix);
range.addEventListener('input', function () { userMix = Number(range.value); });
}
function renderFrame(t) {
var wave = Math.sin(t * Math.PI * 4 / LOOP_MS);
var mix = Math.max(0, Math.min(100, userMix + wave * 25));
var idx = 0;
for (var i = 0; i < CALC_W * CALC_H; i += 1) {
var r = baseBuf[idx], g = baseBuf[idx + 1], b = baseBuf[idx + 2];
var lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
var dc = mapDuotone(lum, mix);
outBuf[idx] = r + (dc[0] - r) * dc[3];
outBuf[idx + 1] = g + (dc[1] - g) * dc[3];
outBuf[idx + 2] = b + (dc[2] - b) * dc[3];
outBuf[idx + 3] = 255;
idx += 4;
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterDuotoneColormap.module.scss";
export default function FilterDuotoneColormap() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const rangeRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const range = rangeRef.current;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const DUOTONE_MIX_PERCENT = 30;
const DUOTONE_DARK = [10, 79, 232];
const DUOTONE_LIGHT = [255, 210, 63];
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
drawPhotoBase(octx, CALC_W, CALC_H, 414011);
const baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
const baseBuf = baseImg.data;
const out = document.createElement("canvas");
out.width = CALC_W; out.height = CALC_H;
const outCtx = out.getContext("2d") as CanvasRenderingContext2D;
const outImg = outCtx.createImageData(CALC_W, CALC_H);
const outBuf = outImg.data;
function mapDuotone(lum: number, mix: number): [number, number, number, number] {
const t = mix / 100;
const dr = DUOTONE_DARK[0] + (DUOTONE_LIGHT[0] - DUOTONE_DARK[0]) * lum;
const dg = DUOTONE_DARK[1] + (DUOTONE_LIGHT[1] - DUOTONE_DARK[1]) * lum;
const db = DUOTONE_DARK[2] + (DUOTONE_LIGHT[2] - DUOTONE_DARK[2]) * lum;
return [dr, dg, db, t];
}
let userMix = DUOTONE_MIX_PERCENT;
if (range) {
range.value = String(userMix);
range.addEventListener("input", function () { userMix = Number(range.value); });
}
function renderFrame(t: number) {
const wave = Math.sin(t * Math.PI * 4 / LOOP_MS);
const mix = Math.max(0, Math.min(100, userMix + wave * 25));
let idx = 0;
for (let i = 0; i < CALC_W * CALC_H; i += 1) {
const r = baseBuf[idx], g = baseBuf[idx + 1], b = baseBuf[idx + 2];
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
const dc = mapDuotone(lum, mix);
outBuf[idx] = r + (dc[0] - r) * dc[3];
outBuf[idx + 1] = g + (dc[1] - g) * dc[3];
outBuf[idx + 2] = b + (dc[2] - b) * dc[3];
outBuf[idx + 3] = 255;
idx += 4;
}
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx!.imageSmoothingEnabled = true;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<input className={styles.vd__range} ref={rangeRef} type="range" min={0} max={100} defaultValue={30} aria-label="duotone mix" />
<p className={styles.vd__caption}>두 색 매핑 값으로 세우는 듀오톤</p>
</div>
</main>
);
}01 · 二色マッピング値で立てる二色調 — 計測 累積面積 73.067% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function mapDuotone(lum, mix) {
var t = mix / 100;
var dr = DUOTONE_DARK[0] + (DUOTONE_LIGHT[0] - DUOTONE_DARK[0]) * lum;
var dg = DUOTONE_DARK[1] + (DUOTONE_LIGHT[1] - DUOTONE_DARK[1]) * lum;
var db = DUOTONE_DARK[2] + (DUOTONE_LIGHT[2] - DUOTONE_DARK[2]) * lum;
return [dr, dg, db, t];
}
var userMix = DUOTONE_MIX_PERCENT;
if (range) {
range.value = String(userMix);
range.addEventListener('input', function () { userMix = Number(range.value); });
}
面積73.1%・強度21.4・動いたフレームは16/23です。
02チャンネルずれ距離値で裂けるグリッチ
2番は赤・緑・青のチャンネルをそれぞれ違う方向へ少しずつずらし、ずれ距離値が大きくなるほど色が大きく離れて壊れたように見えます。プロモーションバナーのホバーで、一瞬だけ視線を引く壊れ方として使えます。CHANNEL_OFFSET_PXがshiftChannels()の中で赤チャンネルと青チャンネルをどれだけ逆方向へ動かすかを決め、元のバッファから毎回サンプリングします。

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">チャンネルずれ距離の値で裂けるグリッチ</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var CHANNEL_OFFSET_PX = 6;
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 414021);
var baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
var baseBuf = baseImg.data;
var out = document.createElement('canvas');
out.width = CALC_W; out.height = CALC_H;
var outCtx = out.getContext('2d');
var outImg = outCtx.createImageData(CALC_W, CALC_H);
var outBuf = outImg.data;
function clampIdx(v, max) { return v < 0 ? 0 : (v >= max ? max - 1 : v); }
function sampleBase(x, y, ch) {
var xi = clampIdx(x, CALC_W), yi = clampIdx(y, CALC_H);
return baseBuf[(yi * CALC_W + xi) * 4 + ch];
}
function shiftChannels(offset) {
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
outBuf[idx] = sampleBase(x - offset, y, 0);
outBuf[idx + 1] = sampleBase(x, y, 1);
outBuf[idx + 2] = sampleBase(x + offset, y, 2);
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
function renderFrame(t) {
var wave = Math.abs(Math.sin(t * Math.PI * 4 / LOOP_MS));
var offset = Math.round(wave * CHANNEL_OFFSET_PX);
shiftChannels(offset);
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterChromaticSplitGlitch.module.scss";
export default function FilterChromaticSplitGlitch() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const CHANNEL_OFFSET_PX = 6;
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
drawPhotoBase(octx, CALC_W, CALC_H, 414021);
const baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
const baseBuf = baseImg.data;
const out = document.createElement("canvas");
out.width = CALC_W; out.height = CALC_H;
const outCtx = out.getContext("2d") as CanvasRenderingContext2D;
const outImg = outCtx.createImageData(CALC_W, CALC_H);
const outBuf = outImg.data;
function clampIdx(v: number, max: number) { return v < 0 ? 0 : (v >= max ? max - 1 : v); }
function sampleBase(x: number, y: number, ch: number) {
const xi = clampIdx(x, CALC_W), yi = clampIdx(y, CALC_H);
return baseBuf[(yi * CALC_W + xi) * 4 + ch];
}
function shiftChannels(offset: number) {
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
outBuf[idx] = sampleBase(x - offset, y, 0);
outBuf[idx + 1] = sampleBase(x, y, 1);
outBuf[idx + 2] = sampleBase(x + offset, y, 2);
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
function renderFrame(t: number) {
const wave = Math.abs(Math.sin(t * Math.PI * 4 / LOOP_MS));
const offset = Math.round(wave * CHANNEL_OFFSET_PX);
shiftChannels(offset);
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx!.imageSmoothingEnabled = true;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>채널 어긋남 거리 값으로 갈라지는 글리치</p>
</div>
</main>
);
}02 · チャンネルずれ距離値で裂けるグリッチ — 計測 累積面積 14.143% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function shiftChannels(offset) {
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
outBuf[idx] = sampleBase(x - offset, y, 0);
outBuf[idx + 1] = sampleBase(x, y, 1);
outBuf[idx + 2] = sampleBase(x + offset, y, 2);
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
function renderFrame(t) {
var wave = Math.abs(Math.sin(t * Math.PI * 4 / LOOP_MS));
var offset = Math.round(wave * CHANNEL_OFFSET_PX);
shiftChannels(offset);
}
面積14.1%・強度37.5(9つの中で面積20%未満では最も高い値)・動いたフレームは22/23です。
03点サイズ値が印刷質感を作る網点フィルター
3番は写真を明るさに応じた大きさの点の格子に置き換え、HALFTONE_DOT_SIZE_PXが大きいほど昔の新聞印刷のような質感になります。レトロなレイアウトが欲しい印刷物風のポスターカードに向いています。sampleBrightness()が格子の各マスの明るさを読み取り、drawHalftoneDot()がそれを円の半径に変換していて、1番と同じくこの値もドラッグできるレンジ入力として画面に出ています。

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<input class="vd__range" id="vdRange" type="range" min="3" max="12" value="4" aria-label="dot size">
<p class="vd__caption">ドットサイズの値が印刷の質感を作る網点フィルター</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__range {
position: absolute;
left: $sp-3;
right: $sp-3;
top: $sp-3;
margin: 0;
accent-color: $subject-blue;
cursor: pointer;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
.vd__range {
position: absolute;
left: 12px;
right: 12px;
top: 12px;
margin: 0;
accent-color: #2f6df6;
cursor: pointer;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var range = document.getElementById('vdRange');
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var HALFTONE_DOT_SIZE_PX = 4;
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 414031);
var baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
var baseBuf = baseImg.data;
function sampleBrightness(xCalc, yCalc) {
var xi = Math.max(0, Math.min(CALC_W - 1, Math.round(xCalc)));
var yi = Math.max(0, Math.min(CALC_H - 1, Math.round(yCalc)));
var idx = (yi * CALC_W + xi) * 4;
return (0.299 * baseBuf[idx] + 0.587 * baseBuf[idx + 1] + 0.114 * baseBuf[idx + 2]) / 255;
}
function drawHalftoneDot(x, y, size, brightness) {
var radius = (1 - brightness) * size * 0.62;
if (radius < 0.4) return;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
var userDot = HALFTONE_DOT_SIZE_PX;
if (range) {
range.value = String(userDot);
range.addEventListener('input', function () { userDot = Number(range.value); });
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
var wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
var dot = Math.max(3, userDot + wave * 3);
var scaleX = CALC_W / cw, scaleY = CALC_H / ch;
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = '#fff7e6';
ctx.fillRect(0, 0, cw, ch);
ctx.fillStyle = '#17141a';
for (var y = dot / 2; y < ch; y += dot) {
for (var x = dot / 2; x < cw; x += dot) {
drawHalftoneDot(x, y, dot, sampleBrightness(x * scaleX, y * scaleY));
}
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterHalftoneDotPrint.module.scss";
export default function FilterHalftoneDotPrint() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const rangeRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const range = rangeRef.current;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const HALFTONE_DOT_SIZE_PX = 4;
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
drawPhotoBase(octx, CALC_W, CALC_H, 414031);
const baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
const baseBuf = baseImg.data;
function sampleBrightness(xCalc: number, yCalc: number) {
const xi = Math.max(0, Math.min(CALC_W - 1, Math.round(xCalc)));
const yi = Math.max(0, Math.min(CALC_H - 1, Math.round(yCalc)));
const idx = (yi * CALC_W + xi) * 4;
return (0.299 * baseBuf[idx] + 0.587 * baseBuf[idx + 1] + 0.114 * baseBuf[idx + 2]) / 255;
}
function drawHalftoneDot(x: number, y: number, size: number, brightness: number) {
const radius = (1 - brightness) * size * 0.62;
if (radius < 0.4) return;
ctx!.beginPath();
ctx!.arc(x, y, radius, 0, Math.PI * 2);
ctx!.fill();
}
let userDot = HALFTONE_DOT_SIZE_PX;
if (range) {
range.value = String(userDot);
range.addEventListener("input", function () { userDot = Number(range.value); });
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
const wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
const dot = Math.max(3, userDot + wave * 3);
const scaleX = CALC_W / cw, scaleY = CALC_H / ch;
ctx!.clearRect(0, 0, cw, ch);
ctx!.fillStyle = "#fff7e6";
ctx!.fillRect(0, 0, cw, ch);
ctx!.fillStyle = "#17141a";
for (let y = dot / 2; y < ch; y += dot) {
for (let x = dot / 2; x < cw; x += dot) {
drawHalftoneDot(x, y, dot, sampleBrightness(x * scaleX, y * scaleY));
}
}
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<input className={styles.vd__range} ref={rangeRef} type="range" min={3} max={12} defaultValue={4} aria-label="dot size" />
<p className={styles.vd__caption}>점 크기 값이 인쇄 질감을 만드는 망점 필터</p>
</div>
</main>
);
}03 · 点サイズ値が印刷質感を作る網点フィルター — 計測 累積面積 62.988% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function sampleBrightness(xCalc, yCalc) {
var xi = Math.max(0, Math.min(CALC_W - 1, Math.round(xCalc)));
var yi = Math.max(0, Math.min(CALC_H - 1, Math.round(yCalc)));
var idx = (yi * CALC_W + xi) * 4;
return (0.299 * baseBuf[idx] + 0.587 * baseBuf[idx + 1] + 0.114 * baseBuf[idx + 2]) / 255;
}
function drawHalftoneDot(x, y, size, brightness) {
var radius = (1 - brightness) * size * 0.62;
if (radius < 0.4) return;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
面積63.0%・強度82.9(9つの中で最も高い値)・動いたフレームは18/23です。
04ぼかし値とノイズ値が重なる磨りガラスフィルター
4番は写真を大きくぼかした上に微細なノイズ値を重ね、奥が透ける磨りガラスの表面のように見せます。モーダル背後の背景画像として、パネルの向こうに何かがあると感じさせたいときに向いています。FROST_BLUR_PXが毎フレームのサインカーブで揺れる基準ぼかし半径で、drawNoiseOverlay()がそのぼかしを平坦に見せないためのノイズを加えます。

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">ぼかし値とノイズ値が重なるすりガラスフィルター</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var LOOP_MS = 2000;
var FROST_BLUR_PX = 8;
var NOISE_ALPHA = 0.05;
var photo = document.createElement('canvas');
var pctx = photo.getContext('2d');
function buildPhoto() {
photo.width = cw; photo.height = ch;
drawPhotoBase(pctx, cw, ch, 414041);
}
function drawNoiseOverlay(bucket) {
var rand = seedRand(414041 + bucket);
for (var i = 0; i < 90; i += 1) {
var x = rand() * cw, y = rand() * ch, s = rand() * 2 + 0.6;
ctx.fillStyle = 'rgba(255,255,255,' + (NOISE_ALPHA + rand() * 0.05) + ')';
ctx.fillRect(x, y, s, s);
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
buildPhoto();
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
var wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
var blur = Math.max(2, FROST_BLUR_PX + wave * 6);
ctx.clearRect(0, 0, cw, ch);
ctx.filter = 'blur(' + blur.toFixed(1) + 'px)';
ctx.drawImage(photo, 0, 0, cw, ch);
ctx.filter = 'none';
drawNoiseOverlay(Math.floor(elapsed / 150));
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterFrostedGlassNoise.module.scss";
export default function FilterFrostedGlassNoise() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const LOOP_MS = 2000;
const FROST_BLUR_PX = 8;
const NOISE_ALPHA = 0.05;
const photo = document.createElement("canvas");
const pctx = photo.getContext("2d") as CanvasRenderingContext2D;
let cw = 480, ch = 300;
function buildPhoto() {
photo.width = cw; photo.height = ch;
drawPhotoBase(pctx, cw, ch, 414041);
}
function drawNoiseOverlay(bucket: number) {
const rand = seedRand(414041 + bucket);
for (let i = 0; i < 90; i += 1) {
const x = rand() * cw, y = rand() * ch, s = rand() * 2 + 0.6;
ctx!.fillStyle = "rgba(255,255,255," + (NOISE_ALPHA + rand() * 0.05) + ")";
ctx!.fillRect(x, y, s, s);
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
buildPhoto();
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
const wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
const blur = Math.max(2, FROST_BLUR_PX + wave * 6);
ctx!.clearRect(0, 0, cw, ch);
ctx!.filter = "blur(" + blur.toFixed(1) + "px)";
ctx!.drawImage(photo, 0, 0, cw, ch);
ctx!.filter = "none";
drawNoiseOverlay(Math.floor(elapsed / 150));
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>흐림 값과 노이즈 값이 겹치는 젖빛 유리 필터</p>
</div>
</main>
);
}04 · ぼかし値とノイズ値が重なる磨りガラスフィルター — 計測 累積面積 43.31% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function draw(elapsed) {
lastElapsed = elapsed;
var wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
var blur = Math.max(2, FROST_BLUR_PX + wave * 6);
ctx.clearRect(0, 0, cw, ch);
ctx.filter = 'blur(' + blur.toFixed(1) + 'px)';
ctx.drawImage(photo, 0, 0, cw, ch);
ctx.filter = 'none';
drawNoiseOverlay(Math.floor(elapsed / 150));
}
面積43.3%・強度19.0・動いたフレームは22/23です。
05ノイズ強度値が散らすヴィンテージフィルムグレイン
5番は写真の上に微細な白黒ノイズを重ね、GRAIN_STRENGTHの値が大きいほど古いフィルム写真の質感が濃くなります。エモいフォトギャラリーカードで、粒子感を傷ではなく温かみとして見せたいときに向きます。applyFilmGrain()はシード付き乱数から1ピクセルごとに1つのずれ値を取り出し、三つの色チャンネルへ均等に足すので、グレインが色づかず灰色のまま保たれます。

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">ノイズ強度の値が振りまくビンテージフィルムグレイン</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var GRAIN_STRENGTH = 18;
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 414051);
var baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
var baseBuf = baseImg.data;
var out = document.createElement('canvas');
out.width = CALC_W; out.height = CALC_H;
var outCtx = out.getContext('2d');
var outImg = outCtx.createImageData(CALC_W, CALC_H);
var outBuf = outImg.data;
function applyFilmGrain(strength, bucket) {
var grainRand = seedRand(414051 + bucket);
var idx = 0;
for (var i = 0; i < CALC_W * CALC_H; i += 1) {
var n = (grainRand() - 0.5) * 2 * strength;
outBuf[idx] = baseBuf[idx] + n;
outBuf[idx + 1] = baseBuf[idx + 1] + n;
outBuf[idx + 2] = baseBuf[idx + 2] + n;
outBuf[idx + 3] = 255;
idx += 4;
}
}
function renderFrame(elapsed) {
var wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
var strength = Math.max(4, GRAIN_STRENGTH + wave * 14);
var bucket = Math.floor(elapsed / 90);
applyFilmGrain(strength, bucket);
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterVintageFilmGrain.module.scss";
export default function FilterVintageFilmGrain() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const GRAIN_STRENGTH = 18;
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
drawPhotoBase(octx, CALC_W, CALC_H, 414051);
const baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
const baseBuf = baseImg.data;
const out = document.createElement("canvas");
out.width = CALC_W; out.height = CALC_H;
const outCtx = out.getContext("2d") as CanvasRenderingContext2D;
const outImg = outCtx.createImageData(CALC_W, CALC_H);
const outBuf = outImg.data;
function applyFilmGrain(strength: number, bucket: number) {
const grainRand = seedRand(414051 + bucket);
let idx = 0;
for (let i = 0; i < CALC_W * CALC_H; i += 1) {
const n = (grainRand() - 0.5) * 2 * strength;
outBuf[idx] = baseBuf[idx] + n;
outBuf[idx + 1] = baseBuf[idx + 1] + n;
outBuf[idx + 2] = baseBuf[idx + 2] + n;
outBuf[idx + 3] = 255;
idx += 4;
}
}
function renderFrame(elapsed: number) {
const wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
const strength = Math.max(4, GRAIN_STRENGTH + wave * 14);
const bucket = Math.floor(elapsed / 90);
applyFilmGrain(strength, bucket);
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx!.imageSmoothingEnabled = true;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>노이즈 강도 값이 흩뿌리는 빈티지 필름 그레인</p>
</div>
</main>
);
}05 · ノイズ強度値が散らすヴィンテージフィルムグレイン — 計測 累積面積 68.114% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function applyFilmGrain(strength, bucket) {
var grainRand = seedRand(414051 + bucket);
var idx = 0;
for (var i = 0; i < CALC_W * CALC_H; i += 1) {
var n = (grainRand() - 0.5) * 2 * strength;
outBuf[idx] = baseBuf[idx] + n;
outBuf[idx + 1] = baseBuf[idx + 1] + n;
outBuf[idx + 2] = baseBuf[idx + 2] + n;
outBuf[idx + 3] = 255;
idx += 4;
}
}
function renderFrame(elapsed) {
var wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
var strength = Math.max(4, GRAIN_STRENGTH + wave * 14);
var bucket = Math.floor(elapsed / 90);
applyFilmGrain(strength, bucket);
}
面積68.1%・強度17.7(9つの中で面積60%を超える項目では最も低い値)・動いたフレームは20/23です。
06明るさ順の値を色に変える熱画像トーン
6番は暗い部分から明るい部分まで順に青から赤へ色をマッピングし、サーモカメラで撮ったように見せます。データ可視化風フォトカードに使えるアイデア段階の項目です。THERMAL_STOPSがthermalColor()の補間元になる5色のグラデーション表で、THERMAL_SHIFTが毎フレーム全ピクセルの参照位置を一緒にずらします。

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">明るさの順位値を色に変える熱画像トーン</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var THERMAL_SHIFT = 0;
var THERMAL_STOPS = [[10, 20, 90], [10, 90, 200], [40, 200, 120], [240, 210, 40], [230, 50, 20]];
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 414061);
var baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
var baseBuf = baseImg.data;
var out = document.createElement('canvas');
out.width = CALC_W; out.height = CALC_H;
var outCtx = out.getContext('2d');
var outImg = outCtx.createImageData(CALC_W, CALC_H);
var outBuf = outImg.data;
function thermalColor(v) {
var t = Math.max(0, Math.min(1, v)) * (THERMAL_STOPS.length - 1);
var i0 = Math.floor(t), i1 = Math.min(THERMAL_STOPS.length - 1, i0 + 1);
var f = t - i0;
var a = THERMAL_STOPS[i0], b = THERMAL_STOPS[i1];
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
}
function renderFrame(elapsed) {
var wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
var shift = THERMAL_SHIFT + wave * 0.28;
var idx = 0;
for (var i = 0; i < CALC_W * CALC_H; i += 1) {
var lum = (0.299 * baseBuf[idx] + 0.587 * baseBuf[idx + 1] + 0.114 * baseBuf[idx + 2]) / 255;
var c = thermalColor(lum + shift);
outBuf[idx] = c[0]; outBuf[idx + 1] = c[1]; outBuf[idx + 2] = c[2]; outBuf[idx + 3] = 255;
idx += 4;
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterThermalHeatmapTone.module.scss";
export default function FilterThermalHeatmapTone() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const THERMAL_SHIFT = 0;
const THERMAL_STOPS: number[][] = [[10, 20, 90], [10, 90, 200], [40, 200, 120], [240, 210, 40], [230, 50, 20]];
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
drawPhotoBase(octx, CALC_W, CALC_H, 414061);
const baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
const baseBuf = baseImg.data;
const out = document.createElement("canvas");
out.width = CALC_W; out.height = CALC_H;
const outCtx = out.getContext("2d") as CanvasRenderingContext2D;
const outImg = outCtx.createImageData(CALC_W, CALC_H);
const outBuf = outImg.data;
function thermalColor(v: number): [number, number, number] {
const t = Math.max(0, Math.min(1, v)) * (THERMAL_STOPS.length - 1);
const i0 = Math.floor(t), i1 = Math.min(THERMAL_STOPS.length - 1, i0 + 1);
const f = t - i0;
const a = THERMAL_STOPS[i0], b = THERMAL_STOPS[i1];
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
}
function renderFrame(elapsed: number) {
const wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
const shift = THERMAL_SHIFT + wave * 0.28;
let idx = 0;
for (let i = 0; i < CALC_W * CALC_H; i += 1) {
const lum = (0.299 * baseBuf[idx] + 0.587 * baseBuf[idx + 1] + 0.114 * baseBuf[idx + 2]) / 255;
const c = thermalColor(lum + shift);
outBuf[idx] = c[0]; outBuf[idx + 1] = c[1]; outBuf[idx + 2] = c[2]; outBuf[idx + 3] = 255;
idx += 4;
}
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx!.imageSmoothingEnabled = true;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>밝기 순서 값을 색으로 바꾸는 열화상 톤</p>
</div>
</main>
);
}06 · 明るさ順の値を色に変える熱画像トーン — 計測 累積面積 75.314% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function thermalColor(v) {
var t = Math.max(0, Math.min(1, v)) * (THERMAL_STOPS.length - 1);
var i0 = Math.floor(t), i1 = Math.min(THERMAL_STOPS.length - 1, i0 + 1);
var f = t - i0;
var a = THERMAL_STOPS[i0], b = THERMAL_STOPS[i1];
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
}
function renderFrame(elapsed) {
var wave = Math.sin(elapsed * Math.PI * 4 / LOOP_MS);
var shift = THERMAL_SHIFT + wave * 0.28;
var idx = 0;
for (var i = 0; i < CALC_W * CALC_H; i += 1) {
var lum = (0.299 * baseBuf[idx] + 0.587 * baseBuf[idx + 1] + 0.114 * baseBuf[idx + 2]) / 255;
var c = thermalColor(lum + shift);
outBuf[idx] = c[0]; outBuf[idx + 1] = c[1]; outBuf[idx + 2] = c[2]; outBuf[idx + 3] = 255;
idx += 4;
}
}
面積75.3%(9つの中で最も高い値)・強度72.9・動いたフレームは20/23です。
07縁の値が色収差を作るレンズフィルター
7番は画像の縁に近づくほどチャンネルを少しずつずらし、EDGE_STRENGTH_PXの値が大きいほど中心から離れた場所の色縁が濃くなり、中央はきれいなまま残ります。レンズ風のプロフィール画像に使えるアイデア段階の項目です。distFromCenter()が各ピクセルの位置を0〜1の距離に変換し、renderFrame()がその距離に縁の値を掛けて実際のチャンネルずれ幅を求めます。

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">縁の値が色収差を作るレンズフィルター</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var EDGE_STRENGTH_PX = 5;
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
drawPhotoBase(octx, CALC_W, CALC_H, 414071);
var baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
var baseBuf = baseImg.data;
var out = document.createElement('canvas');
out.width = CALC_W; out.height = CALC_H;
var outCtx = out.getContext('2d');
var outImg = outCtx.createImageData(CALC_W, CALC_H);
var outBuf = outImg.data;
function clampIdx(v, max) { return v < 0 ? 0 : (v >= max ? max - 1 : v); }
function sampleBase(x, y, ch) {
var xi = clampIdx(x, CALC_W), yi = clampIdx(y, CALC_H);
return baseBuf[(yi * CALC_W + xi) * 4 + ch];
}
function distFromCenter(x, y) {
var cx = CALC_W / 2, cy = CALC_H / 2;
var dx = (x - cx) / cx, dy = (y - cy) / cy;
return Math.min(1, Math.sqrt(dx * dx + dy * dy));
}
function renderFrame(elapsed) {
var wave = Math.abs(Math.sin(elapsed * Math.PI * 4 / LOOP_MS));
var strength = EDGE_STRENGTH_PX * wave;
var cx = CALC_W / 2, cy = CALC_H / 2;
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var ux = (x - cx) / cx, uy = (y - cy) / cy;
var len = Math.sqrt(ux * ux + uy * uy) || 1;
var d = distFromCenter(x, y);
var off2 = strength * d;
var offX = Math.round((ux / len) * off2), offY = Math.round((uy / len) * off2);
outBuf[idx] = sampleBase(x - offX, y - offY, 0);
outBuf[idx + 1] = sampleBase(x, y, 1);
outBuf[idx + 2] = sampleBase(x + offX, y + offY, 2);
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterChromaticEdgeAberration.module.scss";
export default function FilterChromaticEdgeAberration() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const EDGE_STRENGTH_PX = 5;
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
drawPhotoBase(octx, CALC_W, CALC_H, 414071);
const baseImg = octx.getImageData(0, 0, CALC_W, CALC_H);
const baseBuf = baseImg.data;
const out = document.createElement("canvas");
out.width = CALC_W; out.height = CALC_H;
const outCtx = out.getContext("2d") as CanvasRenderingContext2D;
const outImg = outCtx.createImageData(CALC_W, CALC_H);
const outBuf = outImg.data;
function clampIdx(v: number, max: number) { return v < 0 ? 0 : (v >= max ? max - 1 : v); }
function sampleBase(x: number, y: number, ch: number) {
const xi = clampIdx(x, CALC_W), yi = clampIdx(y, CALC_H);
return baseBuf[(yi * CALC_W + xi) * 4 + ch];
}
function distFromCenter(x: number, y: number) {
const cx = CALC_W / 2, cy = CALC_H / 2;
const dx = (x - cx) / cx, dy = (y - cy) / cy;
return Math.min(1, Math.sqrt(dx * dx + dy * dy));
}
function renderFrame(elapsed: number) {
const wave = Math.abs(Math.sin(elapsed * Math.PI * 4 / LOOP_MS));
const strength = EDGE_STRENGTH_PX * wave;
const cx = CALC_W / 2, cy = CALC_H / 2;
let idx = 0;
for (let y = 0; y < CALC_H; y += 1) {
for (let x = 0; x < CALC_W; x += 1) {
const ux = (x - cx) / cx, uy = (y - cy) / cy;
const len = Math.sqrt(ux * ux + uy * uy) || 1;
const d = distFromCenter(x, y);
const off2 = strength * d;
const offX = Math.round((ux / len) * off2), offY = Math.round((uy / len) * off2);
outBuf[idx] = sampleBase(x - offX, y - offY, 0);
outBuf[idx + 1] = sampleBase(x, y, 1);
outBuf[idx + 2] = sampleBase(x + offX, y + offY, 2);
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
let cw = 480, ch = 300;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
renderFrame(elapsed);
outCtx.putImageData(outImg, 0, 0);
ctx!.imageSmoothingEnabled = true;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(out, 0, 0, CALC_W, CALC_H, 0, 0, cw, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>가장자리 값이 색수차를 만드는 렌즈 필터</p>
</div>
</main>
);
}07 · 縁の値が色収差を作るレンズフィルター — 計測 累積面積 13.646% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function renderFrame(elapsed) {
var wave = Math.abs(Math.sin(elapsed * Math.PI * 4 / LOOP_MS));
var strength = EDGE_STRENGTH_PX * wave;
var cx = CALC_W / 2, cy = CALC_H / 2;
var idx = 0;
for (var y = 0; y < CALC_H; y += 1) {
for (var x = 0; x < CALC_W; x += 1) {
var ux = (x - cx) / cx, uy = (y - cy) / cy;
var len = Math.sqrt(ux * ux + uy * uy) || 1;
var d = distFromCenter(x, y);
var off2 = strength * d;
var offX = Math.round((ux / len) * off2), offY = Math.round((uy / len) * off2);
outBuf[idx] = sampleBase(x - offX, y - offY, 0);
outBuf[idx + 1] = sampleBase(x, y, 1);
outBuf[idx + 2] = sampleBase(x + offX, y + offY, 2);
outBuf[idx + 3] = 255;
idx += 4;
}
}
}
面積13.6%(9つの中で最も小さい値)・強度37.3・動いたフレームは23/23で、9つの中で唯一すべてのフレームが動いた項目です。
08ピクセル値を読み取る代表色スウォッチ
8番は全ピクセルの値を走査して最も多い色を計算し、その色をそのままカード背景に使うので、写真の色と衝突しません。商品カード背景の同期という発想の元になった項目です。computeDominantColor()は各チャンネルの上位4ビットでピクセルをバケットに分け(近い色をまとめる簡易な方法)、最もピクセル数が多いバケットを読み戻します。

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">ピクセル値を読み取って抽出する代表色スウォッチ</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
cursor: pointer;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var CALC_W = 160, CALC_H = 100;
var LOOP_MS = 2000;
var PHOTO_SEEDS = [414081, 414082, 414083];
var off = document.createElement('canvas');
off.width = CALC_W; off.height = CALC_H;
var octx = off.getContext('2d');
var photoIndex = 0;
var baseBuf = null;
var dominant = '#2f6df6';
function computeDominantColor() {
var bins = {};
var idx = 0;
for (var i = 0; i < CALC_W * CALC_H; i += 1) {
var r = baseBuf[idx] >> 4, g = baseBuf[idx + 1] >> 4, b = baseBuf[idx + 2] >> 4;
var key = r + '-' + g + '-' + b;
bins[key] = (bins[key] || 0) + 1;
idx += 4;
}
var bestKey = null, bestCount = -1;
for (var k in bins) { if (bins[k] > bestCount) { bestCount = bins[k]; bestKey = k; } }
var parts = bestKey.split('-');
dominant = 'rgb(' + (parseInt(parts[0], 10) * 16 + 8) + ',' + (parseInt(parts[1], 10) * 16 + 8) + ',' + (parseInt(parts[2], 10) * 16 + 8) + ')';
}
function renderPhoto(elapsed) {
var seedVariant = PHOTO_SEEDS[photoIndex] + Math.floor(elapsed / 45);
octx.clearRect(0, 0, CALC_W, CALC_H);
drawPhotoBase(octx, CALC_W, CALC_H, seedVariant);
baseBuf = octx.getImageData(0, 0, CALC_W, CALC_H).data;
computeDominantColor();
}
function cyclePhoto() { photoIndex = (photoIndex + 1) % PHOTO_SEEDS.length; }
canvas.addEventListener('click', function () { cyclePhoto(); draw(lastElapsed); });
var cw = 480, ch = 300;
var lastElapsed = 0;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
draw(lastElapsed);
}
function draw(elapsed) {
lastElapsed = elapsed;
renderPhoto(elapsed);
var photoW = Math.round(cw * 0.72);
ctx.imageSmoothingEnabled = true;
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, photoW, ch);
ctx.fillStyle = dominant;
ctx.fillRect(photoW, 0, cw - photoW, ch);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
var elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove('is-demo'); }
var lastTs = null, lastBucket = -1;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
var bucket = Math.floor(elapsed / (LOOP_MS / 2));
if (bucket !== lastBucket) { lastBucket = bucket; cyclePhoto(); }
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterDominantColorSwatch.module.scss";
export default function FilterDominantColorSwatch() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const CALC_W = 160, CALC_H = 100;
const LOOP_MS = 2000;
const PHOTO_SEEDS = [414081, 414082, 414083];
const off = document.createElement("canvas");
off.width = CALC_W; off.height = CALC_H;
const octx = off.getContext("2d") as CanvasRenderingContext2D;
let photoIndex = 0;
let baseBuf: Uint8ClampedArray | null = null;
let dominant = "#2f6df6";
function computeDominantColor() {
if (!baseBuf) return;
const bins: Record<string, number> = {};
let idx = 0;
for (let i = 0; i < CALC_W * CALC_H; i += 1) {
const r = baseBuf[idx] >> 4, g = baseBuf[idx + 1] >> 4, b = baseBuf[idx + 2] >> 4;
const key = r + "-" + g + "-" + b;
bins[key] = (bins[key] || 0) + 1;
idx += 4;
}
let bestKey: string | null = null, bestCount = -1;
for (const k in bins) { if (bins[k] > bestCount) { bestCount = bins[k]; bestKey = k; } }
if (!bestKey) return;
const parts = bestKey.split("-");
dominant = "rgb(" + (parseInt(parts[0], 10) * 16 + 8) + "," + (parseInt(parts[1], 10) * 16 + 8) + "," + (parseInt(parts[2], 10) * 16 + 8) + ")";
}
function renderPhoto(elapsed: number) {
const seedVariant = PHOTO_SEEDS[photoIndex] + Math.floor(elapsed / 45);
octx.clearRect(0, 0, CALC_W, CALC_H);
drawPhotoBase(octx, CALC_W, CALC_H, seedVariant);
baseBuf = octx.getImageData(0, 0, CALC_W, CALC_H).data;
computeDominantColor();
}
function cyclePhoto() { photoIndex = (photoIndex + 1) % PHOTO_SEEDS.length; }
canvas.addEventListener("click", function () { cyclePhoto(); draw(lastElapsed); });
let cw = 480, ch = 300;
let lastElapsed = 0;
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
draw(lastElapsed);
}
function draw(elapsed: number) {
lastElapsed = elapsed;
renderPhoto(elapsed);
const photoW = Math.round(cw * 0.72);
ctx!.imageSmoothingEnabled = true;
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(off, 0, 0, CALC_W, CALC_H, 0, 0, photoW, ch);
ctx!.fillStyle = dominant;
ctx!.fillRect(photoW, 0, cw - photoW, ch);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
let elapsed = reduce ? LOOP_MS * 0.5 : 0;
if (reduce) { root.classList.remove("is-demo"); }
let lastTs: number | null = null, lastBucket = -1;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
const bucket = Math.floor(elapsed / (LOOP_MS / 2));
if (bucket !== lastBucket) { lastBucket = bucket; cyclePhoto(); }
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>픽셀 값을 읽어 뽑는 대표 색 스와치</p>
</div>
</main>
);
}08 · ピクセル値を読み取る代表色スウォッチ — 計測 累積面積 6.676% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function computeDominantColor() {
var bins = {};
var idx = 0;
for (var i = 0; i < CALC_W * CALC_H; i += 1) {
var r = baseBuf[idx] >> 4, g = baseBuf[idx + 1] >> 4, b = baseBuf[idx + 2] >> 4;
var key = r + '-' + g + '-' + b;
bins[key] = (bins[key] || 0) + 1;
idx += 4;
}
var bestKey = null, bestCount = -1;
for (var k in bins) { if (bins[k] > bestCount) { bestCount = bins[k]; bestKey = k; } }
var parts = bestKey.split('-');
dominant = 'rgb(' + (parseInt(parts[0], 10) * 16 + 8) + ',' + (parseInt(parts[1], 10) * 16 + 8) + ',' + (parseInt(parts[2], 10) * 16 + 8) + ')';
}
面積6.7%(9つの中で最も小さい値)・強度19.6・動いたフレームは23/23です。
09彩度の値が戻るモノクロホバーリビール
9番は普段は彩度の値を0に下げて白黒にしておき、カーソルが乗るとその値を元に戻して色を取り戻します。商品グリッドのホバーで、色が注目への見返りのように感じられる場面向けです。値が変わるのはsetHoverState()だけで、CSSのfilter文字列をgrayscale(1) saturate(0)とgrayscale(0) saturate(1)の間で、GRAYSCALE_HOVER_MSが決める時間をかけて切り替えます。

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">彩度の値が戻ってくる白黒ホバーリビール</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx, w, h, seed) {
var rand = seedRand(seed);
var sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#8fc7ff');
sky.addColorStop(1, '#fff2d6');
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
var sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
var glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, 'rgba(255,247,230,.9)');
glow.addColorStop(1, 'rgba(255,247,230,0)');
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = '#ffe27a';
pctx.fill();
var hillColors = ['#6ec8ff', '#2b9d74', '#17141a'];
for (var i = 0; i < 3; i += 1) {
var baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
var step = Math.max(2, w / 24);
for (var x = 0; x <= w; x += step) {
var n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (var k = 0; k < 60; k += 1) {
var px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = 'rgba(255,255,255,' + (0.12 + rand() * 0.22) + ')';
pctx.fill();
}
}
var LOOP_MS = 2000;
var GRAYSCALE_HOVER_MS = 200;
var photo = document.createElement('canvas');
var pctx = photo.getContext('2d');
var cw = 480, ch = 300;
function buildPhoto() {
photo.width = cw; photo.height = ch;
drawPhotoBase(pctx, cw, ch, 414091);
draw();
}
function draw() {
ctx.clearRect(0, 0, cw, ch);
ctx.drawImage(photo, 0, 0);
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
buildPhoto();
}
canvas.style.transition = 'filter ' + GRAYSCALE_HOVER_MS + 'ms ease';
canvas.style.filter = 'grayscale(1) saturate(0)';
canvas.tabIndex = 0;
function setHoverState(on) {
canvas.style.filter = on ? 'grayscale(0) saturate(1)' : 'grayscale(1) saturate(0)';
}
canvas.addEventListener('pointerenter', function () { setHoverState(true); });
canvas.addEventListener('pointerleave', function () { setHoverState(false); });
canvas.addEventListener('focus', function () { setHoverState(true); });
canvas.addEventListener('blur', function () { setHoverState(false); });
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); setHoverState(true); }
var elapsed = 0, lastTs = null, lastBucket = -1;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
var bucket = Math.floor(elapsed / (LOOP_MS / 4));
if (bucket !== lastBucket) { lastBucket = bucket; setHoverState(bucket % 2 === 1); }
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./FilterGrayscaleHoverReveal.module.scss";
export default function FilterGrayscaleHoverReveal() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function drawPhotoBase(pctx: CanvasRenderingContext2D, w: number, h: number, seed: number) {
const rand = seedRand(seed);
const sky = pctx.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, "#8fc7ff");
sky.addColorStop(1, "#fff2d6");
pctx.fillStyle = sky;
pctx.fillRect(0, 0, w, h);
const sunX = w * 0.72, sunY = h * 0.3, sunR = h * 0.22;
const glow = pctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunR * 2.3);
glow.addColorStop(0, "rgba(255,247,230,.9)");
glow.addColorStop(1, "rgba(255,247,230,0)");
pctx.fillStyle = glow;
pctx.fillRect(0, 0, w, h);
pctx.beginPath();
pctx.arc(sunX, sunY, sunR, 0, Math.PI * 2);
pctx.fillStyle = "#ffe27a";
pctx.fill();
const hillColors = ["#6ec8ff", "#2b9d74", "#17141a"];
for (let i = 0; i < 3; i += 1) {
const baseY = h * (0.5 + i * 0.15);
pctx.beginPath();
pctx.moveTo(0, h);
const step = Math.max(2, w / 24);
for (let x = 0; x <= w; x += step) {
const n = Math.sin(x * 0.06 + i * 2.4 + seed * 0.0003) * h * 0.05 + Math.sin(x * 0.17 + i) * h * 0.02;
pctx.lineTo(x, baseY + n);
}
pctx.lineTo(w, h);
pctx.closePath();
pctx.fillStyle = hillColors[i];
pctx.fill();
}
for (let k = 0; k < 60; k += 1) {
const px = rand() * w, py = rand() * h * 0.55, r = rand() * 1.4 + 0.3;
pctx.beginPath();
pctx.arc(px, py, r, 0, Math.PI * 2);
pctx.fillStyle = "rgba(255,255,255," + (0.12 + rand() * 0.22) + ")";
pctx.fill();
}
}
const LOOP_MS = 2000;
const GRAYSCALE_HOVER_MS = 200;
const photo = document.createElement("canvas");
const pctx = photo.getContext("2d") as CanvasRenderingContext2D;
let cw = 480, ch = 300;
function draw() {
ctx!.clearRect(0, 0, cw, ch);
ctx!.drawImage(photo, 0, 0);
}
function buildPhoto() {
photo.width = cw; photo.height = ch;
drawPhotoBase(pctx, cw, ch, 414091);
draw();
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
buildPhoto();
}
canvas.style.transition = "filter " + GRAYSCALE_HOVER_MS + "ms ease";
canvas.style.filter = "grayscale(1) saturate(0)";
canvas.tabIndex = 0;
function setHoverState(on: boolean) {
canvas!.style.filter = on ? "grayscale(0) saturate(1)" : "grayscale(1) saturate(0)";
}
canvas.addEventListener("pointerenter", function () { setHoverState(true); });
canvas.addEventListener("pointerleave", function () { setHoverState(false); });
canvas.addEventListener("focus", function () { setHoverState(true); });
canvas.addEventListener("blur", function () { setHoverState(false); });
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); setHoverState(true); }
let elapsed = 0, lastTs: number | null = null, lastBucket = -1;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
const bucket = Math.floor(elapsed / (LOOP_MS / 4));
if (bucket !== lastBucket) { lastBucket = bucket; setHoverState(bucket % 2 === 1); }
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>채도 값이 돌아오는 흑백 호버 리빌</p>
</div>
</main>
);
}09 · 彩度の値が戻るモノクロホバーリビール — 計測 累積面積 58.465% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function setHoverState(on) {
canvas.style.filter = on ? 'grayscale(0) saturate(1)' : 'grayscale(1) saturate(0)';
}
canvas.addEventListener('pointerenter', function () { setHoverState(true); });
canvas.addEventListener('pointerleave', function () { setHoverState(false); });
canvas.addEventListener('focus', function () { setHoverState(true); });
canvas.addEventListener('blur', function () { setHoverState(false); });
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); setHoverState(true); }
面積58.5%を彩度の一往復で塗り替え・強度33.5・動いたフレームは10/23で、9つの中で最も少ない値です。毎フレームではなくホバーのたびに一度だけ変わるからです。
9枚のcss 画像 フィルターは、実際どれだけ塗り替えたか
| 項目 | 面積% | 強度 | 動いたフレーム |
|---|---|---|---|
| 01 duotone-colormap | 73.1 | 21.4 | 16/23 |
| 02 chromatic-split-glitch | 14.1 | 37.5 | 22/23 |
| 03 halftone-dot-print | 63.0 | 82.9 | 18/23 |
| 04 frosted-glass-noise | 43.3 | 19.0 | 22/23 |
| 05 vintage-film-grain | 68.1 | 17.7 | 20/23 |
| 06 thermal-heatmap-tone | 75.3 | 72.9 | 20/23 |
| 07 chromatic-edge-aberration | 13.6 | 37.3 | 23/23 |
| 08 dominant-color-swatch | 6.7 | 19.6 | 23/23 |
| 09 grayscale-hover-reveal | 58.5 | 33.5 | 10/23 |
3つのバリエーションとアクセシビリティ
| バリエーション | 値の変更 | 感じ方 |
|---|---|---|
| 二色調の混合比率値を調整する | 01 DUOTONE_MIX_PERCENT 30 → 70 |
値を小さくすると元の色がより多く残り控えめな二色調になり、値を大きくすると二色に完全に寄って、ずっと強い二色調に見えるはずです。 |
| 網点サイズ値を調整する | 03 HALFTONE_DOT_SIZE_PX 4 → 12 |
値を小さくすると点が密になり写真に近く見え、値を大きくすると点が太くなり印刷物のようにずっと粗く見えるはずです。 |
| ホバー時の彩度復元時間値を調整する | 09 GRAYSCALE_HOVER_MS 200 → 600 |
値を小さくするとカーソルが乗った瞬間に色が一気に戻り、値を大きくすると色がゆっくりと染み込むように戻るはずです。 |
prefers-reduced-motion: reduceは1番から8番のis-demoクラスを外して毎フレームのループを止めますが、開始フレームではなくそれぞれ別の代表的な瞬間で止まります — 1番の二色調はDUOTONE_MIX_PERCENTのまま止まり、6番の熱画像トーンは揺れがかからない状態で止まり、5番のグレインはピークではなく基準のGRAIN_STRENGTHで止まります。9番だけは意図的に違う挙動で、彩度の値が毎フレームではなくpointerenter/pointerleaveやfocus/blurでしか変わらないため、モーション低減設定はsetHoverState(true)を一度呼ぶだけでキャンバスを常に色付きのまま残し、同じfocus/blurの組み合わせによってキーボードでタブ移動する読者にもポインターを使わずに色が戻る様子が見えます。
大きな写真や非力な端末でどこが壊れるか
どの項目もgetImageData()・putImageData()でCALC_W × CALC_Hピクセルを毎フレーム最低1回は読み書きします。このデモの480×300キャンバスなら15万読み取り未満で済みますが、同じピクセルごとのループを4000px級のアップロード写真にそのままかけると読み取り回数は50倍以上に跳ね上がり、テストでは一瞬で終わっていたcss フィルター ぼかしの処理が実機でもたつく地点はここです。1・2・5・6・7番は毎フレーム全バッファをループするので実写真の解像度で最初に計測すべき項目で、3・4・8・9番は粗い格子でサンプリングするか、キャンバス自身のctx.filterでぼかすか、ホバー時にしか再計算しないため、大きなサイズでも軽いままです。9枚すべてのindex.html・style.scss一式を開くwd482b7sはこの段落に置いてあります — ここまで読んだ方なら、どのループから計測すべきかもう見えているはずです。
css 画像 フィルターは実際どんな画面で使われるか
css 背景 画像 フィルターのようにページ全体へ一括でかける使い方と違い、この9つは1つのカードや1つのヒーロー画像だけに向けた計算です。1番と3番は<input type="range">として値をそのまま画面に出しているので、ジェネレーターを使わずに読者自身がその場で値を動かして確かめられます。同じ「毎フレーム値をひとつ読む」という考え方でまとめた編としては、CSS 画像 ホバー エフェクト 9選【コピペOK】が9番のようなホバー駆動フィルターに近く、CSS グラスモーフィズム効果9選【コピペOK】は4番のぼかしと重ね合わせの発想を共有していて、CSS フォト ギャラリー 9選 — メイソンリーからライトボックスまではこの9つの多くが実際に収まる先のグリッドです。
css 画像 フィルターは css フィルター モノクロ と何が違うのか?
css フィルター モノクロは彩度をゼロに落とす一つの計算で、9番がまさにその計算だけを行っている項目です。「css 画像 フィルター」という括りはもっと広く、ピクセルの明るさや位置を読んで新しい色を書き戻す計算全体を指し、2番から8番までがオフセット値・ノイズ値・参照テーブルといった別の値でそれを行っています。MDNのfilterリファレンスには4番と9番が直接使うblur()・grayscale()・saturate()が載っていますが、1・2・3・6・7・8番はgetImageData()に対する独自のピクセル計算で、これはネイティブのCSSフィルター関数よりもSVGのfeColorMatrixの考え方に近いです。
css 画像 フィルターを使う前に、よくある質問に答えます
この9つはアップロードした写真や外部ライブラリを読み込みますか?
いいえ。drawPhotoBase()がキャンバスのグラデーションと図形だけで場面を描き、seedRand()のシードが同じなら同じ場面が再現されます。1番の二色調からgrayscale・saturateだけで色を戻す9番まで、画像ファイルもライブラリも一つも読み込みません。css フィルター と は何かを一から試すのに、外部の素材は必要ありません。
9つの中で大きな写真に載せたとき一番重いのはどれですか?
3番の網点は強度が82.9と最も高く、drawHalftoneDot()が明るさの読み取りに加えて格子のマスごとに円を1つ描くためです。6番の熱画像トーンは面積が75.3%と最も広く、全ピクセルが5色のグラデーション参照を受けるためで、どちらも1・2・5・7番より先に実写真の解像度で計測する価値があります。
9番のホバーリビールはホバーではなくクリックにも答えられますか?
はい。setHoverState(on)はすでにpointerenter/pointerleaveとfocus/blurを同じ関数で受けているので、そのリスナーをclickやpointerdownに差し替えるだけで済み、それが駆動しているCSSのfilterトランジション自体には手を加える必要がありません。









