Mobile-First Layout That Ships: How PrimaryLayout Solves Real UX Problems

htmx css web-components javascript
Person holding a phone displaying a clean reading app

Most of your readers are on phones. That's not an opinion - it's what the analytics say for virtually every content site. If mobile feels like a second-class experience, you're losing people before they finish a single article. But hiring a UX team or adopting a component library to solve basic layout problems is expensive overkill for a small operation.

PrimaryLayout is a single custom element that handles the hard parts of mobile reading UX: fixed-header offsets, safe-area insets, recommendation card interactions, and loading feedback. It works alongside HTMX for navigation (see the architecture overview) and keeps the total JavaScript budget well under control (more on that in The Minimal JavaScript Approach).

The problem: content hiding behind fixed headers

A fixed header keeps navigation accessible on every scroll, but it causes content to slide underneath whenever HTMX swaps fire. The typical fix - adding top padding to every page template - is fragile and creates coupling between the header height and every template in the system.

Instead, I baked the offset directly into PrimaryLayout:

.pl-container {
          padding-top: 56px;
        }
        .pl-main-spacer,
        .pl-nav-spacer,
        .pl-aside-spacer {
          flex: 0 0 56px;
          height: 56px;
        }
        

Those spacers live inside the custom element's shadow DOM. The rest of the site doesn't know or care about the header height - PrimaryLayout owns that detail. When HTMX finishes swapping in a new article, the layout's scrollMainToTop() method resets the scroll position so the reader starts at the top with no jarring jump.

This is the kind of encapsulation that pays off over time. Change the header height once, in one place, and every page just works.

Safe-area insets: respecting the hardware

Phones with gesture bars (iOS) or navigation pills (Android) can easily overlap bottom-positioned UI elements. This is the kind of bug that's invisible in desktop testing and infuriating to real users.

.pl-main,
        .pl-mobile-recs {
          padding-bottom: calc(0.5rem + env(safe-area-inset-bottom));
        }
        

The env() function grabs the device's safe-area inset at render time, so the mobile "Show more" button always sits above the nav bar. No hardcoded pixel values, no device-specific hacks. One line of CSS that works on every phone.

Recommendation cards that feel native

Recommended articles need to work seamlessly on both desktop and mobile without duplicating logic. The static build process generates two containers (#desktop-recs and #mobile-recs), and PrimaryLayout exposes slots for each. HTMX's out-of-band swaps (hx-swap-oob) refresh both containers whenever a reader loads a new article - the card you just tapped disappears and the list repopulates without a full reload.

The interactions are designed to feel instant:

These are small details, but they're the difference between a site that feels like a web page and one that feels like a product.

Loading feedback without a skeleton screen

Full-page skeleton screens are a well-known pattern, but they're overkill for a content site where navigations take 100-300ms. Instead, I embedded a progress bar directly under the header:

<header class="pl-header">
          <div class="pl-header-container">...</div>
          <div class="pl-progress-line" aria-hidden="true"></div>
        </header>
        

The layout hooks into HTMX lifecycle events to manage the bar's state:

document.body.addEventListener('htmx:beforeRequest', (event) => {
          withLayout(event.detail, (layout) => layout.setLoading(true));
        });
        document.body.addEventListener('htmx:beforeSwap', (event) => {
          withLayout(event.detail, (layout) => layout.setLoading(false));
        });
        

setLoading(true) triggers a pulse animation; setLoading(false) fades it out so the bar never lingers. Because the bar lives inside the custom element, I can change the gradient, the height, or the animation without touching a single page template.

Scroll-to-top that doesn't break on navigation

Early versions tried to scroll the <article> element directly, which broke whenever HTMX replaced the node. The current approach is simpler: the layout exposes scrollMainToTop(), and an htmx:afterSwap listener calls it as soon as new content arrives. Readers always land at the start of the article, even after tapping through several recommendation cards.

Why this matters for small teams

The real value of PrimaryLayout isn't the code - it's the decision surface area it eliminates. Safe-area padding, header offsets, loading indicators, scroll management: these are problems you solve once, encapsulate in a component, and never think about again. Every hour not spent debugging mobile layout quirks is an hour spent writing content, building features, or talking to users.

For the analytics side of understanding how readers interact with this layout, see Zero-Server Analytics. For the full architecture that ties these pieces together, there's How This Blog Works.

What's next

I'm testing two additions:

  1. Highlighting the currently viewed card in the recommendation list so readers can orient themselves after navigating.
  2. A "back to top" affordance for long-form posts - but only if it can ship without meaningfully increasing the JavaScript footprint.

Until then, PrimaryLayout keeps doing the quiet work: respecting safe areas, coordinating HTMX swaps, and making every page feel like it was built for phones first - because it was.

Recommended

Fixing Navigation and Analytics: When Your Data Lies About User Behavior @jameslcowanjun 5 '26 htmx, javascript
How This Site Works: Architecture for a One-Person Team @jameslcowanjun 8 '26 htmx, javascript
HAL: Cutting 100-300KB of JavaScript by Moving Routing to Build Time @jameslcowanjun 10 '26 htmx, javascript
HAL: Build-Time Link Rewriting That Makes Navigation Feel Instant @jameslcowanjun 18 '26 htmx, javascript
Progressive Enhancement with HTMX: Ship Fast, Degrade Gracefully @jameslcowanjun 8 '26 htmx, javascript
Web Components + HTMX: A Lean Architecture for Content Sites That Ship @jameslcowanjun 10 '26 web-components, javascript
Web Components as a Business Decision @jameslcowanjun 11 '26 web-components, javascript
Less JavaScript, More Leverage: Why I Ship With a 35KB Budget @jameslcowanjun 22 '26 javascript, performance
OpenClaw: What It Is and How to Get the Most Out of It @jameslcowanjun 5 '26 openclaw, javascript
Documentation That Scales: Constitution, Contracts, and Runbooks @jameslcowanjun 8 '26 documentation, markdown
Why I Built My Own Analytics Pipeline (And What It Actually Costs) @jameslcowanjun 18 '26 analytics, javascript
The Static Site Playbook: Shipping a Content Product on a Near-Zero Budget @jameslcowanjun 11 '26 static-site-generation, javascript
Anthropic Trained Its Replacement @jameslcowanjun 9 '26 anthropic, ai
Karpathy Was Wrong: OpenClaw Still Outruns Its 5 Real Alternatives @jameslcowanjun 11 '26 openclaw, ai
Pydantic: The Open Source Layer Quietly Running the AI Economy @jameslcowanjun 22 '26 pydantic, python
The YC S26 Deadline Just Closed. Here's What Separates the 1.5% From Everyone Else. @jameslcowanjun 17 '26 startups, yc
OpenClaw 2026.2.23: The Agent Browser Upgrade @jameslcowanjun 19 '26 openclaw, ai
Why I Skipped the CMS @jameslcowanjun 10 '26 markdown, static-site-generation
Zero-Server Analytics: How I Replaced a SaaS Bill with Netlify Functions and GitHub @jameslcowanjun 8 '26 netlify, analytics
Static Site Generation: The Business Case for Pre-Rendered HTML @jameslcowanjun 14 '26 static-site-generation, performance

Recommended

Fixing Navigation and Analytics: When Your Data Lies About User Behavior @jameslcowanjun 5 '26 htmx, javascript
How This Site Works: Architecture for a One-Person Team @jameslcowanjun 8 '26 htmx, javascript
HAL: Cutting 100-300KB of JavaScript by Moving Routing to Build Time @jameslcowanjun 10 '26 htmx, javascript
HAL: Build-Time Link Rewriting That Makes Navigation Feel Instant @jameslcowanjun 18 '26 htmx, javascript
Progressive Enhancement with HTMX: Ship Fast, Degrade Gracefully @jameslcowanjun 8 '26 htmx, javascript
Web Components + HTMX: A Lean Architecture for Content Sites That Ship @jameslcowanjun 10 '26 web-components, javascript
Web Components as a Business Decision @jameslcowanjun 11 '26 web-components, javascript
Less JavaScript, More Leverage: Why I Ship With a 35KB Budget @jameslcowanjun 22 '26 javascript, performance
OpenClaw: What It Is and How to Get the Most Out of It @jameslcowanjun 5 '26 openclaw, javascript
Documentation That Scales: Constitution, Contracts, and Runbooks @jameslcowanjun 8 '26 documentation, markdown
Why I Built My Own Analytics Pipeline (And What It Actually Costs) @jameslcowanjun 18 '26 analytics, javascript
The Static Site Playbook: Shipping a Content Product on a Near-Zero Budget @jameslcowanjun 11 '26 static-site-generation, javascript
Anthropic Trained Its Replacement @jameslcowanjun 9 '26 anthropic, ai
Karpathy Was Wrong: OpenClaw Still Outruns Its 5 Real Alternatives @jameslcowanjun 11 '26 openclaw, ai
Pydantic: The Open Source Layer Quietly Running the AI Economy @jameslcowanjun 22 '26 pydantic, python
The YC S26 Deadline Just Closed. Here's What Separates the 1.5% From Everyone Else. @jameslcowanjun 17 '26 startups, yc
OpenClaw 2026.2.23: The Agent Browser Upgrade @jameslcowanjun 19 '26 openclaw, ai
Why I Skipped the CMS @jameslcowanjun 10 '26 markdown, static-site-generation
Zero-Server Analytics: How I Replaced a SaaS Bill with Netlify Functions and GitHub @jameslcowanjun 8 '26 netlify, analytics
Static Site Generation: The Business Case for Pre-Rendered HTML @jameslcowanjun 14 '26 static-site-generation, performance