Construí este portfólio do zero pra demonstrar como eu construo produto: restrições deliberadas, primitivas escritas na mão, zero lib quando meu próprio código é curto o suficiente pra ler de uma sentada. Carrossel, toggle de tema, switch de idioma, scroll-spy, animações de reveal e lightbox são tudo CSS custom mais umas linhas de TypeScript.
Restrição
A maioria dos portfólios empilha framework: Tailwind pra estilo, Framer Motion pra animação, lib de UI pra primitivas, lib de i18n pra tradução. Esse stack entrega rápido mas não diz nada sobre o engenheiro por trás. Quis que a base de código em si mostrasse o craft. Então: sem Tailwind, sem lib de animação, sem lib de UI, sem lib de i18n. Só Next.js, React e CSS.
Bilíngue sem split de URL
Setups de i18n geralmente te empurram pra prefixos /en/ vs /pt/ na URL mais middleware. Pra um portfólio de autor único isso adiciona fricção sem valor. Construí um LanguageProvider pequeno do lado do cliente: lê navigator.language no primeiro mount, default PT pra locales pt-*, EN pro resto, persiste em localStorage, e expõe um hook useLang() mais o tipo Tx = { en, pt } usado por toda string traduzível. Trocar é instantâneo, sem reload.
Animações de reveal só com CSS
Animações de reveal são CSS puro: um keyframe único mais animation-delay aplicado via classe em cada wrapper Reveal. Cada seção anima no mount, roda exatamente uma vez via animation-fill-mode forwards, respeita prefers-reduced-motion e mantém o critical path livre de JS. Sem IntersectionObserver, sem scroll listener, sem race de hidratação pra debugar.
Mobile-first por padrão
Mobile-first desde o dia um: a base alvo o menor viewport, com min-width: 641px e min-width: 961px destravando tablet e desktop progressivamente. Todo efeito de hover fica atrás de @media (hover: hover) and (pointer: fine) pra device touch não ficar com artefato de hover preso. Alvo de tap tem mínimo 44px. Lighthouse mobile fica nos 90+.
Carrossel custom + scroll-spy na nav
A galeria de fotos usa scroll-snap-type: x mandatory mais um IntersectionObserver feito na mão pra rastrear qual slide está centralizado pro indicador de dot. Slide ativo em opacity 1, vizinhos com fade pra 0.45 mais peek nas laterais pro usuário ver que tem mais pra deslizar. Clicar abre lightbox com navegação por teclado. A nav da home usa o mesmo padrão de observer pra destacar a seção em view.
Prerender estático
Cada página de detalhe (8 experiências, 8 projetos) é HTML estático via generateStaticParams. Os dados bilíngues moram em módulos TypeScript, então o build resolve as duas línguas em tempo de compilação. O custo em runtime de trocar de idioma é um único state update do React mais uma mudança de classe no documento, sem fetch, sem round trip extra.
- Por que sem Tailwind?CSS vanilla, mobile-firstO design v2 tem spacing e curvas de animação muito específicas. Tailwind me forçaria a brigar com a escala dele ou esticar com valores arbitrários, ambos somam ruído. CSS vanilla com custom properties me deu mais controle com menos carga cognitiva de framework. O stylesheet inteiro é um arquivo que dá pra ler do começo ao fim.
- Por que i18n client-side em vez do routing do Next.js?LanguageProvider client-sideO routing de i18n do Next.js adiciona prefixos /en e /pt mais middleware pra detectar locale. Pra um portfólio de autor único isso não ganha nada. Um LanguageProvider pequeno com persistência em localStorage faz o mesmo com troca instantânea, zero reload e sem ruído de URL.
- Por que reveal animations só com CSS?Keyframes CSS + animation-delayReveals baseados em IntersectionObserver são famosos por race de hidratação, onde wrappers podem ficar em opacity 0 dentro de iframe ou sob hidratação rápida do SSR. Um keyframe CSS com animation-fill-mode forwards sempre roda no mount, dispara exatamente uma vez, respeita prefers-reduced-motion e remove a camada inteira de reveal do JS no critical path.
- Por que prerender estático de cada página de detalhe?generateStaticParams pra tudoO conjunto de dados é pequeno e conhecido em build time (8 experiências, 8 projetos, 2 idiomas). Prerender significa que o usuário recebe HTML no primeiro paint, o conteúdo bilíngue já vai inlined, e o host pode servir do edge sem compute. Trocar de idioma vira só uma mudança de classe no documento.
- Por que carrossel custom em vez de Embla / Swiper?scroll-snap + IntersectionObserverscroll-snap-type: x mandatory mais scroll-snap-align: center já dão swipe nativo, snap e scroll com momentum. O único JavaScript necessário é um IntersectionObserver pra saber qual slide está centralizado (pros dots) e um scrollBy pras setas de prev/next. Adicionar uma lib de 30 KB pra fazer isso seria ridículo.
Um portfólio que carrega rápido, troca de idioma instantaneamente, anima sem JS no critical path e lê limpo em celular e desktop. A base de código em si é o case: cada primitiva é custom e curta o suficiente pra caber numa tela.