GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

css 이미지 필터 효과 9가지 — 값 하나가 색을 바꾼다

css 이미지 필터 효과 9가지를 캔버스 픽셀 계산으로 직접 만들어 봤다. 필터는 사진을 망치는 줄 알았다 — 값 하나를 만지기 전까지는. 01번은 두 색 사이 매핑 값으로 듀오톤을 세우고, 05번은 노이즈 값으로 필름 그레인을 흩뿌리며, 09번은 커서가 오를 때 그 채도 값을

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

픽셀 값 하나가 아홉 필터 계산을 가르는 자리

아홉 장면 모두 <canvas> 에서 픽셀 버퍼를 읽고 다시 그린다(04·09는 예외로 각각 브라우저 filter 와 css 필터 토글을 쓴다). 화면에 보이는 사진은 실제 촬영본이 아니라 drawPhotoBase 가 seedRand 로 매번 새로 그리는 절차적 이미지이고, 필터마다 이 버퍼 위에서 값 하나(혼합 비율·오프셋 거리·노이즈 강도)가 색을 어떻게 다시 계산하는지가 다르다.

01두 색 매핑 값으로 세우는 듀오톤

밝기(lum) 값 하나를 mapDuotone 이 어두운 색과 밝은 색 사이에서 보간해, 사진 전체를 두 가지 색조로만 다시 칠한다. DUOTONE_MIX_PERCENT 값이 커질수록 원색이 사라지고 두 지정색 쪽으로 완전히 쏠린다. 01 항목은 누적면적 73.1%·강도 평균 21.4·23프레임 중 16프레임에서 움직였다.

두 색 매핑 값으로 세우는 듀오톤
새 창에서 열기

01 · 두 색 매핑 값으로 세우는 듀오톤 — 측정 누적면적 73.067% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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

02채널 어긋남 거리 값으로 갈라지는 글리치

shiftChannels 가 빨강·초록·파랑 채널을 CHANNEL_OFFSET_PX 값만큼 서로 다른 방향으로 밀어 픽셀을 다시 쓴다. 오프셋이 사인파를 타고 커졌다 작아지길 반복해, 채널이 벌어지는 정도가 주기적으로 출렁인다. 02 항목은 누적면적 14.1%·강도 평균 37.5·23프레임 중 22프레임에서 움직였다.

채널 어긋남 거리 값으로 갈라지는 글리치
새 창에서 열기

02 · 채널 어긋남 거리 값으로 갈라지는 글리치 — 측정 누적면적 14.143% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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

03점 크기 값이 인쇄 질감을 만드는 망점 필터

drawHalftoneDot 이 픽셀 밝기를 읽어 어두울수록 큰 원, 밝을수록 작은 원으로 바꿔 그린다. HALFTONE_DOT_SIZE_PX 값이 원 반지름 계산식에 그대로 곱해져 격자 자체의 굵기를 정한다. 03 항목은 누적면적 63.0%·강도 평균 82.9·23프레임 중 18프레임에서 움직였다.

점 크기 값이 인쇄 질감을 만드는 망점 필터
새 창에서 열기

03 · 점 크기 값이 인쇄 질감을 만드는 망점 필터 — 측정 누적면적 62.988% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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

04흐림 값과 노이즈 값이 겹치는 젖빛 유리 필터

draw 가 매 프레임 ctx.filter = 'blur()' 로 사진을 흐리게 그린 뒤, drawNoiseOverlay 로 미세한 흰 점을 겹쳐 유리 표면처럼 보이게 한다. FROST_BLUR_PX 값에 사인파를 더해 흐림 정도가 숨쉬듯 오르내린다. 04 항목은 누적면적 43.3%·강도 평균 19.0·23프레임 중 22프레임에서 움직였다.

흐림 값과 노이즈 값이 겹치는 젖빛 유리 필터
새 창에서 열기

04 · 흐림 값과 노이즈 값이 겹치는 젖빛 유리 필터 — 측정 누적면적 43.31% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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

05노이즈 강도 값이 흩뿌리는 빈티지 필름 그레인

applyFilmGrain 이 픽셀마다 -1~1 사이 난수에 GRAIN_STRENGTH 값을 곱한 만큼 밝기를 흔들어 흑백 노이즈를 얹는다. 노이즈 강도 자체도 사인파를 타 프레임마다 짙어졌다 옅어지길 반복한다. 05 항목은 누적면적 68.1%·강도 평균 17.7·23프레임 중 20프레임에서 움직였다.

노이즈 강도 값이 흩뿌리는 빈티지 필름 그레인
새 창에서 열기

05 · 노이즈 강도 값이 흩뿌리는 빈티지 필름 그레인 — 측정 누적면적 68.114% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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

06밝기 순서 값을 색으로 바꾸는 열화상 톤

thermalColor 가 밝기 값 하나를 0에서 1 사이 위치로 바꿔 THERMAL_STOPS 색 목록에서 두 색 사이를 보간해 돌려준다. THERMAL_SHIFT 값이 사인파를 타면서 같은 밝기라도 매 프레임 다른 색 구간에 매핑돼 열이 흐르는 것처럼 보인다. 06 항목은 누적면적 75.3%·강도 평균 72.9·23프레임 중 20프레임에서 움직였다.

밝기 순서 값을 색으로 바꾸는 열화상 톤
새 창에서 열기

06 · 밝기 순서 값을 색으로 바꾸는 열화상 톤 — 측정 누적면적 75.314% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

THERMAL_STOPSthermalColorTHERMAL_SHIFT
  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;
    }
  }

07가장자리 값이 색수차를 만드는 렌즈 필터

distFromCenter 가 중심에서의 거리를 0~1 값으로 구하고, 그 값에 EDGE_STRENGTH_PX 를 곱한 만큼 채널마다 다른 방향으로 픽셀을 밀어 가장자리에만 색 테두리를 남긴다. 중심에 가까운 픽셀은 오프셋이 0에 가까워 거의 갈라지지 않는다. 07 항목은 누적면적 13.6%·강도 평균 37.3·23프레임 중 23프레임에서 움직였다.

가장자리 값이 색수차를 만드는 렌즈 필터
새 창에서 열기

07 · 가장자리 값이 색수차를 만드는 렌즈 필터 — 측정 누적면적 13.646% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

EDGE_STRENGTH_PXdistFromCentersampleBase
  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;

08픽셀 값을 읽어 뽑는 대표 색 스와치

computeDominantColor 가 픽셀을 4비트 단위로 묶어 가장 많이 나온 색을 세어 뽑고, 그 값을 카드 배경색으로 그대로 쓴다. PHOTO_SEEDS 값이 바뀌면 사진이 다시 그려지고 대표 색도 새로 계산된다. 08 항목은 누적면적 6.7%·강도 평균 19.6·23프레임 중 23프레임에서 움직였다.

픽셀 값을 읽어 뽑는 대표 색 스와치
새 창에서 열기

08 · 픽셀 값을 읽어 뽑는 대표 색 스와치 — 측정 누적면적 6.676% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PHOTO_SEEDScomputeDominantColorcyclePhoto
  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) + ')';
  }

09채도 값이 돌아오는 흑백 호버 리빌

setHoverState 가 캔버스 style.filter 를 grayscale(1) 과 grayscale(0) 사이에서 그대로 바꿔, 픽셀 계산 없이 채도 값만 전환한다. GRAYSCALE_HOVER_MS 는 이 전환이 몇 밀리초 안에 끝나는지를 정한다. 09 항목은 누적면적 58.5%·강도 평균 33.5·23프레임 중 10프레임에서 움직였다.

채도 값이 돌아오는 흑백 호버 리빌
새 창에서 열기

09 · 채도 값이 돌아오는 흑백 호버 리빌 — 측정 누적면적 58.465% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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

매핑 비율·오프셋 거리·노이즈 강도 — 값이 갈라지는 세 갈래

값 하나가 파고드는 자리는 크게 세 갈래다. 매핑 비율은 밝기 하나를 두 값 사이에서 고르는 쪽(01의 mix·06의 THERMAL_SHIFT)이고, 오프셋 거리는 채널이나 좌표를 몇 px 밀지 정하는 쪽(02의 CHANNEL_OFFSET_PX·07의 EDGE_STRENGTH_PX)이며, 노이즈·흐림 강도는 원본 위에 레이어를 얼마나 짙게 얹을지 정하는 쪽(04의 FROST_BLUR_PX·05의 GRAIN_STRENGTH)이다. 03의 HALFTONE_DOT_SIZE_PX 은 그 값을 반지름 계산식에 직접 곱해 격자 자체를 바꾸고, 08은 픽셀 값을 집계해 거꾸로 색 하나를 뽑아내며, 09만 픽셀을 건드리지 않고 css 필터 효과 값을 그대로 전환한다.

캡처 24장으로 잰 아홉 필터의 프레임별 변화량

값이 프레임마다 바뀌면 화면의 몇 퍼센트가 다시 칠해지는지를 캡처 24장으로 재실측했다. 누적면적은 겹친 프레임을 하나로 합친 값이고, 강도는 그 변화의 평균 밝기 차다.

항목 누적면적 % 강도 평균 움직인 프레임
01 두 색 매핑 값으로 세우는 듀오톤 73.1 21.4 16/23
02 채널 어긋남 거리 값으로 갈라지는 글리치 14.1 37.5 22/23
03 점 크기 값이 인쇄 질감을 만드는 망점 필터 63.0 82.9 18/23
04 흐림 값과 노이즈 값이 겹치는 젖빛 유리 필터 43.3 19.0 22/23
05 노이즈 강도 값이 흩뿌리는 빈티지 필름 그레인 68.1 17.7 20/23
06 밝기 순서 값을 색으로 바꾸는 열화상 톤 75.3 72.9 20/23
07 가장자리 값이 색수차를 만드는 렌즈 필터 13.6 37.3 23/23
08 픽셀 값을 읽어 뽑는 대표 색 스와치 6.7 19.6 23/23
09 채도 값이 돌아오는 흑백 호버 리빌 58.5 33.5 10/23

듀오톤부터 흑백 리빌까지, 아홉 패턴이 실제로 앉는 자리

듀오톤(01)은 랜딩 페이지 히어로 이미지에 브랜드 두 색만 남겨 통일감을 주고, 글리치(02)는 프로모션 배너에 커서가 오를 때만 잠깐 끼얹는 강조로 쓴다. 인쇄 질감(03)은 포스터·전단 카드에, 젖빛 유리(04)는 모달이 뜰 때 뒤 배경을 가리는 용도로 맞는다. 그레인(05)은 감성 사진 갤러리, 열화상(06)은 데이터를 다루는 카드, 색수차(07)는 렌즈가 있는 프로필 이미지처럼 자리가 구체적이다.

대표 색 스와치(08)는 상품 카드 배경을 사진 색과 맞추는 css 이미지 색상 필터 조합으로 상품 목록 화면에 바로 쓸 수 있고, 흑백 리빌(09)은 제품 그리드에서 마우스가 지나갈 때만 색이 살아나는 흔한 호버 패턴이다. CSS 이미지 호버 효과 9종 — 한 줄 복붙으로 의 커서 반응 아홉 개와 나란히 두면, 09는 필터 값이 바뀌고 그 편의 나머지는 transform·shadow 값이 바뀐다는 차이가 드러난다.

css 이미지 필터는 css 백그라운드 필터와 뭐가 다른가?

filter 는 요소 자체(이미지·캔버스)를 다시 계산해 그리고, backdrop-filter 는 그 요소 뒤에 깔린 배경을 계산해 흐리거나 색을 바꾼다. 04의 젖빛 유리는 이미지를 직접 blur 하는 filter 예제라, 뒤가 비치는 진짜 유리 느낌을 내려면 CSS 글래스모피즘 리퀴드 글래스 효과 9종 쪽의 backdrop-filter 조합과 나눠 맡기는 편이 맞다 — 카드 앞면은 이 편의 필터로, 카드 뒤 배경은 그 편의 backdrop-filter 로 처리한다.

저사양 기기에서 계산이 멈추지 않는 이유 — 어디서 깨지는가

동작 줄임 설정(prefers-reduced-motion)이 켜지면 9개 전부 elapsed 값을 고정해 화면이 멈춘 것처럼 보이지만, tick 의 requestAnimationFrame 루프 자체는 멈추지 않는다. 01·02·03·05·06·07·08 일곱 개는 고정된 elapsed 로 매 프레임 160×100 픽셀 버퍼를 처음부터 다시 계산해 그림은 그대로인데 CPU 만 계속 돈다 — 04는 브라우저 filter(blur)를 전체 해상도 사진에 매 프레임 다시 걸고, 09만 값 전환이 없어 사실상 빈 루프다. 이 오프스크린을 160×100 대신 실제 사진 크기(예: 1920×1080)로 올리면 픽셀 수가 16,000에서 2,073,600으로 130배 가까이 뛰어, 저사양 기기에서 큰 이미지를 그대로 필터에 물리면 프레임이 뚝뚝 끊긴다 — index.html·style.scss 원본에서 이 오프스크린 크기를 직접 맞춰보고 싶다면 wd482b7s 으로 zip 을 열면 된다.

상수 셋만 바꿔보는 변형과 키보드 접근성

이름 값 느낌
듀오톤 혼합 비율 값 조정 01 DUOTONE_MIX_PERCENT 30 → 70 30 쪽에 두면 원색이 남아 듀오톤이 옅어지고, 70까지 올리면 화면이 두 지정색으로만 눌려 대비가 세진다.
망점 크기 값 조정 03 HALFTONE_DOT_SIZE_PX 4 → 12 4에 두면 점이 빽빽해 사진처럼 읽히고, 12로 키우면 점 하나하나가 도드라져 낡은 인쇄판처럼 거칠어진다.
호버 채도 복원 시간 값 조정 09 GRAYSCALE_HOVER_MS 200 → 600 200이면 색이 순간적으로 튀어 돌아오고, 600까지 늘리면 색이 스며들듯 천천히 차오른다.

접근성은 reduce 상태에서 무엇이 남는지로 갈린다. 01·03은 <input type="range"> 에 aria-label(duotone mix·dot size)을 달아 키보드로도 값을 직접 조절할 수 있고, 09는 pointerenter·pointerleave 뿐 아니라 focus·blur 도 같은 setHoverState 를 부르므로 탭 이동만으로 색이 돌아온다. 캡션 배지는 크림색 배경(rgba(255,247,230,.85)) 위에 $subject-ink 글자색을 쓰는 토큰 그대로라 대비가 사이트 전체와 같다.

css 이미지 필터를 처음 쓸 때 자주 묻는 것

css 필터 색상을 두 개보다 더 섞어도 되나요?

된다 — mapDuotone 처럼 밝기 값 하나를 색 배열 하나에 보간하는 구조라, 06의 THERMAL_STOPS 처럼 색을 다섯 개로 늘려도 같은 방식으로 돈다. 색이 늘수록 구간(i0·i1) 계산만 한 단계 늘어난다.

이 데모의 사진을 내 사진으로 바꿀 수 있나요?

지금은 drawPhotoBase 가 seedRand 로 매번 새로 그리는 절차적 이미지라 실제 촬영본이 아니다. <img> 를 로드해 ctx.drawImage(img, 0, 0, CALC_W, CALC_H) 로 오프스크린에 한 번 그려 넣으면 이후 픽셀 루프는 그대로 쓸 수 있다 — 이미지 갤러리 CSS 9종 — 메이슨리부터 라이트박스까지 의 갤러리 카드에 얹어도 된다.

구형 브라우저에서도 그대로 보이나요?

filter·getImageData·backdrop-filter 는 오래전에 표준화돼 대부분의 최신 환경에서 지원한다. 04의 ctx.filter = 'blur()' 처럼 캔버스 2D 컨텍스트의 filter 속성만 예외적으로 늦게 들어왔으니, 그 항목만 구형 환경에서 확인해 두는 편이 안전하다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.