Luz volumétrica, anel de névoa e paralax 3D: reconstruindo o hero em CSS/SVG puro
Cinco técnicas usadas no redesign do hero da home — feixe volumétrico estilo Grok, borda de foto dissolvida, anel de névoa orgânico, paralax 3D no mouse e remoção de fundo com IA — ensinadas como prompts e como código.

O hero da home mudou de novo. Semana passada era um tríptico de retratos transparentes em crossfade sobre um blob ciano/magenta achatado. Esta semana o blob sumiu. No lugar entraram: um feixe branco volumétrico vindo da direita (à la grok.com), um anel de névoa orgânico dissolvendo a borda do retrato no cosmos, e um paralax 3D que segue o cursor.
Sem WebGL. Sem engine 3D. Sem asset novo. CSS puro + filtros SVG + algumas CSS custom properties dirigidas por requestAnimationFrame.
Este post é duas coisas ao mesmo tempo: os templates de prompt que usei para guiar um pair-programmer de IA por esse trabalho, e o código essencial por trás de cada técnica. Se você é AI Product Engineer como eu, as duas camadas importam — saber pedir é metade da implementação.
Resultado ao vivo: home. PR: #36.
Por que isso importa
O retrato estava tecnicamente correto, mas visualmente preso num retângulo. Uma foto sobre fundo escuro sempre se entrega — tem uma borda dura, um halo de matte, uma sensação de "adesivo colado no canvas". O novo passe ataca exatamente esse problema por três frentes: dissolver a borda (máscara), pintar vapor por cima dela (anel de névoa), e recontextualizar a figura inteira num feixe de luz (luz volumétrica de fundo). O paralax adiciona a peça final — a cena reage a você, então deixa de parecer imagem parada.
Para um AI Product Engineer, o hero também é sinalização. Se o primeiro frame que o visitante vê parece template do Bootstrap com foto de stock, o resto do portfólio não ganha chance.
1. Feixe de luz volumétrica em SVG/CSS puro
O prompt
"Curto o efeito de luz/fumaça volumétrica do grok.com. Recria em CSS/SVG puro — sem asset, sem WebGL, sem Three.js. Branco, não tingido de ciano. Vem da borda direita e sangra por uns 50% do hero."
A técnica
Cinco radial-gradient blooms sobrepostos em offsets, tamanhos e blur radii diferentes, mais uma camada SVG de feTurbulence que adiciona textura fibrosa orgânica. O insight chave é que um único bloom grande sempre parece lanterna. Empilhar vários em posições, tamanhos e fases de animação ligeiramente diferentes cria uma distribuição não-uniforme e fibrosa que o olho lê como névoa volumétrica.
// src/components/fx/volumetric-light.tsx — abreviado
<div style={{
top: "-30%", right: "-25%", width: "95%", height: "160%",
background:
"radial-gradient(ellipse 55% 55% at 80% 50%, " +
"rgba(255,255,255,0.55) 0%, rgba(235,245,255,0.32) 18%, " +
"rgba(186,230,253,0.14) 38%, rgba(125,211,252,0.06) 58%, transparent 78%)",
animation: "vlight-breathe 11s ease-in-out infinite",
filter: "blur(40px)",
}} />
// + 3 blooms com offsets, tamanhos e mix-blend-mode: screen diferentes
// + 1 linear-gradient costurando a borda direita
// + 1 camada SVG de fibra (abaixo)
Os keyframes são minúsculos — opacity 0.78 → 1.0 e um translate de ~1.5% — mas quebram a estagnação de um gradient parado. Os tempos são deliberadamente fora de sincronia (11s, 14s, 17s, 21s) pra as camadas nunca se alinharem.
A camada de fibra — e a pegadinha
Pra fazer a névoa parecer atmosfera real, desloquei um radial-gradient com feTurbulence + feDisplacementMap:
<filter id="fiber">
<feTurbulence type="fractalNoise" baseFrequency="0.012 0.020" numOctaves="3" />
<feDisplacementMap in="SourceGraphic" scale="14" />
<feGaussianBlur stdDeviation="0.8" />
</filter>
O modo de falha: minha primeira tentativa usou scale="180" porque intuitivamente "mais deslocamento = mais caos = mais interessante". Errado. Scale alto num gradient suave produz manchas espirradas feias, parecem manchas de café, não vapor. O fix foi contraintuitivo: derrubar scale lá pra baixo (14) pra o deslocamento só cutucar pixels um fio de cabelo, e deixar o próprio padrão de turbulência suave criar a estrutura fibrosa. Aí um gaussian blur de 0.8px lixa qualquer aspereza residual.
E mais: a névoa é branca, não ciano. O hero anterior tinha um blob de accent tingido; a versão volumétrica precisava ser lida como luz, não como aura colorida. Uma pontinha de ciano quase imperceptível no rabo (rgba(125,211,252,0.06)) é o único aceno à paleta.
Fonte completa: src/components/fx/volumetric-light.tsx (160 linhas).
2. Dissolvendo a borda retangular da foto
O prompt
"A borda retangular do retrato tá óbvia demais — faz ela dissolver sofisticadamente no fundo. A figura tem que parecer emergindo da atmosfera, não colada por cima. 3D, robusto, sem linha de matte visível."
A técnica
Uma máscara radial-gradient com cauda alpha longa. A versão ingênua é um círculo suave que vai de preto a transparente em 20% do raio — e sempre mostra um anel sutil no contorno. A versão robusta estica o falloff em quatro stops com um ombro deliberadamente longo:
mask-image: radial-gradient(
ellipse 56% 64% at 50% 44%,
black 22%,
rgba(0, 0, 0, 0.92) 48%,
rgba(0, 0, 0, 0.55) 70%,
rgba(0, 0, 0, 0.18) 86%,
transparent 100%
);
Lê a matemática: só os 22% centrais estão 100% opacos. Em 48% já estamos em 92% de alpha (-8%, invisível ao olho), depois 55% em 70% do raio, 18% em 86%, e zero. Uma cauda alpha longa vence uma curta porque o olho humano trava em taxa de variação. Um falloff cortado em 20% produz uma linha de halo perceptível; um falloff gradual de 78% é lido como absorção atmosférica.
A elipse é deslocada (at 50% 44%) porque rostos pesam pra cima — o centro visual de um retrato fica acima do centro geométrico.
Fonte completa: src/components/fx/hero-portrait.tsx, a constante EDGE_FEATHER.
3. Anel de névoa orgânico em volta da silhueta
O prompt
"Mesmo com a máscara alpha ainda sobra um resíduo na borda. Adiciona uma camada de vapor por cima da borda da silhueta — pinta fumaça em cima da costura ao invés de tentar mascarar perfeito."
A técnica
Esse é meu truque favorito do redesign. Em vez de brigar com a borda residual, você pinta vapor por cima dela. A receita:
- Um radial-gradient cujo centro é transparente, a faixa do meio é opaca, a borda é transparente — ou seja, um anel suave.
- Distorce com
feTurbulence+feDisplacementMap(aquiscale=32está ok porque a gente quer que o deslocamento fragmente o anel em fiapos). feGaussianBlur stdDeviation="1.4"pra suavizar os pedaços quebrados.mix-blend-mode: screenpra só somar luz, nunca escurecer.
<radialGradient cx="50%" cy="46%" r="50%">
<stop offset="0%" stopColor="rgba(186,230,253,0)" />
<stop offset="56%" stopColor="rgba(186,230,253,0)" />
<stop offset="70%" stopColor="rgba(186,230,253,0.38)" />
<stop offset="84%" stopColor="rgba(34,211,238,0.14)" />
<stop offset="100%" stopColor="rgba(34,211,238,0)" />
</radialGradient>
Os dois stops em 0% e 56% com alpha zero criam um disco totalmente transparente dentro do anel — o rosto fica intocado. A faixa do anel (70%–84%) acerta exatamente onde a borda da foto sangrava, e a turbulência despedaça aquilo em vapor.
Modo de falha que evitei: tingir a névoa com o ciano cheio do accent. Fez o retrato parecer pôster de piscina. A mistura atual é majoritariamente azul-pálido #bae6fd com uma pitada de #22d3ee só na borda.
Fonte completa: src/components/fx/hero-portrait.tsx, o bloco <svg> anotado "Dissolve fog".
4. Paralax 3D no mouse (sem re-render)
O prompt
"O hero parece estático. Adiciona um paralax 3D sutil que segue o cursor — perspective, rotateX/Y, um translateZ pequeno — sem fazer o React re-renderizar o retrato a cada mousemove."
A técnica
Três CSS custom properties (--tilt-x, --tilt-y, --tilt-z) no elemento raiz, escritas direto de dentro de um requestAnimationFrame no onMouseMove. O React nunca vê as mudanças; o componente não re-renderiza. O transform lê as custom properties e o browser compõe o frame novo de graça.
const rafRef = useRef<number | null>(null);
const MAX_TILT = 7;
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (reduceMotionRef.current) return;
const el = rootRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const nx = ((e.clientX - r.left) / r.width - 0.5) * 2;
const ny = ((e.clientY - r.top) / r.height - 0.5) * 2;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
el.style.setProperty("--tilt-x", `${(-ny * MAX_TILT).toFixed(2)}deg`);
el.style.setProperty("--tilt-y", `${( nx * MAX_TILT).toFixed(2)}deg`);
el.style.setProperty("--tilt-z", `${( nx * 8).toFixed(2)}px`);
});
};
E no elemento:
transform:
"perspective(1400px) " +
"rotateX(var(--tilt-x, 0deg)) " +
"rotateY(var(--tilt-y, 0deg)) " +
"translateZ(var(--tilt-z, 0px))",
transition: "transform 320ms cubic-bezier(0.16, 1, 0.3, 1)",
Três coisas pra notar:
MAX_TILT = 7graus. Acima de 10 vira efeito de novidade; 7 é o limiar onde o usuário sente profundidade sem perceber conscientemente a inclinação.perspective(1400px)é a distância focal. Números menores deixam a inclinação exagerada e "desenhada"; maiores deixam tudo achatado. 1400 está calibrado pra um retrato de ~540px.prefers-reduced-motioné respeitado viareduceMotionRef.current— o tilt simplesmente não engata pra quem optou por menos movimento.
Fonte completa: src/components/fx/hero-portrait.tsx, handleMouseMove e o <div style={{ transform: ... }}> raiz.
5. Remoção de fundo com IA via rembg — o modelo importa
O prompt que começou tudo
"Os retratos sem fundo perderam muita qualidade comparado aos originais. Tão pixelados e o contorno do capacete tá serrilhado."
A investigação
sips -g pixelWidth -g pixelHeight public/images/hero/portrait-agent-v2.png
# pixelWidth: 1536 pixelHeight: 1024
sips -g pixelWidth -g pixelHeight public/images/hero/portrait-agent-v2-removebg.png
# pixelWidth: 612 pixelHeight: 408
O passe anterior de removebg tinha downscalado os inputs pra 612×408 — três vezes menores que a fonte. Rodar de novo em resolução cheia foi o passo um. O passo dois foi a escolha de modelo, que importou mais do que eu esperava.
A técnica
O rembg (CLI de remoção de fundo com IA) traz vários modelos. O default u2net é decente pra tudo; u2net_human_seg é treinado especificamente em retratos humanos e geralmente é a escolha certa. Mas pro retrato agent — figura usando capacete inteiro — o u2net_human_seg produziu artefatos no contorno do capacete, porque o modelo "espera" ver cabelo/pele onde tem metal.
Trocar pro isnet-general-use no frame com capacete resolveu na hora. Receita final:
uv tool install "rembg[cpu,cli]"
# Retratos humanos sem oclusão
rembg i -m u2net_human_seg \
public/images/hero/portrait-human-v2.png \
public/images/hero/portrait-human-v2-removebg.png
# Sujeito não-humano ou muito ocluso (capacete, armadura)
rembg i -m isnet-general-use \
public/images/hero/portrait-agent-v2.png \
public/images/hero/portrait-agent-v2-removebg.png
Os PNGs de saída pularam de ~250 KB pra ~1.4–1.8 MB, mas o next/image lida com isso — só o frame ativo é decodificado e a qualidade por pixel agora é nítida em retina.
Regra de bolso: pra rostos sem oclusão use u2net_human_seg; no momento em que o sujeito tem capacete, máscara, fantasia, ou você está mascarando objeto não-humano, cai pro isnet-general-use. E sempre confere as dimensões reais do input com sips -g pixelWidth -g pixelHeight — um modelo só consegue ser tão bom quanto a resolução que você entrega pra ele.
O que não funcionou
Três becos sem saída, caso você se tente:
feTurbulencecomscale="180"— prometia caos volumétrico, entregou manchas de café. O fix é o oposto do intuitivo: derruba o scale e deixa a turbulência suave fazer o trabalho.- Babylon.js / Three.js pro feixe volumétrico — teria funcionado, teria embarcado 600 KB+ de bundle, teria pedido shaders. CSS + SVG puro não adiciona um byte e roda a 60fps em qualquer dispositivo. A restrição ("sem WebGL") forçou uma solução melhor.
- React Three Fiber pro paralax — mesma história. Um
requestAnimationFramede 6 linhas escrevendo CSS custom properties bate uma cena 3D inteira via reconciler pra esse uso.
A linha contínua: antes de pegar uma engine, pergunta se a plataforma já te dá a primitiva. CSS moderno + filtros SVG cobrem uma superfície enorme de efeitos "atmosféricos", e você mantém prefers-reduced-motion, acessibilidade e peso de bundle do seu lado.
Juntando tudo
A pilha completa do hero na home agora, de cima pra baixo:
<VolumetricLight />— atmosfera atrás de tudo.<ParticleField />,<BlackHole />,<RocketLaunch />— primitivos pré-existentes.<HeroPortrait />— o retrato que faz morph, com máscara alpha, anel de névoa e tilt 3D.
O resto é só composição. Os componentes são pequenos (a camada volumétrica tem 160 linhas, o retrato 350 com toda a lógica de morph), e compartilham vocabulário — accents ciano/magenta, easing cubic-bezier suave, blend mode screen, respeito a prefers-reduced-motion.
A lição que eu fico reaprendendo: o prompt e o código são o mesmo artefato em resoluções diferentes. Trata os dois com carinho.
PR: #36 — feat(hero): volumetric backlight + 3D parallax portrait.
11 de maio de 2026 · Brazil