How our dot-matrix SVG animation engine works: grid sampling, delay fields, SMIL vs CSS

A look inside LogoAnimator's engine: how a logo becomes a grid of LED-style dots, how one delay field per animation drives every frame, why loops need to be seam-free, and how the same scene ships as SMIL or CSS-in-SVG in a few kilobytes gzipped. With real code and measured file sizes.

Short answer: LogoAnimator turns a logo into a dot-matrix animation in four steps. It samples the image onto a grid of square cells, decides which cells are lit by comparing each cell's coverage against a threshold, gives every lit dot a phase offset from a delay field (a function of its column and row), and writes the result out as a self-contained SVG. The default file uses SMIL <animate> tags so it plays even inside an <img> tag; a CSS-in-SVG variant shares one @keyframes block across all dots for inline use. A looping Ring Reveal of our sample logo at 24 dots across measures 24 to 45 KB raw and about 2 KB gzipped. This post walks through each step with the real code from the engine, which is a zero-dependency TypeScript package that runs in the browser and on Cloudflare Workers.

If you just want the effect, open the editor, drop in a logo and export. If you want to know how the LED-display look is built, read on.

Step 1: sampling a logo onto a dot grid

Everything starts with a single number, density: how many dots sit along the logo's longest edge. Cells are always square, so the short edge gets proportionally fewer:

// util/grid.ts
if (srcWidth >= srcHeight) {
  const cols = d;
  const rows = Math.max(1, Math.round((d * srcHeight) / srcWidth));
  return { cols, rows };
}

The naive way to put a logo on a grid is to read one pixel per cell. That aliases badly: a thin stroke either hits the sample point or disappears. Instead, the sampler renders the logo at 4× the grid resolution per axis, centered with a symmetric contain-fit, and box-averages each 4 × 4 block down to one cell. Every cell ends up with a graded 0 to 1 value for red, green, blue and alpha instead of a yes/no sample. The offsets of that fit are kept as exact fractions, not rounded, so a left-right symmetric mark lands on a left-right symmetric grid.

Two refinements matter for real logos:

  • Bright minority colours. A plain average turns a thin white wordmark over a coloured fill into a muddy in-between colour. When a cell's bright pixels are a genuine minority (5 to 45% of its opacity) and clearly brighter than the rest (a luminance gap of at least 0.25), the cell keeps the minority's colour instead of the mean.
  • Thin line art. Hairline strokes can average out below the threshold everywhere, leaving a near-empty grid. If fewer than 10% of cells come out inked, the sampler estimates the stroke width, dilates the ink toward one cell's width and samples again, up to three attempts.

In the browser this runs on a <canvas>. The same box-average also has a DOM-free path that reads a raw RGBA buffer, which is how the MCP server for AI agents renders logos on Cloudflare Workers, where there is no canvas.

Step 2: thresholding, dot size and colour

The sampled grid is raw data. A pure function, buildModel, turns it into positioned dots, and it can re-run on every slider change without sampling the image again. Here is the core of the lit/unlit decision, slightly trimmed:

// model/buildModel.ts (excerpt)
if (a >= 0.04) {
  let v = mode === "alpha" ? a : REL_LUMA(r, g, b) * a;
  if (invert) v = 1 - v;
  sampledIntensity = clamp01(v);
  litBySampler = sampledIntensity >= threshold;
}

The intensity signal is picked automatically: if any cell is close to fully transparent (alpha below 0.05), the logo has a real transparent background and alpha is the signal. Otherwise, for a logo on a solid background, the engine falls back to luminance, and invert handles a dark logo on a light background. The default threshold is 0.5.

Each lit dot gets a centre in output units (col * cellSize + cellSize / 2), a radius and a colour. The radius is either fixed or scales with the cell's intensity, and a gap setting (0.15 by default) shrinks each dot to 85% of the cell width, which is what makes the dots read as separate LEDs rather than a pixelated image. With the default 10-unit cell, that's a radius of 4.25. The colour is either the sampled pixel colour or a single mono colour. An optional "ghost grid" draws the unlit cells faintly behind the lit ones, like a real LED panel.

Step 3: the animation model, one delay field per animation

An animation in the engine isn't a timeline per dot. It is a small definition with four parts:

  • tracks: what one dot does over one cycle, for example opacity [0, 1, 1, 0] at key times [0, 0.18, 0.45, 1],
  • durationMs: how long one cycle takes,
  • staggerMs: how far apart in time the first and last dot are,
  • delay: a function (col, row, grid) => 0..1 that says where in that stagger window each dot sits.

Every dot plays the same tracks. The only thing that differs between dots is the phase, and the delay field decides it. (A few fly-in animations also give each dot its own travel direction, but they still share one timing curve.) This is the definition of Ring Reveal:

def({ id: "ring-reveal", name: "Ring Reveal", category: "Reveal",
      delay: D.euclideanFromCenter, durationMs: 1800, staggerMs: 1500,
      tracks: [sweep()] })

and its delay field is the normalized distance from the grid centre:

export const euclideanFromCenter: DelayFn = (col, row, { cols, rows }) => {
  const cx = center(cols);
  const cy = center(rows);
  const d = Math.hypot(col - cx, row - cy);
  const max = Math.hypot(cx, cy) || 1;
  return d / max;
};
Ring Reveal, running live on our sample logo — the centre dots lead and the ring expands outward.

Swap the field and you get a different animation from the same tracks: column position gives a left-to-right scan, atan2 around the centre gives a radar sweep, an integer hash of the cell index gives a twinkle. Shimmer is just an opacity pulse [0.4, 1, 0.4] on a column scan. Ripple is the same distance-from-centre field as Ring Reveal, with a scale pulse [1, 1.45, 1] and an opacity pulse on top. In the editor these become parameters: direction, amplitude and so on.

"Delay 0 lights first"

Fields are written with one rule: a delay of 0 starts first. Sweeps run left to right, rings expand from the centre outward. The emitters turn the delay into a negative start time, so the dot that should lead is the one furthest along its cycle. That inverts the value, so one shared function flips it back:

// emit/shared.ts
export function resolveDelay(anim: AnimationDef, opts: EmitOptions): DelayFn {
  return opts.reverse
    ? anim.delay
    : (c, r, ctx) => 1 - anim.delay(c, r, ctx);
}

The "reverse" option (rings in instead of out, right to left instead of left to right) is just not doing that flip.

Why negative delays: no warm-up, no seam

A looping animation with positive per-dot delays has a warm-up problem: at time 0 only the first dot is moving and the rest wait their turn, so the first second looks different from every later second. A negative delay starts each dot mid-cycle, already at its steady-state phase, so the very first frame is a frame from the loop.

For loops, though, the phase field itself must be continuous. If two neighbouring dots get phases that differ by a large amount, you see a hard brightness edge wherever they meet, and it travels with the animation. We call it the "seam", and the classic source is an angle field: atan2 jumps from π to −π along one ray. The first version of Swirl had exactly this bug. It used a spiral field with a fractional angular winding and a 2000 ms stagger over a 2400 ms cycle. The fix is one coefficient:

// animations/delays.ts
// ang = angle around the centre (0..1), rad = distance from the centre (0..1)

// spiralOrder: fine for a one-shot reveal, seams in a loop
return (rad * 0.6 + ang * 0.4) % 1;

// spiralPhase: what Swirl uses now
return wrap01(rad * 0.6 + ang);

With a winding of exactly 1, crossing the angle seam changes the delay by exactly 1. If staggerMs is a whole multiple of durationMs (Swirl now uses 2400 and 2400), a jump of 1 in delay is a jump of one full cycle, which is invisible. The same fix swapped Swirl's triangle-wave pulses for raised-cosine ones, which ease into and out of the wrap instead of turning around on a sharp crease. Combined with tracks whose first and last values match, the loop has no seam anywhere. The engine pins these rules with tests: every track cyclic, stagger ≡ 0 (mod duration), and dots that are neighbours across the wrap render near-identically.

Swirl's seam-free loop — the angle wrap is a whole cycle, so no brightness edge travels through it.

A one-shot "play once" mode uses the opposite trick: positive delays, so the reveal really does start from the delay-0 dots, and it freezes on the last fully lit frame instead of the final keyframe. Reveal tracks fade back to 0 at the end so the loop restarts cleanly, so freezing on the raw last frame would leave the logo invisible.

Step 4: writing the SVG, SMIL vs CSS

The model plus the animation definition go to an emitter. There are two SVG emitters, and the difference between them is the most practical part of this post.

SMIL: one <animate> per dot

SMIL is SVG's built-in declarative animation. Each dot gets its own <animate> with the shared values and its own begin. This is a real dot from the Ring Reveal export of our sample logo:

<g transform="translate(65 25)">
  <circle r="4.25" fill="#e1e6ef">
    <animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.18;0.45;1"
      dur="1.8s" begin="-0.4876s" repeatCount="indefinite" calcMode="linear"/>
  </circle>
</g>

That begin="-0.4876s" is -(1 - delay) × staggerMs: this dot sits near the outer edge (delay ≈ 0.67), so it's only a little advanced; the four centre dots start at about −1.4 s and lead the ring. The upside of SMIL: the file is self-contained and animates everywhere an SVG image can go, including <img src="logo.svg">, CSS background-image and inline markup, in Chrome, Firefox and Safari. That's why it's the default download, and the format on the free plan.

CSS-in-SVG: one @keyframes for all dots

The CSS emitter writes the shared motion once and gives each dot only an animation-delay:

<style>
  @keyframes dm-i2xskz-ring-reveal{0%{opacity:0}18%{opacity:1}45%{opacity:1}100%{opacity:0}}
  .dm-i2xskz .dm-dot{transform-box:fill-box;transform-origin:center;
    animation:dm-i2xskz-ring-reveal 1800ms linear infinite}
</style>
<g class="dm-i2xskz">
  <g transform="translate(65 25)">
    <circle r="4.25" fill="#e1e6ef" class="dm-dot" style="animation-delay:-488ms"/>
  </g>
  …
</g>

Two details make this safe to paste into a page:

  • Scoped names. dm-i2xskz is a hash of everything that shapes the <style> block (animation, tracks, timing, trigger). Two different logo animations on one page get different class and keyframe names, so the second <style> can't retime the first. Identical exports get identical names, so re-exporting stays byte-stable.
  • Accessibility built in. The block also carries a prefers-reduced-motion rule that stops the animation and shows the logo fully lit, and the CSS output supports a hover trigger: the dots stay still until the SVG is hovered or focused by keyboard.

The trade-off: we treat CSS-in-SVG as an inline format, for pasting into your HTML, JSX or a component, where your page's own CSS context applies. For a file you'll drop into an <img> tag or an email, use SMIL. The SVG logo animation and CSS logo animation pages cover the two exports from the user's side; CSS-in-SVG is a Pro format.

How big is an animated dot-matrix SVG?

Here are measurements, not estimates. We ran the engine's own exporter on a 256 × 256 sample logo PNG from the site's public assets, using the default grid settings, licensed output (no watermark), and Node's zlib at gzip level 9:

AnimationGrid (lit dots)SMIL rawSMIL gzipCSS rawCSS gzip
Ring Reveal24 × 24 (206)45.4 KB2.1 KB23.9 KB2.0 KB
Shimmer24 × 24 (206)44.5 KB1.9 KB23.9 KB1.9 KB
Ripple24 × 24 (206)80.5 KB2.7 KB24.0 KB2.1 KB
Swirl24 × 24 (206)148.9 KB4.5 KB24.4 KB2.3 KB
Ring Reveal48 × 48 (600)132.3 KB5.8 KB69.1 KB5.3 KB
Shimmer48 × 48 (600)130.0 KB4.8 KB69.1 KB4.6 KB
Ripple48 × 48 (600)234.8 KB7.9 KB69.3 KB5.3 KB
Swirl48 × 48 (600)434.0 KB12.3 KB69.6 KB5.7 KB

What the numbers say:

  • Raw SMIL grows with the number of animated channels. Ring Reveal animates opacity only; Ripple adds scale; Swirl animates rotation, scale and opacity, so each dot carries three animation elements (one <animate> and two <animateTransform>). CSS stays almost flat, because the motion lives in one shared @keyframes block and each dot only adds a delay.
  • Gzip erases most of the gap. SMIL's per-dot tags repeat the same values/keyTimes strings hundreds of times, which is exactly what DEFLATE is good at. Over the wire, a looping 24 × 24 logo is a 2 to 5 KB request in either format.
  • Size scales with lit dots, not with the image. Doubling the density roughly triples the dot count for this logo, and the file grows with it. Pick the lowest density where the logo still reads.

One case where structure beats compression: a rotation of the whole grid. The editor's Revolve animation moves every dot along its own orbit, which as per-dot keyframes means one long keyframe list for each dot. Since every dot turns by the same angle at the same time, the emitters can replace all of them with a single rotate on the dot group. Measured on the same logo at 48 × 48: 521.1 KB raw / 78.4 KB gzipped for per-dot SMIL against 41.7 KB / 3.5 KB for the group rotation, and 1041.4 KB / 122.2 KB against 67.1 KB / 3.9 KB for CSS. The engine only takes that shortcut when it is provably the same picture: no stagger, no easing, looping playback.

One timing model for SVG, canvas, GIF and video

The live editor preview isn't an SVG at all; it's a <canvas> renderer, and GIF, MP4 and WebM exports rasterize frames. They all sample the same thing. The canvas code computes each dot's position in its cycle with the same resolveDelay and the same per-track interpolation the SVG emitters use:

// render/frame.ts, looping mode
t = ((elapsedMs + phaseMs) % total) / total;

where phaseMs is the dot's delay times the stagger: the same number that becomes begin in SMIL and animation-delay in CSS. So the preview you tune is the loop you export.

The embeddable canvas player takes this one step further. Its runtime is built from the engine's own rt* functions, serialized with Function.prototype.toString() and concatenated into the snippet, so there's one implementation rather than a hand-maintained copy. A parity test checks that the serialized runtime and the module code produce the same frames.

FAQ

How do you make a dot matrix animation in SVG?

Sample the image onto a grid of square cells by averaging each cell's pixels, light the cells whose coverage passes a threshold, and draw one <circle> per lit cell. Then give every circle the same animation with a different start offset, computed from its position (distance from the centre for a ring, column for a scan). In SMIL that offset is the begin attribute; in CSS it is animation-delay.

How do I create an LED display effect with CSS?

Draw the dots as separate shapes with a visible gap between them (dots at about 85% of the cell width is a good start), optionally add faint unlit dots behind them as a ghost grid, and animate opacity with one shared @keyframes rule plus a per-dot negative animation-delay. Negative delays start every dot mid-cycle, so the loop has no warm-up. LogoAnimator's CSS export generates exactly this, scoped so several can share a page.

Should I use SMIL or CSS for an animated SVG logo?

Use SMIL when the SVG is a file: it animates in an <img> tag, as a CSS background and inline, in Chrome, Firefox and Safari. Use CSS-in-SVG when you inline the markup into your HTML or a component and want a smaller raw file, scoped class names, a reduced-motion fallback and a hover trigger. After gzip the two are close in size.

How big is an animated dot-matrix SVG?

In our measurements, a 24 × 24 grid of our sample logo was 24 to 149 KB raw depending on animation and format, and 1.9 to 4.5 KB gzipped. At 48 × 48 the gzipped size was 4.6 to 12.3 KB. Size grows with the number of lit dots and, for SMIL, with the number of animated channels.

Why does my looping SVG animation have a visible seam?

Usually because the per-dot phase jumps somewhere in space: an angle field wraps from π to −π, or a wave wraps from 1 back to 0. Neighbouring dots on either side then sit at very different points of the cycle. Fix it by making the jump exactly one whole cycle (integer winding, and a stagger that is a whole multiple of the duration) and by making every track end where it starts.

Is LogoAnimator a pixel art converter?

Not exactly. It samples your logo onto a dot grid, so the result is pixel-like, but the output is vector: each dot is an SVG shape, so it stays sharp at any size, and the motion comes from 51 built-in animations. It's deterministic: the same logo and settings always produce the same file.

Try it on your logo

The dot-matrix logo generator is the quickest way to see the sampling step on your own mark, and the editor lets you change density, threshold and gap while the preview updates. The free plan exports the SMIL file, with a small watermark, using 9 animations; Pro ($5/month) adds the CSS-in-SVG export, Lottie, GIF and video. If you'd rather have your AI assistant do it, the same engine is available as an MCP tool for agents, and the companion post on animating a logo with ChatGPT or Claude compares that with a hand-prompted SVG.

See it on your own logo

Everything above works on your logo too — free to try, right in the browser, no signup.

Animate your logo