TG
frontend·css·svg·10 min de leitura

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.

Read in English
Luz volumétrica, anel de névoa e paralax 3D: reconstruindo o hero em CSS/SVG puro

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:

  1. Um radial-gradient cujo centro é transparente, a faixa do meio é opaca, a borda é transparente — ou seja, um anel suave.
  2. Distorce com feTurbulence + feDisplacementMap (aqui scale=32 está ok porque a gente quer que o deslocamento fragmente o anel em fiapos).
  3. feGaussianBlur stdDeviation="1.4" pra suavizar os pedaços quebrados.
  4. mix-blend-mode: screen pra 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 = 7 graus. 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 via reduceMotionRef.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:

  • feTurbulence com scale="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 requestAnimationFrame de 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:

  1. <VolumetricLight /> — atmosfera atrás de tudo.
  2. <ParticleField />, <BlackHole />, <RocketLaunch /> — primitivos pré-existentes.
  3. <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.

Thiago Marinho

11 de maio de 2026 · Brazil