All writing

Code sharing is caring

A 3 KB animated wordmark, with no JavaScript, on the card that welcomes new students at PDR (Papo de Residência). I learned it from Mike Bespalov and I'm giving it back here, step by step.

Gabriel Souza5 min read

Mike Bespalov, the founder of Refero, posted an animated wordmark that turned out to be an SVG of about 3 KB. No canvas, no WebGL, no video, not a single line of JavaScript. I couldn't stop looking at it. The next day he put out a twenty-second video showing how he made it.

It's the gesture I admire most in people who make products: opening up your own code so someone else can go further. Code sharing is caring. This post follows that idea, one part at a time: his code, what I give back, and where it turns into care. For me, that's the card that welcomes new students at PDR (Papo de Residência), the medical residency prep program in Brazil where I work.

Here's the result, running in your browser:

Code

1. One plain path. Type the name in a font you like and convert it to outlines. I used Helvetica Neue Bold, slightly stretched and with tighter gaps. The letters become a single vector shape.

The PDR wordmark in a flat mid grey, with no effects.

2. Inner shadow. Blur the shape and keep only the inside edge. It's Figma's inner shadow, with no offset and a blur of about half the stroke. The letter gets volume, like a tube.

The same grey wordmark with an inner shadow: the inside edge of each letter darker and the core lighter.
<filter id="material" color-interpolation-filters="sRGB">
  <feGaussianBlur in="SourceAlpha" stdDeviation="13"/>
  <feComposite in2="SourceAlpha" operator="arithmetic"
               k2="-1" k3="1"/>
  <feBlend in="SourceGraphic" mode="overlay"/>
</filter>

3. A stripe that never stops. The fill becomes a white, black and white gradient, tilted about 36°, like a \ bar, that repeats and slides forever. That's the whole animation. Where the white part passes, the letter lightens; where the black part passes, it darkens.

The wordmark in shades of grey with the stripe of light passing: the letters lighten and darken in sequence.
<linearGradient id="stripe" gradientUnits="userSpaceOnUse"
                x2="327.3" y2="-237.8" spreadMethod="repeat">
  <stop stop-color="#fff"/>
  <stop offset="0.109" stop-color="#fff"/>
  <stop offset="0.409" stop-color="#000"/>
  <stop offset="0.7" stop-color="#000"/>
  <stop offset="1" stop-color="#fff"/>
  <animate attributeName="x1" from="0" to="500"
           dur="4.4s" repeatCount="indefinite"/>
  <animate attributeName="x2" from="327.3" to="827.3"
           dur="4.4s" repeatCount="indefinite"/>
</linearGradient>

Something I only found out after publishing: in Safari, and in every browser on the iPhone, the stripe stood still. The first version slid the gradient with animateTransform on gradientTransform, and WebKit doesn't animate that. Moving its two ends (x1 and x2) gives exactly the same motion, and Safari plays it too.

4. Grey becomes color. A table swaps each shade of grey for a color, like Photoshop's Gradient Map. Where the grey is dark, the letter turns navy; where the stripe passes, the other colors show up. The yellow and the pink stay narrow and hug the outline, and that thin edge is what draws the letter. If you're into a more hands-on process, you can build the still look by hand in Photoshop, which has every piece. Figma doesn't get there: the gradient map is exactly what it's missing.

The wordmark with the color applied: navy, and the stripe passing through the letters in light blue, yellow and pink.

The table comes out of a script that reads the tokens from the CSS. The last color has to be exactly the card's, or a rectangle shows up around the word.

In the dark theme the order flips: the core turns white and the edge ends on a blue lighter than the card. In my first version it ended on a navy darker than the background, and the letter became a hole and vanished whole when the stripe went by.

5. Blur and grain. A blur softens everything and a fine noise adds film texture. The blur smears the edge of the letter, which runs through every grey and gets a ring of color around it. Because the bar is tilted, it cuts the letters diagonally. They disappear one at a time, in pieces and half-moons, and come back the same way.

The final wordmark: navy with a ring of light blue, yellow and pink around it, blurred and grainy, swept by the stripe of light.
<feGaussianBlur stdDeviation="7.3"/>
<feTurbulence type="fractalNoise" baseFrequency="4"/>

Sharing

You could ask your Claude Code to build it from scratch. But if you're not quite that token maxxing, I wrote a prompt with the whole recipe, with both scripts ready: one pulls the word's outline straight from the font, the other builds both SVGs with your colors. You give it the word, the font file and the palette. I tested it with another word, another font and other colors, and it worked on the first try.

prompt-gradient-wordmark.md
# Prompt: animated gradient wordmark in a ~3 KB SVG

Paste this into your agent (Claude Code, Cursor, whatever you use).

## What I want

An animated wordmark in a single SVG file of about 3 KB, with no JavaScript, no canvas and no
WebGL. A stripe of light sweeps the word forever, and every shade of grey becomes a color from
my brand. The core of the letters stays dark and the color shows at the edge and where the
stripe passes. I want one version for the light theme and one for the dark.

The technique is by Mike Bespalov: https://x.com/bbssppllvv/status/2104747296671883312
This prompt reproduces the version Gabriel Souza made for PDR, and his files work as a
reference: https://gsouza.xyz/writing/pdr-gradient/pdr-claro.svg and
https://gsouza.xyz/writing/pdr-gradient/pdr-escuro.svg

## Before you start, ask me

1. **The word** and **the font file** (.ttf or .otf). Prefer a heavy weight (Bold, Black): the
   effect happens inside the stroke, and a thin stroke can't hold it.
2. **The light ramp**: 8 to 16 hex colors, from the core of the letter to its edge. The first
   is very dark and the last is exactly the color of the background the wordmark sits on.
3. **The dark ramp**: the opposite. The first is very light and the last is exactly the dark
   background color.

Don't start without these three answers.

## Rules (break none of them)

- **Never draw the letters by hand.** No `stroke`, thick lines or made-up curves. The outline
  comes from the font, through `outline.py` below, and becomes a single `<path>` with a `fill`.
- **Never make up colors.** Use only the ramps I give you, in the order I give them.
- **Never write the color tables by hand.** They come out of `build_wordmark.py` below.
- **Don't change the SVG template.** `build_wordmark.py` already has the whole file. You only
  edit the ramps, the backgrounds and the outline file.
- **The `feFlood` is always `#fff`, in the dark theme too.** The dark background comes from the
  end of the table, not from the flood. If you set the flood to the dark color, the background
  comes out light.
- **Use these ramp positions:** the dark colors from 0 to about .55, the edge colors squeezed
  between .6 and .83 and the background color from .88 to 1. That's what lets the dark rule and
  keeps the color at the edge. The first edge color takes only about .05: the narrow band is what
  draws the outline. Follow the example's positions and spread them out
  again if my ramp has a different number of colors.
- **In the dark theme, the color before the background is lighter than the background.** If it's
  darker, the letter turns into a hole and vanishes whole when the stripe goes by.
- Keep `gradientUnits="userSpaceOnUse"` (without it, the whole word blinks instead of being
  swept) and `color-interpolation-filters="sRGB"` (without it, the colors come out washed out).

## How to build it

**1. The outline.** Run `outline.py` with the font and the word. It measures the word, scales
the letters to 107 units tall (every number in the effect is for that scale) and saves the
outline. Needs `pip install fonttools`.

```
python3 outline.py font.ttf WORD > outline.txt
```

```python
"""Outline of a word from a font file. Needs: pip install fonttools

Usage: python3 outline.py font.ttf WORD
Prints the width and height on the first line and the path's d on the second.
"""
import sys
from fontTools.ttLib import TTFont
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.transformPen import TransformPen
from fontTools.pens.boundsPen import BoundsPen

font, text = TTFont(sys.argv[1]), sys.argv[2]
glyphs, cmap = font.getGlyphSet(), font.getBestCmap()
HEIGHT, TRACK = 107, -2  # letter height and extra space between letters, in SVG units


def layout(scale):
    x, items = 0, []
    for ch in text:
        name = cmap[ord(ch)]
        items.append((name, x))
        x += glyphs[name].width * scale + TRACK
    pen = BoundsPen(glyphs)
    for name, dx in items:
        glyphs[name].draw(TransformPen(pen, (scale, 0, 0, -scale, dx, 0)))
    return items, pen.bounds


_, (x0, y0, x1, y1) = layout(1)
scale = HEIGHT / (y1 - y0)
items, (x0, y0, x1, y1) = layout(scale)
pen = SVGPathPen(glyphs, ntos=lambda v: ("%.1f" % v).rstrip("0").rstrip("."))
for name, dx in items:
    glyphs[name].draw(TransformPen(pen, (scale, 0, 0, -scale, dx - x0, -y0)))
print(round(x1 - x0, 1), round(y1 - y0, 1))
print(pen.getCommands())
```

**2. The two SVGs.** Replace the ramps and backgrounds in `build_wordmark.py` with mine and run
it. It outputs `wordmark-light.svg` and `wordmark-dark.svg`. Python 3 only.

```
python3 build_wordmark.py outline.txt
```

```python
"""Builds the light and dark wordmark from the outline and two ramps. Python 3 only.

Usage: python3 build_wordmark.py outline.txt
outline.txt is the output of outline.py: "WIDTH HEIGHT" on the first line, the path's d on the second.
Edit only the ramps and backgrounds. Don't change the SVG template.
"""
import math, sys


# The outline comes from the font, never drawn by hand: letters 107 tall, corner at (0, 0)
size, PATH = open(sys.argv[1]).read().strip().split("\n", 1)
WIDTH, HEIGHT = (float(v) for v in size.split())

# Ramps: (position from 0 to 1, hex color). 0 is the core of the letter, 1 is the background.
# From 0.88 on, always the exact background color.
LIGHT_BG = "#fefefe"
LIGHT = [(0, "#09243f"), (.14, "#0d375f"), (.26, "#12497e"), (.36, "#165b9e"), (.44, "#457cb1"),
         (.5, "#739dc5"), (.55, "#d0deec"), (.6, "#e0b367"), (.65, "#dd6fa3"), (.72, "#e48cb5"),
         (.78, "#eba9c8"), (.83, "#f8e2ed"), (.88, LIGHT_BG), (1, LIGHT_BG)]
DARK_BG = "#292c33"
DARK = [(0, "#ffffff"), (.2, "#fbf0f5"), (.32, "#f1c5da"), (.42, "#e48cb5"), (.5, "#dd6fa3"),
        (.56, "#e0b367"), (.62, "#a2bdd8"), (.68, "#739dc5"), (.74, "#457cb1"), (.79, "#165b9e"),
        (.84, "#12497e"), (.88, DARK_BG), (1, DARK_BG)]

# The stripe: 44 of white, 121 of transition, 118 of black, 121 of transition, tilted 36°, like a backslash.
# One cycle takes 4.4 s.
WHITE, RAMP, BLACK, ANGLE, SPEED = 44.07, 121.19, 118.05, 36, 91.93


def channel(stops, i, t):
    for (a, ca), (b, cb) in zip(stops, stops[1:]):
        if a <= t <= b:
            va, vb = (int(c[1 + 2 * i:3 + 2 * i], 16) / 255 for c in (ca, cb))
            return va + (vb - va) * (t - a) / (b - a)


def table(stops, i):
    # 32 points, so the narrow bands show up; three decimals, plus a tiny margin: the browser rounds down
    return " ".join("%.3f" % min(1, channel(stops, i, k / 31) + .0015) for k in range(32))


def svg(stops, uid):
    w, h = round(WIDTH + 44), round(HEIGHT + 44)
    p = WHITE + 2 * RAMP + BLACK
    a = math.radians(ANGLE)
    f = lambda x: "%.3f" % (x / p)
    return f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="-22 -22 {w} {h}">
<defs>
<linearGradient id="stripe-{uid}" gradientUnits="userSpaceOnUse" x2="{p * math.cos(a):.1f}" y2="{-p * math.sin(a):.1f}" spreadMethod="repeat">
<stop stop-color="#fff"/><stop offset="{f(WHITE)}" stop-color="#fff"/><stop offset="{f(WHITE + RAMP)}" stop-color="#000"/><stop offset="{f(WHITE + RAMP + BLACK)}" stop-color="#000"/><stop offset="1" stop-color="#fff"/>
<animate attributeName="x1" from="0" to="{p / math.cos(a):.1f}" dur="{p / SPEED:.2f}s" repeatCount="indefinite"/>
<animate attributeName="x2" from="{p * math.cos(a):.1f}" to="{p * math.cos(a) + p / math.cos(a):.1f}" dur="{p / SPEED:.2f}s" repeatCount="indefinite"/>
</linearGradient>
<filter id="material-{uid}" color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceAlpha" stdDeviation="13"/>
<feComposite in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feBlend in="SourceGraphic" mode="overlay"/>
<feComponentTransfer><feFuncR type="linear" slope="1.45"/><feFuncG type="linear" slope="1.45"/><feFuncB type="linear" slope="1.45"/></feComponentTransfer>
</filter>
<filter id="color-{uid}" filterUnits="userSpaceOnUse" x="-22" y="-22" width="{w}" height="{h}" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="7.3" result="soft"/>
<feFlood flood-color="#fff"/>
<feComposite in="soft" operator="over" result="flat"/>
<feTurbulence type="fractalNoise" baseFrequency="4"/>
<feColorMatrix values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1"/>
<feComposite in="flat" operator="arithmetic" k2="1" k3=".14" k4="-.07"/>
<feComponentTransfer>
<feFuncR type="table" tableValues="{table(stops, 0)}"/>
<feFuncG type="table" tableValues="{table(stops, 1)}"/>
<feFuncB type="table" tableValues="{table(stops, 2)}"/>
</feComponentTransfer>
</filter>
</defs>
<g filter="url(#color-{uid})"><path d="{PATH}" fill="url(#stripe-{uid})" filter="url(#material-{uid})"/></g>
</svg>
'''


open("wordmark-light.svg", "w").write(svg(LIGHT, "light"))
open("wordmark-dark.svg", "w").write(svg(DARK, "dark"))
print("ok: wordmark-light.svg and wordmark-dark.svg")
```

What each part does, so you can check:

- **Inner shadow** (`material`): blurs the shape and keeps only the inside edge. With a blur of
  13, each stroke turns into a tube.
- **Stripe** (`stripe`): a white, black and white gradient, tilted 36° like a `\` bar, that
  repeats and moves forever. That's the whole animation. It cuts the letter diagonally, and the
  letter disappears in pieces and half-moons. What moves are the gradient's ends (`x1` and `x2`).
  Don't replace this with `animateTransform` on `gradientTransform`: in Safari and on the iPhone the
  stripe stands still.
- **Color** (`color`): a 7.3 blur, a white background, grain and the table that swaps each grey
  for the ramp's color. The blur smears the edge of the letter, and that edge gets the ring of
  colors.

**3. Reduced motion.** Make a still frame of each version, as WebP, by pausing the animation
(`svg.pauseAnimations()` and `svg.setCurrentTime(3.4)`) and taking a screenshot. On the page:

```html
<picture>
  <source media="(prefers-reduced-motion: reduce)" srcset="wordmark-light-still.webp">
  <img src="wordmark-light.svg" alt="…">
</picture>
```

## How to check before handing it over

Render frames at 0, 1, 2, 3, 4 and 5 seconds, in both themes, and show me. Compare them with the
reference files. Check that:

- the letters are the font's, with real corners and curves, nothing that looks like a marker;
- the stripe sweeps the word from one side to the other, in Safari too, and doesn't blink;
- the core is dark and the colors show only at the edge and where the stripe passes;
- the colors are my ramp's, in order, no rainbow;
- the background is my background color, with no rectangle around the word;
- in the dark theme, the letter never turns into a hole darker than the background;
- each file is about 3 or 4 KB.

If you'd rather skip the agent, here's the whole file, exactly as it runs at the top of this post. The dark theme version only changes the three lines of the color table, and it's here.

pdr-claro.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-22 -22 348 151">
  <defs>
    <linearGradient id="stripe-claro" gradientUnits="userSpaceOnUse" x2="327.2" y2="-237.8" spreadMethod="repeat">
      <stop stop-color="#fff"/>
      <stop offset="0.109" stop-color="#fff"/>
      <stop offset="0.409" stop-color="#000"/>
      <stop offset="0.700" stop-color="#000"/>
      <stop offset="1" stop-color="#fff"/>
      <animate attributeName="x1" from="0" to="500.0" dur="4.4s" repeatCount="indefinite"/>
      <animate attributeName="x2" from="327.2" to="827.2" dur="4.4s" repeatCount="indefinite"/>
    </linearGradient>
    <filter id="material-claro" color-interpolation-filters="sRGB">
      <feGaussianBlur in="SourceAlpha" stdDeviation="13"/>
      <feComposite in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1"/>
      <feBlend in="SourceGraphic" mode="overlay"/>
      <feComponentTransfer>
        <feFuncR type="linear" slope="1.45"/>
        <feFuncG type="linear" slope="1.45"/>
        <feFuncB type="linear" slope="1.45"/>
      </feComponentTransfer>
    </filter>
    <filter id="color-claro" filterUnits="userSpaceOnUse" x="-22" y="-22" width="348" height="151" color-interpolation-filters="sRGB">
      <feGaussianBlur stdDeviation="7.3" result="soft"/>
      <feFlood flood-color="#fff"/>
      <feComposite in="soft" operator="over" result="flat"/>
      <feTurbulence type="fractalNoise" baseFrequency="4"/>
      <feColorMatrix values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1"/>
      <feComposite in="flat" operator="arithmetic" k2="1" k3=".14" k4="-.07"/>
      <feComponentTransfer>
        <feFuncR type="table" tableValues="0.037 0.04 0.044 0.048 0.051 0.056 0.061 0.067 0.072 0.077 0.082 0.087 0.15 0.225 0.307 0.404 0.57 0.805 0.856 0.877 0.869 0.879 0.892 0.906 0.92 0.95 0.978 0.993 0.998 0.998 0.998 0.998"/>
        <feFuncG type="table" tableValues="0.143 0.16 0.177 0.194 0.211 0.23 0.249 0.268 0.287 0.309 0.332 0.355 0.402 0.454 0.513 0.582 0.699 0.864 0.769 0.635 0.463 0.481 0.534 0.592 0.653 0.782 0.907 0.978 0.998 0.998 0.998 0.998"/>
        <feFuncB type="table" tableValues="0.249 0.277 0.306 0.335 0.364 0.396 0.428 0.461 0.494 0.534 0.574 0.615 0.646 0.676 0.711 0.753 0.823 0.922 0.607 0.466 0.618 0.668 0.701 0.739 0.779 0.863 0.943 0.986 0.998 0.998 0.998 0.998"/>
      </feComponentTransfer>
    </filter>
  </defs>
  <g filter="url(#color-claro)">
    <path d="M24.7 50.4V18.3H43.9Q48.2 18.3 52.1 18.9Q56.1 19.5 59 21.2Q62.1 23 63.8 26.1Q65.6 29.2 65.6 34.3Q65.6 39.5 63.8 42.6Q62.1 45.8 59 47.5Q56.1 49.2 52.1 49.8Q48.2 50.4 43.9 50.4ZM0 0V107.1H24.7V68.7H50.7Q61.3 68.7 68.7 65.8Q76.1 62.8 80.7 58Q85.4 53.2 87.5 47Q89.7 40.8 89.7 34.3Q89.7 27.8 87.5 21.6Q85.4 15.5 80.7 10.6Q76.1 5.8 68.7 2.9Q61.3 0 50.7 0ZM123.6 87.3V19.8H141.2Q150.3 19.8 156.5 22.3Q162.8 24.8 166.5 29.4Q170.4 34 172 40.6Q173.6 47.1 173.6 55.2Q173.6 64 171.2 70.2Q168.9 76.3 164.9 80.2Q161 84 156 85.6Q151 87.3 145.6 87.3ZM98.8 0V107.1H147.3Q160.2 107.1 169.8 103Q179.3 98.8 185.7 91.6Q192 84.4 195.2 74.5Q198.3 64.7 198.3 52.9Q198.3 39.6 194.5 29.7Q190.6 19.8 183.8 13.2Q177 6.6 167.5 3.3Q158.2 0 147.3 0ZM231.5 48.4V18.3H258.5Q267 18.3 271.3 21.8Q275.5 25.3 275.5 33.1Q275.5 41.2 271.3 44.8Q267 48.4 258.5 48.4ZM206.7 0V107.1H231.5V65.2H256.2Q265.4 65.2 269.5 69.2Q273.6 73 274.9 81.4Q275.8 87.9 276.3 94.9Q276.7 102 278.8 107.1H303.5Q301.8 104.8 301 101.6Q300.1 98.4 299.7 94.8Q299.3 91.2 299.1 87.8Q299 84.3 298.8 81.8Q298.5 77.7 297.6 73.7Q296.8 69.6 294.9 66.2Q293 62.8 290 60.4Q287 57.9 282.4 56.7V56.4Q291.9 52.8 296.1 45.9Q300.3 39 300.3 29.5Q300.3 23.4 298 18.1Q295.6 12.8 291.3 8.7Q287 4.6 280.9 2.3Q274.9 0 267.3 0Z" fill="url(#stripe-claro)" filter="url(#material-claro)"/>
  </g>
</svg>

Caring

At PDR, the wordmark opens the card that welcomes new students. To show it off I built a short video with Claude Opus 5.5: it renders each frame in the browser and ffmpeg stitches them together, no video editor.

If you make your own, send it to me on LinkedIn. I want to see which word and colors you picked.