case/projects/portfolio
04/04
paulomartins.dev·Solo, design + engineering·2026, liveLive

paulomartins.dev

A portfolio whose codebase is the case study: every primitive is hand-rolled, mobile-first and short enough to fit on one screen.

15
static pages prerendered
0
UI / animation / i18n libraries
EN/PT
auto-detected, switch is instant
Solo
team

I built this portfolio from scratch to demonstrate how I build products: deliberate constraints, hand-rolled primitives, no library where my own code is short enough to read in a sitting. Carousel, theme toggle, language switch, scroll-spy, reveal animations and lightbox are all custom CSS plus a few lines of TypeScript.

01

Constraint

Most portfolios stack frameworks: Tailwind for styling, Framer Motion for animations, a UI library for primitives, an i18n library for translations. That stack ships fast but says nothing about the engineer behind it. I wanted the codebase itself to demonstrate craft. So: no Tailwind, no animation library, no UI library, no i18n library. Just Next.js, React and CSS.

02

Bilingual without URL split

Most i18n setups push you into /en/ vs /pt/ URL prefixes plus middleware. For a single-author portfolio that adds friction without value. I built a tiny client-side LanguageProvider: it reads navigator.language on first mount, defaults to PT for pt-* locales otherwise EN, persists to localStorage, and exposes a useLang() hook plus a typed Tx = { en, pt } shape used by every translatable string. Switching is instant, no reload.

15
static pages prerendered
03

CSS-only reveal animations

Reveal animations are pure CSS: a single keyframe plus animation-delay applied via class on each Reveal wrapper. Every section animates on mount, runs once via animation-fill-mode forwards, respects prefers-reduced-motion, and keeps the critical path JS-free. No IntersectionObserver, no scroll listener, no hydration races to debug.

04

Mobile-first by default

Built mobile-first from day one: base styles target the smallest viewport, with min-width: 641px and min-width: 961px progressively unlocking tablet and desktop layouts. Every hover effect sits behind @media (hover: hover) and (pointer: fine) so touch devices do not get stuck-hover artifacts. Tap targets are at least 44px. Lighthouse mobile scores stay in the high 90s.

05

Custom carousel + scroll-spy nav

Photo gallery uses scroll-snap-type: x mandatory plus a hand-rolled IntersectionObserver to track which slide is centered for the dot indicator. Active slide at full opacity, neighbors fade to 0.45 with peek on the sides so the user sees there is more to swipe through. Click any slide for a keyboard-navigable lightbox. The home nav uses the same observer pattern to highlight the section currently in view.

06

Static prerender

Every detail page (8 experiences, 4 projects) ships as static HTML via generateStaticParams. The bilingual data lives in TypeScript modules so the build resolves both languages at compile time. The runtime cost of switching language is a single React state update plus a class change on the document, no fetch, no extra round trip.

decisions & tradeoffs
  • Why no Tailwind?
    Vanilla CSS, mobile-firstThe v2 design has very specific spacing and animation curves. Tailwind would force me to either fight its scale or stretch it with arbitrary values, both of which add noise. Vanilla CSS with custom properties gave me more control with less framework cognitive load. The whole stylesheet is one file you can read top to bottom.
  • Why client-side i18n instead of Next.js routing?
    Client-side LanguageProviderNext.js i18n routing adds /en and /pt URL prefixes plus middleware to detect locale. For a single-author portfolio that gains nothing. A small LanguageProvider with localStorage persistence does the same job with instant switching, zero reload, and no URL noise.
  • Why CSS-only reveal animations?
    CSS keyframes + animation-delayIntersectionObserver-based reveals are notorious for hydration races, where wrappers can stay at opacity 0 inside iframes or under fast SSR hydration. A CSS keyframe with animation-fill-mode forwards always runs on mount, fires exactly once, respects prefers-reduced-motion, and removes the entire reveal layer from the critical-path JS.
  • Why static prerender of every detail page?
    generateStaticParams for everythingThe data set is small and known at build time (8 experiences, 4 projects, 2 languages). Prerendering means the user gets HTML on first paint, the bilingual content is already inlined, and the host can serve from the edge with no compute. Switching language becomes a class change on the document.
  • Why a custom carousel instead of Embla / Swiper?
    scroll-snap + IntersectionObserverscroll-snap-type: x mandatory plus scroll-snap-align: center already gives native swipe, snap, and momentum scrolling. The only JavaScript needed is an IntersectionObserver to know which slide is centered (for the dots) and a scrollBy call for the prev/next arrows. Adding a 30 KB carousel library to do that would be ridiculous.

A portfolio that loads fast, switches language instantly, animates without JS in the critical path, and reads cleanly on phones and desktops. The codebase itself is the case study: every primitive is hand-rolled and short enough to fit on one screen.