UX design for a developer portfolio: animations, typography, and design decisions

5 min readApril 29, 2026#design#ux#next.js#framer-motion#tailwind#turso#sqlite#site-perso

How this site was designed

The CMS pipeline is documented elsewhere. This post is about the design and UX decisions — why each feature exists, and how it was built.


The starting point: nav.al/archive

The main inspiration is Naval Ravikant's archive page. Black. White. Text. Nothing else.

The principle: if you strip away everything that isn't text, is the information still readable? Here, the answer is yes.

Full palette:

  • Background: #090909
  • Text: #f0f0f0
  • Meta: #888888
  • Separators: #1e1e1e / #333333

Single typeface: Jost, weights 300/400/500. Monospace: JetBrains Mono, code only.


The homepage tagline

The first version just showed a list of posts. No context. No invitation.

The current version borrows from the Twitter style: a short sentence that establishes identity, a context subline, and an explicit invitation to understand why this site exists.

Je construis.
J'écris ce que j'apprends.

Builder SaaS · Cotonou, Bénin · Marché africain francophone
Journal public d'un dev qui fait, pas qui enseigne.

Minimalist. No hero image. No CTA button.


The "load more" feed

The initial problem: the homepage showed every post at once. No pagination. No end. Just a list that kept growing.

Solution: PostFeed is a client component with PAGE_SIZE = 12. It shows 12 posts, then a "Load more" button that loads 12 more, with a fade-in animation on each new batch.

const PAGE_SIZE = 12;
const [visible, setVisible] = useState(PAGE_SIZE);

No pagination. No route. Just local state.


List previews

Every post in a list now shows an excerpt. getExcerpt() in lib/content.ts:

  1. Uses the frontmatter description if it exists
  2. Otherwise, strips headings, code blocks, links, markdown — takes the first 130 characters

That stripping matters. An excerpt starting with ## Introduction or `const x = ` gives you no information at all. Only the raw text counts.


Architectural challenge: Nav is a Server Component. SearchModal is a Client Component. They can't share state directly.

Solution: decoupling via custom events.

// NavSearchTrigger.tsx (client)
window.dispatchEvent(new CustomEvent("open-search"));
 
// SearchModal.tsx (client)
window.addEventListener("open-search", () => setOpen(true));

The modal also listens for ⌘K / Ctrl+K and Escape.

The search index comes from an /api/search route that returns every post with its title, description, excerpt, and tags. Search runs client-side over that JSON — no need for Algolia for 50 posts.

Framer Motion bug: the modal used left: 50%; transform: translateX(-50%) for centering. Framer Motion overrides the inline transform with its own animation properties (y, scale). The fix: left: 0; right: 0; margin-inline: auto. Zero CSS transform — Framer Motion gets to do whatever it wants.


The animations

Stack: Framer Motion v12 (motion/react).

Page enter/exit

app/template.tsx — Next.js re-renders this file on every navigation.

<motion.div
  initial={{ opacity: 0, y: 6 }}
  animate={{ opacity: 1, y: 0 }}
  exit={{ opacity: 0, y: -6 }}
  transition={{ duration: 0.28, ease: [0.25, 0.1, 0.25, 1] }}
>

With a useEffect to reset the scroll to the top of the page on every navigation:

useEffect(() => {
  window.scrollTo({ top: 0, behavior: "instant" });
}, []);

The ↑ button

Circular, fixed at the bottom center (left: 0; right: 0; margin-inline: auto), appears after 400px of scroll. AnimatePresence handles enter/exit.

The FAB navigation

Three circular buttons at the bottom right: search, home, links. The links open in a bottom sheet with a spring animation (stiffness: 380, damping: 38).


The "See also" widget

On articles, a floating translucent widget shows related articles. It appears once the article header scrolls out of the viewport — detected with an IntersectionObserver.

const observer = new IntersectionObserver(
  ([entry]) => setVisible(!entry.isIntersecting),
  { threshold: 0 }
);
observer.observe(headerEl);

The widget is collapsible. It remembers its open/closed state in local state.


View counter and likes

Why not node:sqlite: the built-in node:sqlite has existed since Node.js 22.5. Vercel runs on Node.js 18/20. Incompatible in production.

Solution: Turso (@libsql/client). SQLite-compatible, pure JS, free for a personal site. Locally: file:.data/site.db. In production: libsql://name.turso.io.

Minimal schema:

CREATE TABLE IF NOT EXISTS stats (
  slug TEXT PRIMARY KEY,
  views INTEGER DEFAULT 0,
  likes INTEGER DEFAULT 0
)

API routes:

  • GET /api/views/[slug] — counts a view + returns the total
  • GET /api/likes/[slug] — reads the like count
  • POST /api/likes/[slug] — toggles like/unlike

Fetch optimization: PostStats renders N times on a list page. A single /api/stats/all route returns everything in one request. The component uses a module-level cache:

let _cache: Record<string, Stats> | null = null;
let _promise: Promise<void> | null = null;

N instances = 1 HTTP request.


Syntax highlighting

rehype-pretty-code with Shiki's one-dark-pro theme. keepBackground: true preserves the theme's background.

A language label appears at the top left of every code block:

.prose pre[data-language]::before {
  content: attr(data-language);
  /* ... */
}

On mobile, code overflows the container slightly (margin-inline: -1.25rem) to use the full width of the screen.


What I'd do differently

Turso from the start. I first tried better-sqlite3 (failed — native bindings blocked by pnpm v10), then node:sqlite (failed — Node.js 22 only). Two detours to reach the obvious solution.

Test Framer Motion + CSS transform upfront. Resolving the transform: translateX(-50%) conflict took time. The rule is simple: never mix CSS transform and Framer Motion on the same element.


The whole site was built in a single Claude Code work session. The full stack, the animations, the database, the search — all from scratch.