Compartilhar código é cuidar
Um letreiro animado de 3 KB, sem JavaScript, no card que recebe o aluno novo do PDR (Papo de Residência). Aprendi com o Mike Bespalov e devolvo aqui, com o passo a passo.
O Mike Bespalov, fundador da Refero, postou um letreiro animado que é um SVG de uns 3 KB. Sem canvas, sem WebGL, sem vídeo, sem uma linha de JavaScript. Fiquei vidrado. No dia seguinte ele soltou um vídeo de vinte segundos mostrando como fez.
É o gesto que eu mais admiro em quem faz produto: abrir o próprio código pra outra pessoa ir mais longe. Compartilhar código é cuidar. O texto segue essa ideia, parte por parte: o código dele, o que eu devolvo e onde isso vira cuidado. No meu caso, é o card que recebe o aluno novo do PDR (Papo de Residência), a preparação pra residência médica onde eu trabalho.
Esse é o resultado, rodando no seu navegador:
Código
1. Um path simples. Escreve o nome numa fonte que te agrade e converte em contorno. Eu usei Helvetica Neue Bold, um pouco esticada e com os vãos apertados. As letras viram uma única forma vetorial.
2. Sombra interna. Desfoca a forma e guarda só a borda de dentro. É o inner shadow do Figma, sem deslocamento, com desfoque de mais ou menos metade do traço. A letra ganha volume, como um tubo.
<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. Uma faixa que não para. O preenchimento vira um degradê de branco, preto e branco,
inclinado uns 36°, como uma barra \, que se repete e desliza pra sempre. Essa é a animação inteira. Onde passa
a parte branca, a letra clareia; onde passa a preta, escurece.
<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>
Uma coisa que só descobri depois de publicar: no Safari, e em qualquer navegador do iPhone, a
faixa ficava parada. A primeira versão deslizava o degradê com animateTransform em
gradientTransform, e o WebKit não anima isso. Mover as duas pontas dele (x1 e x2) dá
exatamente o mesmo movimento, e aí o Safari também mostra.
4. O cinza vira cor. Uma tabela troca cada tom de cinza por uma cor, como o Mapa de gradiente do Photoshop. Onde o cinza é escuro, a letra fica azul-marinho; onde a faixa passa, aparecem as outras cores. O amarelo e o rosa ficam estreitos e colados no contorno, e é essa borda fina que desenha a letra. Se você curte um processo mais craft, dá pra montar o visual parado à mão no Photoshop, que tem todas as peças. No Figma não fecha: falta justamente o mapa de gradiente.
- blue-800#09243f0%
- blue-700#0d375f14%
- blue-600#12497e26%
- blue-500#165b9e36%
- blue-400#457cb144%
- blue-300#739dc550%
- blue-100#d0deec55%
- yellow-300#e0b36760%
- pink-500#dd6fa365%
- pink-400#e48cb572%
- pink-300#eba9c878%
- pink-100#f8e2ed83%
- fundo do card#fefefe88%
- white#ffffff0%
- pink-50#fbf0f520%
- pink-200#f1c5da32%
- pink-400#e48cb542%
- pink-500#dd6fa350%
- yellow-300#e0b36756%
- blue-200#a2bdd862%
- blue-300#739dc568%
- blue-400#457cb174%
- blue-500#165b9e79%
- blue-600#12497e84%
- fundo do card#292c3388%
A tabela sai de um script que lê os tokens do CSS. A última cor tem que ser exatamente a do card, senão aparece um retângulo em volta da palavra.
No tema escuro a ordem se inverte: o miolo fica branco e a borda termina num azul mais claro que o card. Na minha primeira versão ela terminava num marinho mais escuro que o fundo, e a letra virava um buraco e sumia inteira quando a faixa passava.
5. Desfoque e grão. Um desfoque suaviza tudo e um ruído fino dá textura de filme. O desfoque borra a borda da letra, que passa por todos os cinzas e ganha um anel de cor em volta. Como a barra é inclinada, corta as letras na diagonal. Elas somem uma de cada vez, em pedaços e meias-luas, e voltam do mesmo jeito.
<feGaussianBlur stdDeviation="7.3"/>
<feTurbulence type="fractalNoise" baseFrequency="4"/>
Compartilhar
Dá pra pedir pro seu Claude Code construir isso do zero. Mas se você não tá tão token maxxing assim, escrevi um prompt com a receita inteira, já com os dois scripts prontos: um tira o contorno da palavra direto da fonte, o outro monta os dois SVGs com as suas cores. Você passa a palavra, o arquivo da fonte e a paleta. Testei com outra palavra, outra fonte e outras cores, e deu certo de primeira.
# Prompt: letreiro animado em gradiente, num SVG de ~3 KB
Cole este texto no seu agente (Claude Code, Cursor, o que você usar).
## O que eu quero
Um letreiro animado num único arquivo SVG de uns 3 KB, sem JavaScript, sem canvas e sem WebGL.
Uma faixa de luz varre a palavra sem parar, e cada tom de cinza vira uma cor da minha marca. O
miolo das letras fica escuro e a cor aparece na borda e onde a faixa passa. Quero uma versão pro
tema claro e outra pro escuro.
A técnica é de Mike Bespalov: https://x.com/bbssppllvv/status/2104747296671883312
Este prompt reproduz a versão que o Gabriel Souza fez pro PDR, e os arquivos dele servem de
gabarito: https://gsouza.xyz/writing/pdr-gradient/pdr-claro.svg e
https://gsouza.xyz/writing/pdr-gradient/pdr-escuro.svg
## Antes de começar, me pergunte
1. **A palavra** e **o arquivo da fonte** (.ttf ou .otf). Prefira fonte bem pesada (Bold, Black):
o efeito acontece dentro do traço, e traço fino não segura.
2. **A rampa do tema claro**: de 8 a 16 cores em hex, do miolo da letra pra borda. A primeira é
bem escura e a última é exatamente a cor do fundo onde o letreiro vai ficar.
3. **A rampa do tema escuro**: o contrário. A primeira é bem clara e a última é exatamente a cor
do fundo escuro.
Não comece sem essas três respostas.
## Regras (não quebre nenhuma)
- **Nunca desenhe as letras à mão.** Nada de `stroke`, linhas grossas ou curvas inventadas. O
contorno sai da fonte, pelo `outline.py` abaixo, e vira um único `<path>` com `fill`.
- **Nunca invente cores.** Use só as rampas que eu te passar, na ordem que eu passar.
- **Nunca escreva as tabelas de cor na mão.** Elas saem do `build_wordmark.py` abaixo.
- **Não mude o modelo do SVG.** O `build_wordmark.py` já tem o arquivo inteiro. Você só edita as
rampas, os fundos e o arquivo de contorno.
- **O `feFlood` é sempre `#fff`, também no tema escuro.** O fundo escuro sai da ponta da tabela,
não do flood. Se você trocar o flood pela cor escura, o fundo sai claro.
- **Use estas posições na rampa:** as cores escuras de 0 a uns .55, as de borda espremidas entre
.6 e .83 e a cor do fundo de .88 até 1. É isso que deixa o escuro dominar e a cor só na borda.
A primeira cor de borda ocupa só uns .05: é a faixa estreita que desenha o contorno.
Siga as posições do exemplo e redistribua se a minha rampa tiver outro número de cores.
- **No tema escuro, a cor antes do fundo é mais clara que o fundo.** Se for mais escura, a letra
vira um buraco e some inteira quando a faixa passa.
- Mantenha `gradientUnits="userSpaceOnUse"` (sem ele, a palavra pisca inteira em vez de ser
varrida) e `color-interpolation-filters="sRGB"` (sem ele, as cores saem lavadas).
## Como construir
**1. O contorno.** Rode o `outline.py` com a fonte e a palavra. Ele mede a palavra, escala as
letras pra 107 unidades de altura (todos os números do efeito valem pra essa escala) e salva o
contorno. Precisa de `pip install fonttools`.
```
python3 outline.py fonte.ttf PALAVRA > contorno.txt
```
```python
"""Contorno de uma palavra a partir de um arquivo de fonte. Precisa de: pip install fonttools
Uso: python3 outline.py fonte.ttf PALAVRA
Imprime a largura e a altura na primeira linha e o d do path na segunda.
"""
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 # altura das letras e espaço extra entre elas, nas unidades do SVG
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. Os dois SVGs.** Troque as rampas e os fundos no `build_wordmark.py` pelos meus e rode. Ele
gera `letreiro-claro.svg` e `letreiro-escuro.svg`. Só usa Python 3.
```
python3 build_wordmark.py contorno.txt
```
```python
"""Gera o letreiro claro e o escuro a partir do contorno e de duas rampas. Só Python 3.
Uso: python3 build_wordmark.py contorno.txt
contorno.txt é a saída de outline.py: "LARGURA ALTURA" na primeira linha, o d do path na segunda.
Edite só as rampas e os fundos. Não mude o modelo do SVG.
"""
import math, sys
# O contorno vem da fonte, nunca desenhado à mão: letras com 107 de altura, canto em (0, 0)
size, PATH = open(sys.argv[1]).read().strip().split("\n", 1)
WIDTH, HEIGHT = (float(v) for v in size.split())
# Rampas: (posição de 0 a 1, cor em hex). 0 é o miolo da letra, 1 é o fundo.
# Do 0,88 em diante, sempre a cor exata do fundo.
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)]
# A faixa: 44 de branco, 121 de transição, 118 de preto, 121 de transição, inclinada 36°, como uma barra invertida.
# Um ciclo leva 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 pontos, pras faixas estreitas aparecerem; três casas, com uma folguinha: o navegador arredonda pra baixo
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("letreiro-claro.svg", "w").write(svg(LIGHT, "claro"))
open("letreiro-escuro.svg", "w").write(svg(DARK, "escuro"))
print("ok: letreiro-claro.svg e letreiro-escuro.svg")
```
O que cada parte faz, pra você conferir:
- **Sombra interna** (`material`): desfoca a forma e guarda só a borda de dentro. Com desfoque
13, cada traço vira um tubo.
- **Faixa** (`stripe`): um degradê branco, preto e branco, inclinado 36° como uma barra `\`, que
se repete e anda pra sempre. É a animação inteira. Ela corta a letra na diagonal, e a letra some
em pedaços e meias-luas. Quem anda são as pontas do degradê (`x1` e `x2`). Não troque por
`animateTransform` em `gradientTransform`: no Safari e no iPhone a faixa fica parada.
- **Cor** (`color`): desfoque de 7,3, fundo branco, grão e a tabela que troca cada cinza pela cor
da rampa. O desfoque borra a borda da letra, e é ela que ganha o anel de cores.
**3. Movimento reduzido.** Gere um quadro parado de cada versão, em WebP, pausando a animação
(`svg.pauseAnimations()` e `svg.setCurrentTime(3.4)`) e fotografando. Na página:
```html
<picture>
<source media="(prefers-reduced-motion: reduce)" srcset="letreiro-claro-parado.webp">
<img src="letreiro-claro.svg" alt="…">
</picture>
```
## Como conferir antes de me entregar
Renderize quadros em 0, 1, 2, 3, 4 e 5 segundos, nos dois temas, e me mostre. Compare com os
arquivos de gabarito. Confira:
- as letras são as da fonte, com cantos e curvas de verdade, sem cara de traço de caneta;
- a faixa varre a palavra de um lado pro outro, também no Safari, e não pisca;
- o miolo é escuro e as cores aparecem só na borda e onde a faixa passa;
- as cores são as da minha rampa, na ordem, sem arco-íris;
- o fundo é a cor do meu fundo, sem retângulo em volta da palavra;
- no escuro, a letra nunca vira um buraco mais escuro que o fundo;
- cada arquivo tem uns 3 ou 4 KB.
Se preferir pular o agente, aqui está o arquivo inteiro, do jeito que ele roda no topo deste texto. A versão do tema escuro muda só as três linhas da tabela de cores, e está aqui.
<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>
Cuidar
No PDR, o letreiro abre o card que recebe o aluno novo. Pra apresentar, construí um vídeo curto com o Claude Opus 5.5: ele monta cada quadro no navegador e o ffmpeg junta tudo, sem editor de vídeo.
Se fizer o seu, me manda no LinkedIn. Quero ver que palavra e que cores você escolheu.
Gabriel Souza