GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Image Filters: 9 Patterns, One Value

These are css image filters. A filter seemed like something that ruins a photo, until one value said otherwise.

Auto-plays · click a tile to jump to its section · all nine in one zip

All nine start from the same procedurally-drawn scene — drawPhotoBase() paints a gradient sky, a sun, and a few geometric shapes with seedRand(), a seeded pseudo-random generator, so nothing here is an uploaded photo. The order below runs from the filter that repaints the most of that frame to the one that repaints the least, so item 1's duotone (73.1% of the canvas) opens the list and item 8's dominant-color swatch (6.7%) closes it.

Where a CSS Image Filter Actually Earns a Spot in a Layout

Reach for one of these nine before reaching for a css image filter generator — a generator is built for exploring hundreds of combinations, but a marketing hero, a product grid, or a modal background only ever needs the one calculation that matches its mood, wired straight into the canvas draw call. Items 1 and 3 below even expose that one value as a real <input type="range"> a reader can drag, no generator required.

01Duotone Built From a Two-Color Mapping Value

Item 1 maps each pixel's brightness to a point between a dark color and a light color, so the whole scene reads as two tones instead of its original palette — useful on a marketing landing hero image where the photo needs to match one brand palette instead of its own colors. The value that drives it is DUOTONE_MIX_PERCENT, read into mapDuotone() once per pixel inside the per-frame loop that walks the canvas buffer.

Duotone Built From a Two-Color Mapping Value
Open in a new tab

01 · Duotone Built From a Two-Color Mapping Value — Measured area 73.067% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

Measured: 73.1% of the frame repaints across the loop, intensity averages 21.4, and 16 of 23 sampled frames moved.

02Glitch Split From a Channel-Offset Distance Value

Item 2 nudges the red, green, and blue channels apart in different directions, so as the offset-distance value grows the three colors separate wider and the scene reads as broken — a fit for a promotion banner's hover state where a moment of visible glitch draws the eye. CHANNEL_OFFSET_PX sets how far shiftChannels() pushes the red channel one way and the blue channel the other, sampling from the untouched base buffer each time.

Glitch Split From a Channel-Offset Distance Value
Open in a new tab

02 · Glitch Split From a Channel-Offset Distance Value — Measured area 14.143% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

Measured: 14.1% of the frame repaints, intensity averages 37.5 — the highest of any item still under 20% area — and 22 of 23 frames moved.

03Halftone Filter Whose Dot-Size Value Builds a Print Texture

Item 3 replaces the photo with a grid of dots sized by local brightness, so a larger HALFTONE_DOT_SIZE_PX value reads like an old newspaper print rather than a clean photo — the kind of print-styled poster card a retro layout wants. sampleBrightness() reads the luminance under each grid cell and drawHalftoneDot() turns that into a circle radius, and like item 1 this value is also exposed as a draggable range input.

Halftone Filter Whose Dot-Size Value Builds a Print Texture
Open in a new tab

03 · Halftone Filter Whose Dot-Size Value Builds a Print Texture — Measured area 62.988% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

Measured: 63.0% of the frame repaints, intensity averages 82.9 — the highest of all nine — and 18 of 23 frames moved.

04Frosted-Glass Filter Where a Blur Value Layers Over a Noise Value

Item 4 blurs the scene heavily and then scatters a fine noise layer on top, so it reads like a translucent frosted-glass pane instead of a sharp photo — a natural background image sitting behind a modal. FROST_BLUR_PX sets the base blur radius that oscillates with a sine wave each frame, and drawNoiseOverlay() adds the grain that keeps the blur from looking flat.

Frosted-Glass Filter Where a Blur Value Layers Over a Noise Value
Open in a new tab

04 · Frosted-Glass Filter Where a Blur Value Layers Over a Noise Value — Measured area 43.31% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

Measured: 43.3% of the frame repaints, intensity averages 19.0, and 22 of 23 frames moved.

05Vintage Film Grain Scattered by a Noise-Strength Value

Item 5 lays a fine black-and-white noise layer over the photo, so a larger GRAIN_STRENGTH value deepens the look of an old film print — built for a mood photo gallery card where a little grain reads as warmth rather than damage. applyFilmGrain() draws one random offset per pixel from a seeded generator and adds it to all three color channels equally, which is what keeps the grain gray instead of colored.

Vintage Film Grain Scattered by a Noise-Strength Value
Open in a new tab

05 · Vintage Film Grain Scattered by a Noise-Strength Value — Measured area 68.114% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

Measured: 68.1% of the frame repaints, intensity averages 17.7 — the lowest of any item over 60% area — and 20 of 23 frames moved.

06Thermal Tone That Recolors a Brightness-Order Value

Item 6 maps brightness in order, darkest to brightest, onto a blue-to-red gradient, so the scene reads as though a thermal camera shot it — an idea-stage fit for a data-visualization styled photo card. THERMAL_STOPS is the five-color gradient table thermalColor() interpolates between, and THERMAL_SHIFT nudges every pixel's lookup position together each frame.

Thermal Tone That Recolors a Brightness-Order Value
Open in a new tab

06 · Thermal Tone That Recolors a Brightness-Order Value — Measured area 75.314% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

Measured: 75.3% of the frame repaints — the highest area of all nine — intensity averages 72.9, and 20 of 23 frames moved.

07Lens Filter Whose Edge Value Builds Chromatic Aberration

Item 7 shifts channels a little more the closer a pixel sits to the frame's edge, so a larger EDGE_STRENGTH_PX value deepens the color fringe far from center while the middle of the frame stays clean — an idea-stage lens effect for a profile image. distFromCenter() turns each pixel's position into a 0–1 distance, and renderFrame() multiplies that distance by the edge-strength value to get the actual channel offset for that pixel.

Lens Filter Whose Edge Value Builds Chromatic Aberration
Open in a new tab

07 · Lens Filter Whose Edge Value Builds Chromatic Aberration — Measured area 13.646% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

Measured: 13.6% of the frame repaints — the smallest area of all nine — intensity averages 37.3, and 23 of 23 frames moved, the only item that moved on every sampled frame.

08Dominant-Color Swatch Read From Pixel Values

Item 8 scans every pixel's value to compute the single most common color, then fills a card's background with that exact color so the card matches the photo instead of clashing with it — the idea behind a product-card background sync. computeDominantColor() buckets each pixel by its top four bits per channel (a cheap way to group near-identical colors), then reads back whichever bucket holds the most pixels.

Dominant-Color Swatch Read From Pixel Values
Open in a new tab

08 · Dominant-Color Swatch Read From Pixel Values — Measured area 6.676% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

Measured: 6.7% of the frame repaints — the smallest of all nine — intensity averages 19.6, and 23 of 23 frames moved.

09Grayscale Hover Reveal Where a Saturation Value Returns

Item 9 sits at zero saturation by default and, on hover, returns that same saturation value to its original level so color comes back — built for a product grid hover where color should feel like a reward for attention. setHoverState() is the only place the value changes, toggling a CSS filter string between grayscale(1) saturate(0) and grayscale(0) saturate(1) over a transition timed by GRAYSCALE_HOVER_MS.

Grayscale Hover Reveal Where a Saturation Value Returns
Open in a new tab

09 · Grayscale Hover Reveal Where a Saturation Value Returns — Measured area 58.465% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var playing = !reduce;
  if (reduce) { root.classList.remove('is-demo'); setHoverState(true); }

Measured: 58.5% of the frame repaints in one saturation swing, intensity averages 33.5, and only 10 of 23 frames moved — the fewest of all nine, because it changes once per hover instead of every frame.

What Nine CSS Image Filters Actually Repainted, Frame by Frame

Item Area % Intensity Frames moved
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

Three Variants Worth Bending in This Build

Variant Value change Feel
Adjust the duotone mix-ratio value 01 DUOTONE_MIX_PERCENT 30 → 70 Shortening the value should leave more of the original color for a subtle duotone, and lengthening it should push fully into the two mapped colors for a much stronger duotone.
Adjust the halftone dot-size value 03 HALFTONE_DOT_SIZE_PX 4 → 12 Shortening the value should pack the dots tight for a near-photographic look, and lengthening it should thicken the dots into a much coarser print texture.
Adjust the hover saturation-restore duration value 09 GRAYSCALE_HOVER_MS 200 → 600 Shortening the value should snap the color back the instant the cursor arrives, and lengthening it should let the color seep back in slowly.

Where CSS Image Filters Break — Large Photos and Low-end Devices

Every filter above reads or writes CALC_W × CALC_H pixels through getImageData()/putImageData() at least once per frame — on the demo's 480×300 canvas that's under 150,000 reads, but drop the same per-pixel loop onto a 4000px upload and the count climbs past fifty times that, which is where a css photo filters build that looked instant in testing turns choppy on a real device. Items 1, 2, 5, 6, and 7 all loop the full buffer every frame, so they're the ones worth profiling first before shipping a filter at full photo resolution; items 3, 4, 8, and 9 either sample a coarser grid, blur through the canvas's own ctx.filter, or only recompute on hover, so they stay cheap even at a larger size. The wd482b7s that opens all nine index.html/style.scss pairs sits in this paragraph — worth reading this far before you decide which loop to profile first.

Accessibility: What prefers-reduced-motion Actually Freezes

prefers-reduced-motion: reduce removes the is-demo class and stops the per-frame loop on items 1 through 8, but each one freezes at a different representative moment rather than its starting frame — item 1's duotone stops at DUOTONE_MIX_PERCENT, item 6's thermal tone stops with no wave shift applied, and item 5's grain stops at its base GRAIN_STRENGTH rather than a peak. Item 9 behaves differently on purpose: because its saturation value only changes on pointerenter/pointerleave or focus/blur rather than every frame, reduced motion calls setHoverState(true) once and leaves the canvas in full color, and the same focus/blur pair means a reader tabbing through the page with a keyboard sees the color return without ever touching a pointer.

How Are CSS Image Filters Different From a CSS Duotone Filter?

A css duotone filter is one specific two-color mapping — item 1 above is that filter on its own. "CSS image filters" as a category is the wider set item 1 belongs to: any calculation that reads a pixel's brightness or position and writes back a new color, which is also what items 2 through 9 do with an offset, a noise value, a lookup table, or a saturation switch instead of a two-color map. For patterns built the same way — one value read every frame instead of a fixed transition — 9 CSS Image Hover Effects — One Line, Copy-Paste is the closest relative for hover-driven filters like item 9, 9 CSS Glassmorphism Effects to Copy-Paste shares item 4's blur-and-layer approach, and 9 CSS Image Gallery Grid Effects is where several of these nine would actually sit inside a real grid.

The MDN filter reference documents the native CSS functions items 4 and 9 lean on directly (blur(), grayscale(), saturate()); items 1, 2, 3, 6, 7, and 8 instead compute their own per-pixel math against getImageData(), which is closer to what an SVG feColorMatrix does than to a native CSS filter function.

Questions to Ask Before You Copy One of These Nine CSS Image Filters

Do any of these nine load an uploaded photo or a third-party library?

No. drawPhotoBase() paints the scene from scratch with canvas gradients and shapes, seeded by seedRand() so the same seed always draws the same scene, and every filter — from item 1's duotone to a css image filter grayscale pass like item 9's — runs entirely on that canvas buffer with no image files and no library loaded.

Which of the nine costs the most to run on a large photo?

Item 3's halftone has the highest measured intensity (82.9) because drawHalftoneDot() draws one circle per grid cell in addition to the brightness read, and item 6's thermal tone covers the most area (75.3%) because every pixel gets a five-stop gradient lookup — both are worth testing first at real photo resolution before item 1, 2, 5, or 7.

Can item 9's hover reveal answer a click instead of a hover?

Yes — setHoverState(on) already answers pointerenter/pointerleave and focus/blur with the same function, so swapping those listeners for a click or pointerdown pair is a one-line change to addEventListener() and doesn't touch the CSS filter transition it drives.

Enter the archive password

The password is inside this article. You will find it as you read.