Introduction
There is no sprite sheet, no rotate keyframe, and no per-icon JavaScript timer here — just a handful of emoji dropped into span tags, an SVG filter that never leaves the document, and a single animated attribute inside that filter. The "wiggle" is not a transform at all: it is a displacement map redrawing where every pixel of the glyph sits, frame by frame, based on a noise field that keeps reseeding itself.
The trickiest part is not making one icon wiggle — that is a few lines. It is making twelve of them wiggle without looking like a single sprite sheet stamped out twelve times, which is where a second and third copy of the filter, offset in timing, earns its keep.
Why feTurbulence plus feDisplacementMap reads as a hand-jiggled sticker in a way that CSS rotate keyframes never quite do — how animating the seed attribute inside <feTurbulence> drives the whole effect without a single line of JavaScript — how three filter instances with different seed cycles and durations keep a group of icons from wiggling in visible sync — and why a monochrome emoji font is what lets plain CSS color actually recolor a glyph that is normally a fixed, unchangeable color.
Step 1 — feTurbulence Generates the Noise, feDisplacementMap Applies It
feTurbulence draws a field of Perlin-style noise that is invisible on its own — it never gets painted to the screen directly. Instead feDisplacementMap reads that noise as a per-pixel offset map and nudges the source graphic’s pixels around based on it, using the red channel for horizontal displacement and the green channel for vertical. The result looks like the glyph itself is being tugged at, not rotated or scaled.
<filter id="wobble-a" x="-30%" y="-30%" width="160%" height="160%">
<feTurbulence
type="turbulence"
baseFrequency="0.035"
numOctaves="2"
seed="2"
result="n" />
<feDisplacementMap
in="SourceGraphic"
in2="n"
scale="4.5"
xChannelSelector="R"
yChannelSelector="G" />
</filter>The x/y/width/height on the filter itself are worth keeping — a filter region defaults to a tight box around the source element, and a displacement scale of even a few pixels can clip the edges of a glyph if the region isn’t padded outward first.
Step 2 — <animate> on the seed Attribute
A static seed produces a static distortion — the icon just sits there permanently nudged sideways. The wiggle comes entirely from an <animate> child on feTurbulence that steps the seed attribute through a short list of values with calcMode="discrete", which regenerates the whole noise field each time it lands on a new value instead of interpolating smoothly between two noise fields.
<feTurbulence type="turbulence" baseFrequency="0.035" numOctaves="2" seed="2" result="n">
<animate
attributeName="seed"
values="2;7;13;2"
dur="4.5s"
calcMode="discrete"
repeatCount="indefinite" />
</feTurbulence>The icon never knows it’s wiggling — it only knows the noise field underneath it just got replaced. Every displaced pixel repaints itself against the new field automatically, with no transform, no requestAnimationFrame, and no CSS keyframe at all.
Step 3 — wobble-a, wobble-b, wobble-c
Pointing every icon at the same filter id works, but it means every icon shares one noise field, so the whole group visibly lurches in unison on every seed step — the tell of a single sprite sheet, not twelve independent stickers. The fix is three filters instead of one, each with its own starting seed, its own seed cycle order, and a slightly different duration, then icons are assigned round-robin across the three.
.compass, .bird, .bee, .cloud { filter: url('#wobble-a') drop-shadow(2px 3px 2px #00000025); }
.leaf, .bloom, .snail, .butter { filter: url('#wobble-b') drop-shadow(2px 3px 2px #00000025); }
.sun, .berry, .mush, .star { filter: url('#wobble-c') drop-shadow(2px 3px 2px #00000025); }
/* wobble-a: seed 2;7;13;2 dur 4.5s
wobble-b: seed 5;11;18;5 dur 5.2s
wobble-c: seed 9;3;16;9 dur 3.8s */Three staggered durations (4.5s, 5.2s, 3.8s) means the group as a whole only realigns back to a shared beat roughly every 78.28 seconds — long enough that no viewer ever notices a loop, even though the underlying animation is deterministic and repeats.
Step 4 — Why Noto Emoji, Specifically
Most system emoji fonts (Apple Color Emoji, Segoe UI Emoji) are baked-in color glyphs — CSS color on them does nothing, because the glyph itself already carries its own palette. Noto Emoji is different: it’s a single-color, outline-style glyph set, which means it behaves like any other text character and takes a normal CSS color rule. That’s what lets each icon get its own tint via a plain class instead of needing twelve hand-colored SVGs.
.icon {
font-family: "Noto Emoji", sans-serif;
font-size: clamp(44px, 6vw, 68px);
}
.compass { color: #b5542b; }
.leaf { color: #2b4a42; }
.heart { color: #d9636e; }
.wand { color: #8576e4; }Step 5 — drop-shadow, Not box-shadow
Because the icons are inline glyphs with no box to speak of, box-shadow isn’t an option — it would draw a rectangle behind the character, not hug its silhouette. filter: drop-shadow() instead traces the actual alpha shape of the glyph, so a leaf or a star gets a shadow shaped like a leaf or a star. It’s chained directly after the wobble filter on the same property, so the shadow itself gets displaced along with the glyph and never looks like it’s floating separately from the icon it belongs to.
Chain drop-shadow after a distortion filter, not before it, if you want the shadow to move with the distortion. Filters apply left to right, so feDisplacementMap-style effects need to come first in the chain for the shadow that follows to inherit the same wiggle.
Tuning Reference
| Property | Example | Effect |
|---|---|---|
| baseFrequency | 0.035 | Noise granularity — lower is a slower, broader wobble; higher is jittery and fast |
| numOctaves | 2 | Layers of noise detail combined; higher adds finer, busier distortion |
| feDisplacementMap scale | 4.5 | How far pixels get pushed — the actual wiggle distance in px |
| animate dur | 3.8s – 5.2s | Staggered per filter so a group of icons never re-syncs visibly |
| drop-shadow offset | 2px 3px 2px #00000025 | Soft contact shadow shaped to each glyph’s silhouette |
Full Source Code
The whole thing is a single self-contained HTML file — the three SVG filters, the Noto Emoji import, and a flat wrap of tinted, wiggling icons, with no build step and no external dependencies.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/wiggle-icons.html