Portfólio pessoal de Matheus Simões — desenvolvedor frontend especializado em interfaces com motion design.
Uma single-page application sem scroll nativo: as seções são camadas fixas empilhadas que se fecham umas sobre as outras conforme o input de scroll avança, orquestradas inteiramente por GSAP.
- Sobre o projeto
- Stack
- Começando
- Scripts
- Variáveis de ambiente
- Arquitetura
- Estrutura de pastas
- Conteúdo: adicionando um projeto
- Convenções de código
- Acessibilidade e performance
- Deploy
- Roadmap
- Autor
O site é uma vitrine técnica além de um portfólio: cada decisão de arquitetura existe para servir a uma experiência de navegação contínua, sem recarregamento e sem barra de scroll.
Seis seções, todas position: fixed ocupando a viewport inteira, empilhadas por z-index:
| # | Seção | Id | O que apresenta |
|---|---|---|---|
| 00 | Hero | hero-section |
Abertura com loader, texto datilografado e fundo WebGL animado |
| 01 | What I Do | whatido-section |
Áreas de atuação e o tipo de trabalho entregue |
| 02 | Sobre | aboutme-section |
Apresentação pessoal em formato de ticket/passaporte |
| 03 | Projetos | projects-section |
Galeria filtrável por categoria, com detalhe em rota própria |
| 04 | Skills | skills-section |
Stack e ferramentas, com efeito ScrambleText |
| 05 | Contato | contact-section |
Formulário que abre o WhatsApp, e-mail e redes |
Destaques técnicos:
- 🎬 Motion-first — 100% da animação (entrada, scroll, interação, transição) é GSAP. Zero
@keyframesde interação, zero Framer Motion. - 🧱 Scroll próprio —
Observerdo GSAP intercepta wheel/touch/pointer e converte em progresso de pilha; o navegador nunca rola. - 🎨 WebGL sem peso — gradiente animado (
@firecms/neat) na Hero e shader de scanner (ogl) escritos em GLSL puro. - 🔗 Rotas compartilháveis sem router —
/projetos/:slugvia History API nativa, semreact-router. - 🕷️ SEO real numa SPA — plugin de build gera um
.htmlpor projeto com<head>próprio +sitemap.xml. - 📉 Mídia otimizada por script — WebP + VP9 + capas OG 1200×630 geradas com
sharpeffmpeg. - ♿
prefers-reduced-motionem toda animação — sem exceção: estado final aplicado viagsap.set().
| Pacote | Versão | Papel |
|---|---|---|
react / react-dom |
^19.2 |
UI. Sem StrictMode — ver Convenções |
gsap |
^3.15 |
Toda animação: timelines, Observer, ScrambleTextPlugin, quickTo |
ogl |
^1.0 |
WebGL mínimo para o shader de scanner |
@firecms/neat |
^1.0 |
Gradiente animado em WebGL da Hero |
parallax-js |
^3.1 |
Profundidade por data-depth nos cards |
| Pacote | Versão | Papel |
|---|---|---|
vite |
^7.0 |
Bundler + dev server (+ plugin de SEO customizado) |
typescript |
^6.0 |
Type-check no build (tsc && vite build) |
tailwindcss + @tailwindcss/vite |
^4.3 |
Layout, spacing e cores estáticas |
sharp |
^0.35 |
Conversão WebP e geração das capas OG |
eslint + typescript-eslint |
^10 / ^8 |
Lint, com react-hooks e react-refresh |
@playwright/test |
^1.62 |
Inspeção visual em desenvolvimento |
@fontsource/* |
^5.3 |
Space Grotesk (sans) e Cormorant Infant (serif) self-hosted |
Requisitos externos:
ffmpegeffprobenoPATH— usados apenas pornpm run media.
node --version # 22.x (ver .nvmrc)# 1. clone
git clone https://github.com/dinamous/portifolio-dinamous.git
cd portifolio-dinamous
# 2. use a versão de Node do projeto
nvm use # lê o .nvmrc → 22
# 3. instale
npm install
# 4. suba o dev server
npm run dev # http://localhost:5173npm run build # tsc (type-check) → vite build → páginas SEO + sitemap
npm run preview # serve o dist/ localmente| Comando | O que faz |
|---|---|
npm run dev |
Dev server do Vite com HMR |
npm run build |
Type-check (tsc) e build de produção; o plugin de SEO escreve dist/projetos/*.html e dist/sitemap.xml |
npm run preview |
Serve o dist/ para validar o build final |
npm run lint |
ESLint em todo o projeto |
npm run media |
Otimiza a mídia dos projetos in-place e gera as capas OG — ver Pipeline de mídia |
Variáveis client-side usam o prefixo VITE_ (tipadas explicitamente em src/env.d.ts).
# .env.local
VITE_GA_ID=G-XXXXXXXXXX| Variável | Obrigatória | Descrição |
|---|---|---|
VITE_GA_ID |
Não | ID de medição do GA4. Sem ela, o analytics vira no-op completo — nenhum request é feito e visitas de desenvolvimento não sujam o relatório. |
flowchart TD
A[main.tsx] --> B[initAnalytics + trackInitialPageview]
A --> C[App.tsx]
C --> D[initSectionStack]
D --> E["GSAP Observer<br/>wheel · touch · pointer"]
E --> F["state.value 0..5<br/>(quickTo, power2.out)"]
F --> G["clip-path inset + overlay preto<br/>por seção"]
F --> H["evento stack:progress<br/>(por seção)"]
F --> I["evento stack:value<br/>(global)"]
H --> J[Cada seção anima o próprio conteúdo]
I --> K[SectionNav · SectionProgress]
C --> L[ProjectDetail]
L --> M["router.ts — History API<br/>/projetos/:slug"]
M --> N[applyProjectMeta → title/OG/canonical + page_view]
Todo o comportamento vive em src/lib/sectionStack.ts — é o coração do site.
Como funciona:
- Toda seção marcada com
data-stacké registrada e recebez-indexdecrescente — as primeiras ficam por cima, cobrindo as seguintes até se fecharem. - Um
Observerdo GSAP capturawheel,touchepointercompreventDefault: true. O scroll nativo do navegador nunca acontece. - O delta acumula num
targetnormalizado (0 .. nº de seções - 1) e é aplicado astate.valuepor umgsap.quickTo— retargetar o mesmo tween em vez de criar um novo por evento é o que transforma notches discretos de wheel em movimento contínuo. - A cada tick, cada seção recebe
clip-path: inset(...)fechando das laterais até virar uma barra de5vw, com um overlay preto subindo por cima.
Detalhes que importam:
| Constante | Valor | Por quê |
|---|---|---|
HOLD |
0.25 |
Fatia inicial do range em que o scroll não move nada — dá um respiro depois da entrada da seção |
TRANSITION_DISTANCE |
1800px |
Curso de wheel/touch para fechar uma seção; alto o bastante para cada notch ler como glide, não como salto |
TOUCH_SENSITIVITY |
3.2 |
Um swipe cobre muito menos px que a soma de notches de wheel — sem o boost, seria preciso vários swipes por seção |
CLIP_CLOSE_T |
0.7 |
O clip termina antes de local === 1, deixando o range restante livre para o "finale" próprio da seção |
Contratos públicos:
initSectionStack() // registra as seções e liga o Observer (singleton)
enableSectionStack() // libera o input — só depois do intro da Hero terminar
goToSection("projects-section") // navegação por clique, com duration escalando pela distância
lockSectionStack() // Observer.disable() — usado pelo overlay de projeto
unlockSectionStack()
refreshSectionStack() // re-emite o estado atual para quem acabou de remontarEventos emitidos:
stack:progress(na seção) —{ progress: 0..1 }do fechamento dela. Cada seção reage ao próprio progresso sem que o módulo genérico toque em DOM específico.stack:value(nowindow) — posição global da pilha. Consumido porSectionNaveSectionProgress.
Escapes:
data-no-close-clipopta por sair do wipe genérico (a Skills usa cortina própria);data-stack-scrollmarca uma coluna rolável interna, que recebe o delta primeiro e só devolve o controle à pilha ao bater na própria borda.
src/lib/router.ts — sem react-router, apenas History API nativa, porque existe exatamente uma rota.
/projetos/:slugabre o overlayProjectDetail, é compartilhável e recarregável.applyProjectMeta()trocadocument.title,description, tags OG/Twitter ecanonicalna navegação client-side — é isso que faz a URL valer como página própria para o Googlebot, que indexa o DOM renderizado.- Slug inválido é apagado da URL e cai na home, mas o
hrefquebrado ainda aparece no relatório do GA — o que denuncia link morto circulando.
O plugin seoPlugin() em vite.config.ts roda no closeBundle:
| Saída | Conteúdo |
|---|---|
dist/projetos/<slug>.html |
Mesmo bundle e mesmo #root, apenas o <head> muda: title, description, canonical e OG/Twitter do projeto |
dist/sitemap.xml |
Home + todas as rotas de projeto |
O problema que isso resolve: crawlers sem JS (LinkedIn, WhatsApp, Slack, Bing) só leem o HTML servido. Como o app é 100% client-side, todo link de projeto compartilhado mostrava o card genérico da home antes disso.
scripts/optimize-media.mjs — idempotente, pula o que já está no formato/tamanho alvo.
npm run media| Etapa | Transformação | Alvo |
|---|---|---|
| Imagens | PNG/JPG → WebP quality 80, redimensionado |
máx. 1600px de largura |
| Vídeos | .webm → VP9 crf 33 + Opus 64k |
máx. 720px de altura |
| Capas OG | Primeira imagem de cada projeto → JPG quality 82 |
1200×630 em public/og/<slug>.jpg |
A capa da home vem de um print à parte (
src/assets/og/home.png), recortado pelo rodapé — que é onde o nome fecha a Hero. De cima só sobraria gradiente.
src/lib/analytics.ts — GA4 direto via gtag.js, sem container GTM: existe só uma tag para carregar, e o container seria mais um request num site que já paga caro em WebGL + GSAP.
send_page_view: false— numa SPA o automático só enxergaria o primeiro load, e ainda com o título estático doindex.html.- Todo
page_viewsai depageview(), chamado por quem conhece o título canônico da rota. - Guarda de
pathnameevita contagem dupla quando a mesma rota é reaplicada (ex.:popstate).
portifolio-dinamous/
├── docs/superpowers/ # specs e planos de implementação por feature
│ ├── plans/
│ └── specs/
├── public/
│ ├── favicon/ # favicon completo + webmanifest
│ ├── og/ # capas 1200x630 geradas por `npm run media`
│ ├── projects/ # imagens legadas (fallback do campo `img`)
│ ├── noise.png
│ └── robots.txt
├── scripts/
│ └── optimize-media.mjs # WebP + VP9 + capas OG
├── src/
│ ├── assets/projects/<pasta>/ # mídia da galeria — auto-descoberta por import.meta.glob
│ ├── components/
│ │ ├── graphics/ # AccentRule, CropMarks, DashedLine, Perforation,
│ │ │ # RegMark, Stamp, Ticks — vocabulário gráfico do site
│ │ ├── Navbar/
│ │ ├── ProjectGallery/
│ │ ├── SectionNav/
│ │ ├── SectionProgress/
│ │ ├── AnimatedLetters.tsx
│ │ ├── AnimatedWords.tsx
│ │ ├── HeroBackground.tsx # gradiente WebGL (@firecms/neat)
│ │ └── ScannerBackground.tsx # shader GLSL (ogl)
│ ├── lib/
│ │ ├── analytics.ts # GA4
│ │ ├── contact.ts # e-mail, WhatsApp, redes
│ │ ├── motion.ts # prefersReducedMotion()
│ │ ├── projects.ts # normalização + filtros + lookup por slug
│ │ ├── router.ts # History API + meta tags por rota
│ │ ├── scrub.ts # scrubValue / scrubTimeline com lag
│ │ ├── sectionStack.ts # ⭐ o scroll da página
│ │ ├── slug.ts # usado no runtime E no build (mesma URL nas duas pontas)
│ │ └── textType.ts # efeito de digitação da Hero
│ ├── styles/global.css # @import "tailwindcss" + tokens
│ ├── views/ # uma pasta por seção: .tsx + .css companheiro
│ │ ├── Hero/ WhatIDo/ AboutMe/ Projects/ Skills/ Contact/
│ │ └── ProjectDetail/ # overlay da rota /projetos/:slug
│ ├── App.tsx
│ ├── env.d.ts
│ └── main.tsx
├── projects.json # ⭐ fonte de conteúdo dos projetos
├── index.html # <head> canônico: OG, Twitter, JSON-LD (schema.org/Person)
├── vite.config.ts # + seoPlugin()
├── vercel.json
└── CLAUDE.md # regras do projeto para agentes de IA
1. Adicione a entrada em projects.json:
2. Solte a mídia em src/assets/projects/<pasta>/. Nada mais precisa ser tocado — o
import.meta.glob em src/lib/projects.ts descobre todo png|jpg|jpeg|webp|mp4|webm|mov da pasta automaticamente.
Ordenação: a galeria ordena pelo nome do arquivo original (a URL final do Vite leva hash, então não serve de critério). A thumb deve levar o prefixo
a0-para vir primeiro.
3. Rode o otimizador:
npm run mediaNormalizações automáticas:
categoriatem grafia inconsistente no JSON (Front-end/Front-End) —projects.tsnormaliza para os rótulos do filtro.Fullstacknão existe no dado: é derivado de o projeto ter Frontend e Backend.- Filtros disponíveis:
Todos,Fullstack,Frontend,Backend,UI/UX.
As regras completas vivem em
CLAUDE.md— o resumo:
- ✅ Toda animação é GSAP. ❌ Sem
@keyframes/transitionpara interação, sem Framer Motion, sem anime.js.- Exceção documentada:
@keyframes hero-type-blinkpara o piscar do cursor — puramente decorativo.
- Exceção documentada:
- ✅
gsap.registerPlugin()uma vez por plugin no projeto inteiro. - ✅ Sequenciamento de seções sempre via
sectionStack.ts— nunca recriar a lógica. - ✅ Uma timeline por seção/interação; encadear com position parameters (
"-=","<", labels) — nuncasetTimeout. - ✅ Animar
transform/opacity; nuncawidth/top/left. - ✅ Checar
prefers-reduced-motione pular para o estado final viagsap.set().
- Dados/props no corpo do componente; DOM e GSAP em
useEffect, buscando por id/seletor. - Todo
useEffectde animação limpa listeners/tweens noreturn— sem isso, remount/HMR duplica listeners. - Um componente = um
.tsx+ um.csscompanheiro de mesmo nome, importado direto. Sem CSS Modules — as classes já são BEM. - Sem
StrictModeemmain.tsx: os efeitos são timelines imperativas de disparo único, portadas do Astro, não escritas para sobreviver a double-mount.
- Tailwind 4 via
@import "tailwindcss"para layout, spacing e cores estáticas. - O
.cssdo componente só para o que Tailwind não cobre bem: BEM custom comclamp(), alvos de animação GSAP. - Nomenclatura BEM:
.hero__word--first.
- Comentários só quando explicam um "porquê" não óbvio. Os marcadores
ponytail:sinalizam simplificações deliberadas, com o teto conhecido e o caminho de upgrade. - Antes de escrever lógica nova, checar
src/lib/— preferir estender a duplicar. - Commits em gitmoji:
feat: :sparkles: ...,fix: :bug: ...,refactor: :recycle: ....
| Frente | Implementação |
|---|---|
| Reduced motion | Checado em sectionStack, HeroBackground, ScannerBackground, textType e em cada seção. O quickTo é substituído por escrita direta — duration: 0 divide por zero internamente e propagaria NaN para state.value, congelando a pilha inteira |
| Semântica | <section>/<h1>–<h2> reais, aria-label na navegação, aria-hidden no decorativo, role="status" no feedback do formulário |
| Navegação | Seções também acessíveis por clique (SectionNav), com a URL refletindo a posição via replaceState — cada clique não vira entrada no histórico |
| Mídia | WebP + VP9; imagens no máx. 1600px, vídeos no máx. 720px |
| Cache | /assets/* com max-age=31536000, immutable (nomes hasheados pelo Vite) |
| Assets críticos | Fontes self-hosted via @fontsource — sem request a CDN de terceiros |
| Analytics | GA4 direto, sem container GTM — um request a menos |
Hospedado na Vercel, configurado por vercel.json:
{
"cleanUrls": true,
"trailingSlash": false,
"headers": [ /* cache imutável em /assets/* */ ],
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }],
"ignoreCommand": "git diff --quiet HEAD^ HEAD -- . ':!*.md' ':!docs'"
}| Ajuste | Motivo |
|---|---|
rewrites |
SPA fallback: /projetos/:slug precisa servir o app em qualquer URL |
cleanUrls |
As páginas SEO são escritas como dist/projetos/<slug>.html e servidas sem a extensão |
ignoreCommand |
Commits que só tocam *.md ou docs/ não disparam build |
![]() Matheus Simões Motion Frontend |


{ "titulo": "Nome do Projeto", // vira o slug: /projetos/nome-do-projeto "img": "/projects/fallback.webp", // fallback quando não há `pasta` "pasta": "nome-da-pasta", // src/assets/projects/<pasta>/ "descricao": "Uma linha. Vira a meta description e o card de compartilhamento.", "techs": ["React", "GSAP"], "categoria": ["Front-End"], // Front-End | Back-End | UI/UX | Mobile "actions": [{ "label": "Link", "link": "https://exemplo.com" }] }