Progressive portfolio enhancements building in real time.

Role: Designer-engineer Project: Personal portfolio (Astro + React) Skills spotlighted: Design engineering, responsive interaction design, motion design


Outcome

A floating dock replaced a header bar whose mobile hamburger opened nothing. Two responsive modes — a top-center pill on mobile, a slim left-edge sidebar on desktop — that hide while you read, surface topic shortcuts that deep-link to filtered work, and ship with a theme transition driven by the View Transitions API.

Before — conventional header bar with logo, nav links, theme toggle, and hamburger
Before — conventional header (desktop)
After — slim left-edge column of icon-only dock buttons
After — slim left-edge dock (desktop)
Before — mobile view with hamburger button that did not open a menu
Before — broken hamburger (mobile)
After — compact top-center pill with logomark and icon-only links
After — top-center pill (mobile)
Stage 1 — Top header Stage 2 — Right dock Stage 3 — Slim left dock (shipped)
Mobile menu Broken hamburger Top-center capsule Compact icon-only pill
Desktop chrome 96px header eating the hero Right-edge column, off the reading path Single slim left-edge column
Wayfinding One flat Feed link Topic shortcuts present, but off to the side Topic shortcuts where the eye enters
Theme toggle Class swap View Transitions reveal View Transitions reveal

Challenge

The shipped nav looked finished and worked on desktop. Three failures sat beneath:

  1. The mobile hamburger didn’t open anything. It rendered. It depressed on tap. It led nowhere.
  2. The header ate viewport. 96px tall on desktop, pushing “Vision to shipped.” below the fold on shorter laptops.
  3. No wayfinding into the work. Every project sat under one flat Feed link, with no way to signal what was inside.

Constraints: static site (Astro), but the nav needed real-time behavior (scroll-hide, theme transitions), so it had to be a client-loaded React island. Subframe is sync-managed under src/subframe, so project-owned components live outside that folder.


Process

Three stages, two pivots

The dock didn’t arrive in one move. It took two course-corrections — each driven by a different failure of the previous shape.

Stage 1 — original top header bar
Stage 1 — Top header. Conventional. Ate 96px of viewport, broken hamburger on mobile, no wayfinding into the work.
Stage 2 — right-edge dock
Stage 2 — Right dock. Off the reading path, which felt safe. But it parked wayfinding on the side the eye reaches last — every topic shortcut sat where readers look only after they’ve finished scanning the content.
Stage 3 — slim left-edge dock, shipped
Stage 3 — Slim left dock (shipped). Single thin column on the left edge, where left-to-right reading begins. Slim enough to guide without crowding the content. Icon-only, tooltip on hover/focus, hides on scroll-down.

The first pivot — from header to dock

The plan was small: wire up a slide-down menu, ship it, move on. Forty minutes in, I realized I was about to ship a marginally better version of the same wrong thing. A drawer would fix the broken interaction, but the bar would still eat 96px of viewport, desktop would still be a wall of text links, and the topic problem wouldn’t move.

I stopped. New file: src/components/nav/DockNav.tsx. New question: if the nav weren’t a header, what would it be?

Mobile needed something compact and thumb-reachable. Desktop had dead vertical edge space the header wasn’t using. Both modes needed to get out of the way while reading. That shape is a dock.

The second pivot — from right edge to left, kept slim

My first dock landed on the right edge. The logic seemed sound: keep controls out of the reading path, the way OS chrome (Finder sidebar, file tree, IDE rails) sits off to the side. It shipped. It worked. But it parked the navigation on the side the eye reaches last.

Reading runs left to right. The eye enters every page from the left and ends on the right. A right-edge dock put the topic shortcuts — the whole point of the rebuild, the wayfinding into the work — in the spot a reader looks at only after they’ve finished scanning. The nav was technically present and effectively invisible. It also meant the first thing on screen was a wall of content with no signposting, and the signposting only when you’d given up looking.

The fix was two moves at once:

  1. Move to the left edge. Where reading begins. The dock now sits in the eye’s natural entry point, so wayfinding is the first thing a reader can act on — not the last.
  2. Keep it slim. The earlier instinct to slim the chrome stays: a single thin column of small icon buttons, not a heavy stacked rail. On the left edge slimness matters even more — a fat left rail would crowd the content and read like a 2018 SaaS dashboard. Thin enough to guide, light enough to stay out of the way.

Decisions and tradeoffs

Two layout shells, one component. A horizontal mobile pill and a vertical desktop sidebar solve different problems (mobile = reach, desktop = real estate). DockNav.tsx houses shared primitives (DockLink, ThemeDockButton) and composes them into two distinct shells, swapped at the md breakpoint — not one container morphing with five conditionals.

Hide on scroll-down, return on scroll-up. I know hide-on-scroll has costs: keyboard users scrolling to orient lose the nav, and motion-sensitive readers can find it twitchy. I weighed those against the cost of a floating dock that competes with the reading on every page. Mitigations: focus on any nav link forces the dock visible, useReducedMotion disables the animation entirely, and the dock returns the instant the scroll direction reverses (12px threshold, RAF-throttled, three states: top / up / down).

Icon-only on desktop, text on mobile. Costs discoverability and is fragile on touchscreen laptops. The bet: desktop visitors are mouse-first, tooltips appear on hover and keyboard focus, and the mobile pill uses text labels for the touch case. If discoverability shows up as a real problem, the fix is small — icon plus 11px label.

Topic shortcuts deep-link to a filtered feed. Each shortcut routes to /feed/?topic=design-systems. The feed page already filtered on the query param, so the nav surfaced filtering that was already there.

Feed page filtered to Design Systems topic, reached in one click from the dock's topic shortcut
One click from anywhere → topic-filtered feed

Iteration

The first mobile build broke the homepage. The fixed pill at top: 16 overlapped the hero headline because the old header used to provide top padding the hero relied on. A Playwright screenshot diff caught it; pt-20 lg:pt-0 on the hero fixed it.

The first theme toggle was a class swap. It felt cheap. I rebuilt it with the View Transitions API — a circular clip-path expanding from the button’s center to fill the viewport in 700ms, with an instant fallback in unsupported browsers. Same outcome, but the transition does the work of telling you something just changed across the whole page.

[INTERACTION PLACEHOLDER: 6-second loop of the theme toggle's circular reveal, dark→light and light→dark]


Solution

Mobile pill (< md) Compact rounded capsule pinned to top-center. Custom Ale logomark as home on the left, then a small row of icon-only buttons (About, Feed, theme toggle) — no text labels at this size. Backdrop-blurred. Hides on scroll-down via a Framer Motion spring.

Mobile pill dock — compact backdrop-blurred capsule pinned top-center with logomark and icon-only About, Feed, theme toggle

Desktop slim dock (md+) A single thin column of small circular icon buttons pinned to the left edge, vertically centered — where left-to-right reading begins. Theme toggle on top, then primary nav (Home, About, Feed), topic shortcuts (Design Systems, Product Design, AI Building, Prototypes), and back-to-top at the bottom — all icon-only, all with tooltips that open to the right on hover and keyboard focus. The slim form keeps the dock present without crowding the reading column, and the homepage hero and feed filter were given extra horizontal padding so the dock never overlaps their content.

Desktop slim dock — single thin column of small circular icon buttons on the left edge

[INTERACTION PLACEHOLDER: 10-second loop — scroll-hide on scroll-down, return on scroll-up, topic-shortcut click jumping to a filtered feed, hover-reveal tooltip]

Engineering grain

  • useScrollDirection hook: three states, 12px threshold, RAF-throttled
  • Custom SVG logomark mixing currentColor (theme-reactive) with hsl(var(--primary)) (brand accent)
  • Subframe Feather icons for the rest
  • Framer Motion springs (stiffness: 420, damping: 34)
  • useReducedMotion honored — animations disable for motion-sensitive users
  • <nav aria-label="Primary"> on both modes, aria-current="page" on the active link, focus-visible rings on every interactive element

Impact

Homepage, current build (May 2026)

Metric Value Tool
Lighthouse Performance (mobile) 76 lighthouse --form-factor=mobile
Lighthouse Accessibility 96 same
Lighthouse Best Practices 100 same
Cumulative Layout Shift 0 Lighthouse
Total Blocking Time 0 ms Lighthouse
Largest Contentful Paint 5.9 s Lighthouse (mobile-throttled)
DockNav.tsx bundle 5.66 KB gzipped (18.7 KB raw) gzip -c dist/_astro/DockNav.*.js
Total site JS 203 KB gzipped (577 KB raw) find dist -name "*.js"
axe-core violations 1 (color-contrast, serious) axe-core 4.11 in headless Chrome
axe-core passes 35 rules same

The one a11y violation is a p.text-sm.tracking-wide element on the homepage (neutral-500 on neutral-100, ratio 4.24 — below the 4.5 WCAG AA threshold). It’s outside the dock itself; the fix is a one-token contrast bump in the homepage copy. CLS 0 and TBT 0 confirm the scroll-hide animation and theme transition don’t shift layout or block interaction.

Structural shifts

  • Hero headline now sits at the top of the viewport — no header tax.
  • One component (DockNav.tsx) replaces Nav.astro, the dead hamburger, and the inline theme toggle.
  • useScrollDirection is portable — reusable for any future floating UI on the site.

What I’d do differently

  • I left the old Nav.astro, Logo.astro, and original ThemeToggle in the repo for a “later cleanup pass.” That cleanup is still pending. I should have deleted them in the same PR; dead code creates ambiguity about which component is current.
  • I hardcoded the nav items inside DockNav.tsx instead of reading from the existing src/data/navData.js. That breaks the pattern the rest of the site uses. A small refactor I owe.

Reflection

The smallest fix isn’t always the right fix — and the first redesign isn’t always the right one either. I sat down to wire a hamburger; forty minutes later I scrapped it for a dock. I shipped that dock on the right edge feeling done; days later I moved it to the left edge, slim, because that’s where reading starts and where wayfinding should be. The “out of the way” instinct had quietly buried the most useful part of the nav. Each course-correction cost time. Each one moved the dock closer to the shape that actually serves the reading instead of competing with it.

The dock also taught me something about responsive design. The standard playbook (one component, breakpoint classes, hide-this-show-that) works when desktop and mobile are the same idea at different sizes. When they’re solving different problems, two shells sharing primitives beats one container with five conditionals.