Every time I’m in San Francisco I go hunting for the perfect postcard to give people back home. This time I wanted to make it myself.

Most evenings here, the fog comes in. You see it first over the hills, a soft grey edge that doesn’t look like weather yet. Ten minutes later half the skyline is gone, and the half that’s left looks like it was drawn in pencil.

I watched it once from an office window and thought, very specifically: that’s feTurbulence with a slow baseFrequency and a displacement map on top.

Occupational damage, probably. But also true. So I built the postcard out of filters. Go on, turn it over.

Greetings from San Francisco

<filter id="fog" x="0" y="0" width="100%" height="100%">
  <!-- the noise -->
  <feTurbulence type="fractalNoise" baseFrequency="0.005 0.014"
    numOctaves="4" seed="11" result="base" />
  <!-- noise to push the noise around -->
  <feTurbulence type="fractalNoise" baseFrequency="0.02"
    numOctaves="2" result="warp" />
  <feDisplacementMap in="base" in2="warp" scale="60"
    xChannelSelector="R" yChannelSelector="G" />
  <!-- white, and only where it's thick -->
  <feColorMatrix type="matrix" values="
    0   0 0 0 1
    0   0 0 0 1
    0   0 0 0 1
    1.4 0 0 0 -0.5" />
  <feGaussianBlur stdDeviation="2" />
</filter>

No images in there. Three empty <div>s with a filter each for the fog, a flipped copy of the city under a ripple filter for the water, and one more layer on top for the grain, all slotted between three layers of drawing.

That’s the postcard. The build is below, notebook style: one page per idea, each one right where it turns into code.

Atmosphere isn’t drawn

Page one has no code in it at all. It’s what the window shows, before anyone gets tempted to draw clouds.

Page 1 · what the window showed
1. what the window showedno shape. just thick or thinwider than tall → bandsfar stuff goes firstmoves 2 ways: it travels → and it churns

Most SVG filter tutorials treat filters as finishes: a drop shadow, a gooey blob, a glitch on hover. Something you apply to a thing that already exists.

Fog doesn’t work like that. It has no shape. It’s density, movement and loss of contrast, all at once, and it changes what’s behind it instead of sitting on top. That’s exactly what a filter pipeline is good at.

So here’s the claim: SVG filters are better at weather than at decoration. You don’t draw fog. You describe the conditions and let the browser compute it, pixel by pixel.

Here’s the drawing on its own, before any of that. Switch the conditions on, one at a time, or let the button do it.

The same drawing, the weather on top

just the drawing

The drawing underneath never changes. Every switch adds a layer on top of it, and the three doing the heavy lifting, water, fog and grain, are filters running on things that were already there. We’ll build it in five layers: noise, drift, density, water, depth.

It’s also a shader problem in disguise: how close can SVG filters get to the kind of thing you’d normally write a shader for? A fragment shader is a small function the GPU runs once for every pixel: take a position, give back a colour. A filter chain is the same idea written as markup. feTurbulence is the noise function, feDisplacementMap bends where each pixel samples from, feColorMatrix does the colour maths. Page two is that chain, and it’s the whole article in five steps.

Page 2 · a shader, but SVG
2. a shader, but SVGevery pixel asks: how much fog is here?noisefeTurbulencewarp itfeDisplacementMapthick?feColorMatrixsoftenfeGaussianBlur× 3 layersplain CSSno canvas, no WebGL. a fragment shader, written as markup

Layer 1: noise

Everything starts with feTurbulence. It generates Perlin noise across the filter region, and that noise is the raw material for every cloud, smoke and fog effect you’ve seen done in SVG.

<svg width="0" height="0">
  <filter id="fog" x="0" y="0" width="100%" height="100%">
    <feTurbulence
      type="fractalNoise"
      baseFrequency="0.004 0.012"
      numOctaves="4"
      seed="7" />
  </filter>
</svg>

Three attributes do most of the work:

  • type: fractalNoise gives soft, cloudy gradients. turbulence folds the noise on itself and produces ridges that look like marble or smoke. Fog wants fractalNoise.
  • baseFrequency: takes two values, x and y. A lower x than y stretches the noise horizontally, which is what makes it lie in bands instead of blobs. Real fog over a city is wider than it is tall.
  • numOctaves: layers of finer detail. Four is enough. Past five you’re paying for detail nobody sees.
Raw noise

bands

Set x and y to the same value and you get blobs. Pull x below y and they flatten into bands, and that’s the moment it stops looking like noise and starts looking like something sitting over water.

Raw noise is colourful, and its alpha channel is noise too, so it’s patchy and see-through in random places. It isn’t fog yet. It’s the weather report.

Layer 2: drift

Fog moves in two ways at once. The whole mass travels, and inside it, everything churns. Those are two different problems, and conflating them is why most animated SVG fog looks like it’s boiling.

Travel: move the element, not the noise. The tempting move is animating baseFrequency. Don’t. Changing frequency rescales the noise, so it zooms and pulses instead of travelling. Make the filtered element wider than the viewport and translate it instead. The noise moves with it because it’s computed in that element’s coordinate space.

.fog {
  width: 300%;
  animation: drift 90s linear infinite alternate;
}

@keyframes drift {
  to { transform: translateX(-66%); }
}

Churn: warp it with more noise. A second feTurbulence feeds an feDisplacementMap, which pushes the first layer’s pixels around based on the second’s colour channels.

<filter id="fog">
  <feTurbulence type="fractalNoise" baseFrequency="0.004 0.012"
    numOctaves="4" result="base" />
  <feTurbulence type="fractalNoise" baseFrequency="0.02"
    numOctaves="2" result="warp">
    <animate attributeName="baseFrequency"
      values="0.02;0.024;0.02" dur="40s" repeatCount="indefinite" />
  </feTurbulence>
  <feDisplacementMap in="base" in2="warp" scale="60"
    xChannelSelector="R" yChannelSelector="G" />
</filter>

Here animating baseFrequency is fine, because nobody sees the warp layer directly. Its boiling becomes the fog’s slow internal motion.

Travel, churn, both

weather

Travel alone looks like a sliding texture. Churn alone looks like a lava lamp. Together it’s weather. Give each one a few seconds, it’s slow on purpose.

(I’m jumping ahead by one layer here: the fog in that demo is already white and thinned out. That’s the next step.)

Layer 3: density

Raw, the fog is a rainbow rectangle. Fog is the opposite: colourless, and mostly made of where it isn’t. One feColorMatrix fixes both, and it makes more sense as a graph than as a matrix.

Page 3 · how thick is thick
3. how thick is thick?alphanoise≈ .36clear skyfoga = R × 1.4 − 0.5steeper → hard cloudsshift left → city gonetwo numbers= the whole mood
<feColorMatrix type="matrix" values="
  0   0 0 0 1
  0   0 0 0 1
  0   0 0 0 1
  1.4 0 0 0 -0.5" />

The first three rows ignore the input and set every pixel to white. The last row is where the fog lives: alpha becomes the red channel times 1.4, minus 0.5. Anything darker than roughly a third of the noise drops to zero. That’s your clear sky between banks.

Those two numbers are the whole mood. Raise the multiplier and the edges harden into clouds. Raise the offset towards zero and the city disappears.

The alpha row

rolling in

Push the offset down past -1 and it’s a clear evening, a few banks sitting offshore. Drag it up towards zero and you get a normal San Francisco evening.

Edges come from outside the filter. Real fog is heavier near the water and thins out with height. A CSS mask does this better than another primitive:

.fog {
  mask-image: linear-gradient(to top, black 30%, transparent 85%);
}

A final feGaussianBlur with a stdDeviation of 2 or 3 cleans up the grain the displacement map leaves behind. Any more and it looks out of focus, not foggy.

Layer 4: water

The first version of this bay had a drawn reflection: the city flipped upside down and sliced into stripes with a mask. It looked fine. It also looked printed. Water isn’t stripes. It’s the same picture, torn into strips that each slid a little.

Page 4 · water is fog upside down
4. water is fog upside downwaterlineflip it,slide eachstrip sidewaysnoise, stretched flatx .0018 · y .068≈ 40× fasterdown than acrosssame primitives as the fog. just pointed at a picture

Which is fog again, pointed at a picture. Flip the city at the waterline, then push its pixels around with noise that’s been stretched until it’s nothing but horizontal lines:

<filter id="wave">
  <feTurbulence type="fractalNoise" baseFrequency="0.0018 0.068"
    numOctaves="2" result="ripple" />
  <feDisplacementMap in="SourceGraphic" in2="ripple" scale="14"
    xChannelSelector="R" yChannelSelector="G" />
</filter>

<!-- the same city, mirrored at the waterline (y = 430) -->
<svg class="reflection" viewBox="0 0 1200 600">
  <g filter="url(#wave)">
    <g transform="translate(0 860) scale(1 -1)">…the city…</g>
  </g>
</svg>
.reflection {
  opacity: 0.5;
  /* hard at the waterline, half gone by the middle, gone by the bottom */
  mask-image: linear-gradient(to bottom,
    transparent 71.4%, black 72.4%, rgb(0 0 0 / .55) 86%, transparent);
}

An x of 0.0018 against a y of 0.068 makes noise that changes almost forty times faster going down than going across. That’s thin horizontal bands, and feDisplacementMap reads every band as “shift this row a few pixels”. Each row gets its own shift, so straight towers come out wobbling, which is exactly what a reflection on moving water does.

Notice the filter sits on a <g> inside the SVG, not on the <svg> itself. That one cost me a whole round of “why does it look different on my phone”, and it’s the second story in the next-to-last section.

Ripples

a light breeze

Drop the chop to zero and you get a perfect mirror, which looks wrong straight away: real water is never that still. Push the wind up and the strips get thinner, like the city is breaking up on smaller waves. Here the ripples hold still. In the postcard at the top they don’t: one more primitive slides the ripple noise towards the horizon and back, very slowly, so the reflection swells like water.

<feOffset in="ripple" result="swell">
  <!-- out to the horizon and back, easing at both ends -->
  <animate attributeName="dy" values="0;-60;0" dur="26s" repeatCount="indefinite"
    calcMode="spline" keyTimes="0;0.5;1" keySplines="0.45 0 0.55 1;0.45 0 0.55 1" />
</feOffset>
<feDisplacementMap in="SourceGraphic" in2="swell" scale="14" … />

That’s an animation inside a filter, so it recomputes every frame, which the next sections have opinions about.

Layer 5: depth

One fog layer over a picture is a texture. Fog between things is a place. The difference is interleaving.

Page 5 · fog between things
5. fog between thingshillsfog · farsmall, slow, densecityfog · midpierfog · nearbig, fast, thinback → front. each fog its own filter + seed
<div class="scene">
  <svg class="hills">…</svg>
  <div class="fog fog--far"></div>
  <svg class="water">…</svg>
  <svg class="reflection">…</svg>
  <svg class="skyline">…</svg>
  <div class="fog fog--mid"></div>
  <svg class="pier">…</svg>
  <div class="fog fog--near"></div>
</div>

Each layer gets its own filter, because filter attributes don’t accept custom properties. That’s annoying, but it forces you to make each layer actually different:

LayerbaseFrequencyDriftOpacity
Far0.008 0.02180s0.9
Mid0.005 0.01490s0.6
Near0.003 0.00845s0.35

Distance works backwards from what you’d guess. Far fog has smaller features and moves slower, because it’s further away. It’s also denser, because you’re looking through more of it. Near fog is big, fast and thin: wisps passing in front of the camera.

Change the seed on each layer too. Same seed, same pattern, and the eye catches the repetition within seconds.

Three layers, or one

Flip it. The same fog, collapsed into one sheet in front of everything, is a skyline behind a dirty window. Spread across three depths, the skyline is inside the fog.

The bill

Weather is expensive. feTurbulence is computed per pixel, and anything that animates inside the filter recomputes the whole chain every frame. Three layers at full viewport size will make a laptop fan audible.

Three things keep it reasonable:

  • Animate outside the filter when you can. The CSS translateX drift is cheap: the browser computes the filter once and slides the result around. The animated warp layer isn’t. Measured in Chromium, a fog layer three times wider than the screen drifting with CSS costs about a tenth per frame of a screen-sized one with the <animate> warp running. If you only keep one animation, keep the travel. The big scenes on this page break this rule exactly once, for the water, and only while they’re on screen.
  • Cover less, don’t shrink and scale. The obvious trick is filtering a quarter-size element and scaling it up four times, since fog has no fine detail to lose. It saves nothing. Chromium computes the filter at the size it ends up on screen, so the scaled-up version costs exactly what the full-size one does. What does help is fewer filtered pixels: only cover the band where the fog lives, and keep numOctaves low.
  • Check Safari early. On this page it wouldn’t animate the fog at all, more on that below. Find out before you ship, not after.

And respect prefers-reduced-motion. Fog that stops moving is still fog:

@media (prefers-reduced-motion: reduce) {
  .fog { animation: none; }
}

The SMIL <animate> inside the filter doesn’t listen to that media query, but the <svg> holding it can pause every animation inside it with one line:

if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
  document.querySelectorAll('svg.fog-defs').forEach((svg) => svg.pauseAnimations());
}

Every animated demo on this page does both, and also stops moving the moment it scrolls out of view, so the fog you can’t see costs nothing.

What broke along the way

Every one of these looked fine on the screen I was building on. None of them threw an error. I’m writing them down because each one is the kind of thing you lose an evening to.

The filter that rendered nothing

The <svg> holding the filters is invisible anyway, so display: none looks like the obvious thing to put on it. In Chromium that kills every filter inside it. No warning, the fog <div>s just render as nothing. The fix is to keep the defs in the render tree and out of sight:

/* display:none would kill the filters */
.fog-defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

Water that changed size with the screen

The ripples looked perfect in an exported image and wrong in the article on my phone. Same filter, same numbers. The difference was where the filter was attached.

Put filter: url(#wave) on the outer <svg> from CSS, and baseFrequency and scale are measured in CSS pixels on screen. The drawing scales down on a phone, the ripples don’t, so on a small screen they come out huge and coarse. Put the same filter on a <g> inside the SVG and they’re measured in the drawing’s own units, so they scale with it. This demo renders the scene smaller, the way a phone would, then blows it back up to full size so you can see what happened to the water:

Screen pixels or drawing units

At 100% the two look the same, which is exactly why this hides so well. Drag it down to 30% with the switch on and the towers’ reflections start wobbling in big, lazy waves more than three times the size they should be, because the ripples stayed the same number of screen pixels while the city around them shrank. Switch it off and they shrink with the city.

A switch nobody could see

“flatten it” worked on my laptop. On an iPhone I tapped it and nothing happened. It had happened, the classes changed, the layers swapped. The flat version just used the same mask as the layered one, dense at the bottom and clear at the top, so on a small screen the two looked nearly identical, and the swap was instant, so nothing caught the eye.

A demo whose difference you can’t see on the reader’s screen is a broken demo. The fix was to make the flat state actually different, one even sheet over everything like a dirty window, and to crossfade between the two so your eye catches the switch:

.fog { transition: opacity .6s; }
.fog--flat { mask-image: none; opacity: 0; }
.is-flat .fog--flat { opacity: .62; }
.is-flat :is(.fog--far, .fog--mid, .fog--near) { opacity: 0; }

Boats with no reflection

When the reflection moved from a drawn copy to the filter, the ferry and the buoy quietly lost theirs, because only the city was being flipped. Adding them wasn’t just a copy-paste either. A boat floats on its own waterline, a few pixels lower than the quay, so each one has to flip around where its hull meets the water, or the reflection floats off on its own:

<!-- the city flips at y = 430, the ferry's hull sits at 437 -->
<g transform="translate(0 874) scale(1 -1)">…the ferry…</g>

Fog that Safari wouldn’t paint

On my iPhone the postcard had water and no fog. Not faint fog, none. Further down the page the fog showed up, but stood perfectly still.

My first guess was the flip. The card used to turn the textbook way, both faces in one preserve-3d box, and 3D is a usual suspect when something renders in one browser and not another. So I rebuilt the turn in two flat halves: the front spins to its edge, the faces swap, the back spins in from the other edge, and at rest nothing is transformed at all:

const spin = (from, to) => [
  { transform: `perspective(1800px) rotateY(${from}deg)` },
  { transform: `perspective(1800px) rotateY(${to}deg)` },
];

// two half turns: out to the edge, swap faces, in from the edge
front.animate(spin(0, 90), { duration: 380, easing: 'cubic-bezier(0.5, 0, 0.75, 0)' }).onfinish = () => {
  card.classList.add('show-back');
  back.animate(spin(-90, 0), { duration: 460, easing: 'cubic-bezier(0.25, 1, 0.5, 1)' });
};

Still no fog. The 3D was never the problem.

The real clue was the water. Its filter lives inside the SVG, on a <g>, and on the iPhone it moved. The fog is a CSS filter: url(#fog) on an HTML <div>, and Safari’s engine (which every browser on an iPhone uses, Chrome included) doesn’t reliably paint that kind of filter, and doesn’t redraw it when it animates. Moving the fog inside SVGs made it show up and move, and broke it in other ways, in every browser.

So in Safari the postcard is a recording. A few lines of script spot WebKit and swap the live scene for a looping video of it, with the lettering and the turn still live on top:

// every browser on an iPhone is WebKit; on a Mac, Safari is the one without Chrome in its name
const ua = navigator.userAgent;
const webkit = /iP(hone|ad|od)/.test(ua)
  || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1)
  || (/Safari\//.test(ua) && !/Chrome|Chromium|CriOS|FxiOS|Edg|OPR|Android/.test(ua));

It’s the one place on this page I’d rather show the real thing and can’t. The rest of it stays live in Safari, the fog just holds still.

A hidden face that kept painting

The two half turns stayed, they’re simpler, and they showed up the next thing straight away. Halfway through the turn, as the back spun in, a grey slab of fog hung in the air behind it. The front was supposed to be gone: its face had visibility: hidden. The face was. Its fog wasn’t.

The fog layers inside the front are elements with their own CSS filter, and in Chromium those filtered children kept painting even with their parent hidden. Nothing in the inspector looks wrong, the parent says hidden and the children inherit it. They just keep drawing. What does hide everything is opacity, because it’s applied to the face as one flattened picture, filters and all:

/* visibility alone left the fog's filters painting behind the back */
.show-back .pc-face--front {
  visibility: hidden;
  opacity: 0;
}
.pc-card:not(.show-back) .pc-face--back {
  opacity: 0;
}

visibility stays too, so the hidden face can’t be clicked or reached by a screen reader. opacity is what actually makes it disappear.

Back at the window

By dark the fog had taken the bridge and most of the bay. Nobody drew it. A few conditions lined up, temperature, wind, water, and the city computed it.

The postcard at the top works the same way. The fog on it keeps moving, which no postcard in a shop can do. If you haven’t turned it over yet, it’s still up there.

That’s the whole point of filters. Stop asking them to decorate things. Give them conditions.