11 de maio de 2026

Como construí o eliel.work

Um portfólio pra engenheiro sênior full-stack é um objeto estranho. Quem lê sabe ler código; “animaçãozinha bonita” sozinha não impressiona. Mas a página também precisa pintar no mobile em menos de dois segundos, ranquear organicamente e convencer um recrutador que não conhece sua stack de que você leva ofício a sério. As restrições são barulhentas:

  • Lighthouse 100/100/100/100, mobile com throttle, no dia um.
  • Animação que lê como Apple-like sem virar parquinho.
  • Tipografia editorial que segura a voz da página — não uma pilha de utilitários fingindo ter personalidade.
  • Multilíngue (EN default, PT-BR mirror) sem dobrar o custo de manutenção.

Aqui vai como o site que você tá lendo saiu.

A stack

Eu sou usuário diário de Next.js. Não peguei Next.js pra esse.

Um portfólio é ~80% conteúdo parado e ~20% animação. Next.js manda React runtime por página mesmo quando a página é estruturalmente estática, custando ~50–80kb de JS que hidrata componentes que não se movem. Num perfil mobile com throttle, isso é a diferença entre 100 e 92 no Lighthouse Performance. Astro emite HTML em build e só hidrata islands declaradas.

Então: Astro 5 com output: 'static', React islands pra GSAP, content collections MDX pros case studies e pra esse blog, Tailwind v4 com bloco CSS-first @theme dirigindo todos os tokens a partir do DESIGN.md. Deploy em Cloudflare Pages — static, sem edge runtime na v1.

O trade é real — Astro não me dá o modelo mental Next.js que afiei por anos. Mas prefiro pagar esse custo uma vez do que pagar o tax de perf em cada page load.

Fraunces e Geist no lugar de Domaine e ABC Favorit

O design system é conscientemente inspirado na Resend: canvas preto puro, headlines editoriais serif gigantes, glows atmosféricos em cores accent de baixa opacidade, sem drop shadow. A Resend usa Domaine Display e ABC Favorit — ambas proprietárias, ambas caras.

Pra um portfólio pessoal, pagar uns USD 200–600 por estilo de fonte é overkill. Stack de substituição:

  • Fraunces (variable, optical sizing) substitui Domaine Display nos headlines 76–96px. Com features ss01 e liga ligadas, é o serif free mais próximo em voz.
  • Geist substitui ABC Favorit no body marketing. Menos personalidade em 16px que ABC Favorit, mas compensado com -0.5% de letter-spacing.
  • Inter pros labels UI. Geist Mono pro código.

As quatro são self-hosted via fontsource. A Fraunces variable é preloaded pro hero acima da dobra; o resto carrega com font-display: swap.

Um gotcha que queimei uma hora: fontsource declara a família como 'Geist Sans', não 'Geist'. Acerta o nome errado e o browser cai pra system-ui sem reclamar.

GSAP dentro de React islands

O mapa de animação é quatro momentos cirúrgicos, não uma parada:

  1. Headline do hero — GSAP SplitText quebra o <h1> em palavras e revela com stagger 0.04s, blur 8→0, y 20→0, opacity 0→1.
  2. Preview de workScrollTrigger prende a seção por uma viewport-height enquanto três case study cards revelam sequencialmente.
  3. Banda de números FortCred — counters animam 0 → valores finais com power2.out em 1.6s. Números acima de mil renderizam com separadores de milhar locale-aware.
  4. Transições entre páginas<ClientRouter /> nativo do Astro dirigindo a View Transitions API do browser. Títulos dos case study cards carregam view-transition-name: case-<slug> pra o título mórfar do work index pra página dedicada.

Cada animação vive num componente React (.tsx) que consome GSAP via hook useGSAP do @gsap/react. O hook roda a animação dentro de um gsap.context() e limpa em unmount automaticamente — importante porque View Transitions do Astro destroem componentes em navegação client-side.

Importo esses islands na .astro correspondente com client:visible, então o bundle pra banda de números FortCred não é buscado até o usuário rolar pra ele. O hero, que tá acima da dobra, usa client:load — não tem motivo pra esperar um round-trip de IntersectionObserver quando a seção é a primeira coisa na tela.

Detalhe não-óbvio: prefers-reduced-motion: reduce curto-circuita todo island. O markup estático já tá no estado final; usuários de reduced-motion veem conteúdo instantaneamente sem timelines GSAP rodando e Lenis desligada.

A seta de hover que todo mundo copia

Os botões desse site fazem o move que você viu em landing pages o ano todo: seta escondida, hover desliza ela de fora, texto shifta levemente. A implementação que mais gente alcança é animação de width com overflow-hidden, que dá jank no Safari. A versão mais limpa usa CSS grid template columns:

<button class="group inline-flex items-center">
  <span class="transition-transform group-hover:-translate-x-0.5">
    {label}
  </span>
  <span class="grid grid-cols-[0fr] group-hover:grid-cols-[1fr]
               transition-[grid-template-columns] duration-200">
    <span class="overflow-hidden">
      <ArrowRight class="ml-1.5 -translate-x-1.5 opacity-0
                         group-hover:translate-x-0 group-hover:opacity-100
                         transition-all duration-200" />
    </span>
  </span>
</button>

A coluna da seta anima de 0fr pra 1fr — largura natural do conteúdo — sem nenhum width hardcoded. O ícone dentro é fade + translate. O texto ganha 2px de shift pra esquerda pra ênfase visual. Duzentos milissegundos, ease-out, zero jank.

i18n sem dobrar o código

O routing i18n do Astro 5 põe EN em / (default) e PT-BR em /pt/. Content collections organizadas por pasta de locale (src/content/work/en/, src/content/work/pt/), e os helpers de lookup em src/lib/content.ts filtram por prefixo. Hreflang tags incluindo x-default são emitidas em toda página por um componente <SeoHead>; o sitemap pareia cada URL com seu equivalente via <xhtml:link rel="alternate">.

Trato o lado EN como canônico pra busca internacional. O mirror PT-BR é pra parte do meu trabalho que aterrissa no Brasil — clientes, posts pra conferência, qualquer um que lê em português primeiro. Adicionar um novo case study é um único arquivo MDX em cada pasta de locale, zero código tocado.

O que eu faria diferente

Se fosse começar de novo, provavelmente pegava a mesma stack. Os lugares que eu apertaria:

  • Cache de OG image: hoje regerado em todo build via satori. Um gate de hash de conteúdo cortaria tempo de build em CI.
  • Lenis em devices mais lentos: puxaria o coeficiente de inércia um pouco pra baixo pra Android low-end — o default fica ótimo num M1 MacBook e ligeiramente pesado num Moto de 2 anos atrás.
  • Scaffold do blog: shipei as rotas, RSS e empty state, mas só fui escrever um post de verdade agora. Resposta honesta: tem que ter pelo menos um post publicado antes do lançamento.

Enfim. Aqui tá um. O código completo tá no repo, e dá pra ver como cada decisão aterrissa navegando pelos case studies — é lá que o raciocínio de engenharia aparece contra restrições reais de produção.