Aberration

Red, green and blue drawn separately and slightly out of line, like a cheap lens or a misaligned print.

How it works

Three channels, slightly out of line

A lens that doesn't focus all wavelengths at the same spot draws red, green and blue at slightly different positions. An SVG filter can fake that on any element by literally separating the channels and moving them apart.

The pipeline

The filter is applied with plain filter: url(#id) on the content, not as a backdrop filter. It runs the same three steps for red, green and blue, then puts them back together:

  1. Isolate one color channel with feColorMatrix.
  2. Move it with feOffset (or feDisplacementMap).
  3. Recombine the three with feBlend mode="screen".

Where the three copies overlap exactly, the result is the original image. Only at edges, where the copies disagree, do colored fringes appear: red on one side, blue (or cyan) on the other.

1. Isolate: feColorMatrix

<feColorMatrix type="matrix"> computes every output channel as a weighted sum of the input channels. The values attribute has four rows (R, G, B, A) of five numbers: the weights of R, G, B, A and a constant. To keep only red:

1 0 0 0 0   ← R = R
0 0 0 0 0   ← G = 0
0 0 0 0 0   ← B = 0
0 0 0 1 0   ← A = A

Each of the three matrices starts again from SourceGraphic, the untouched content, via in="SourceGraphic". Alpha is kept as it is, so a red copy of white text is red text with the same edges.

2. Move: feOffset

<feOffset dx dy> shifts its input by a fixed number of px. The Distance and Angle become dx = cos(angle) × distance and dy = sin(angle) × distance for red, and the negated values for the channels moving the other way. In rgb mode green stays put; in red-cyan green travels with blue, which gives the two-tone look of anaglyph 3D.

By default a filter region is the element's bounding box plus 10% on each side, so a shift larger than 10% of the element (easily reached by a line of small text) gets clipped. The component sets filterUnits="userSpaceOnUse" instead and pads the region by the distance plus the jitter, in px: currently 5 px on every side. For HTML elements, user-space coordinates start at the top-left corner of the border box, so the component measures its size (with a ResizeObserver, through Svelte's bind:offsetWidth) to fill in the width and height.

Radial mode: feDisplacementMap

Real lenses get worse towards the edge of the image: the channels line up in the center and drift apart further out, along the line from the center. A fixed feOffset can't do that, but feDisplacementMap can, given the right map. The map is a tiny SVG image whose red channel rises from 0 on the left to 1 on the right and whose green channel rises from top to bottom, two linearGradients combined with mix-blend-mode: screen. Loaded with <feImage> and stretched over the filter region (preserveAspectRatio="none"), it holds at every pixel the vector from the center: (R − ½, G − ½). The map is generated for the element's size and padding: both gradients get the same slope per px of the element's half-size, so one scale reaches the same offset on all four edges even on a wide element.

feDisplacementMap samples each pixel from scale × (R − ½, G − ½) away. With a negative scale, red samples from closer to the center and appears slightly enlarged; with a positive one, blue shrinks. Green gets scale 0. The scale is chosen so the offset at the element's edge is the Distance setting.

3. Recombine: feBlend screen

screen computes 1 − (1 − a)(1 − b) per channel. When one input is black in a channel (as each isolated copy is in two of three), that is just the other input, so screening the three copies adds them back up to the full color. Alpha is combined the same way. feComposite operator="arithmetic" k2="1" k3="1" (plain addition) gives the same colors; it only differs in how it sums alpha.

The filter for your current settings:

<svg width="0" height="0" aria-hidden="true">
  <!-- W × H: the element's size in px -->
  <filter id="rgb-split" filterUnits="userSpaceOnUse"
          x="-5" y="-5" width="W + 10" height="H + 10"
          color-interpolation-filters="sRGB">
    <feColorMatrix in="SourceGraphic" type="matrix"
                   values="1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0" />
    <feOffset dx="3" dy="0" result="r2" />
    <feColorMatrix in="SourceGraphic" type="matrix"
                   values="0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 1 0" />
    <feOffset dx="0" dy="0" result="g2" />
    <feColorMatrix in="SourceGraphic" type="matrix"
                   values="0 0 0 0 0  0 0 0 0 0  0 0 1 0 0  0 0 0 1 0" />
    <feOffset dx="-3" dy="0" result="b2" />
    <feBlend in="r2" in2="g2" mode="screen" result="rg" />
    <feBlend in="rg" in2="b2" mode="screen" />
  </filter>
</svg>

<style>
  .split { filter: url(#rgb-split); }
</style>

Jitter

Optional wobble: on every animation frame the component adds a small offset to the split, from a sum of slow sine waves with unrelated periods. The fastest of them runs at 1.7 Hz and the motion is continuous, so nothing jumps or flashes; only thin colored edges move. It doesn't start on its own, and it stays off when the user prefers reduced motion.

Readability

Split text is harder to read: the edges of each letter blur into colored fringes. Large, heavy type survives a lot, body text very little. The default of 3 px keeps the headline here crisp, and the caption below the image is deliberately left unfiltered. If you use this on real content, keep it to display type and imagery, and never on running text.

The white headline sits on a dark scrim and has a soft dark halo (text-shadow). Measured on this page with the split off, what is directly behind the letters stays at 7.8:1 or better against white for every image option. The scrim alone only reached about 3.1:1 over the brightest parts, which is why the halo is there. The colored fringes the effect adds come on top of that, along the edges of the letters.

Browser support

Every primitive used here is part of SVG 1.1, and filter: url() on HTML elements works in all current engines. This page was checked in Chromium and Firefox. Safari is untested.

Cost: the filter runs over the whole element, three times over, and is re-run whenever the content changes, for example while the image is animated or the jitter is on. On static content it is computed once and cached.

Back to the demo ↑