Mobile-First Layout That Ships: How PrimaryLayout Solves Real UX Problems
Most of your readers are on phones. That is not a design opinion. It is what the analytics show for virtually every content site. When mobile feels like an afterthought, people leave before they finish the first section.
Hiring a UX team or importing a component library to fix header overlap and safe area bugs is expensive overkill for a solo builder or small product team. The problems are real. The enterprise response is not.
The take: encapsulate mobile layout pain once inside a single custom element. Solve header offset, safe areas, loading feedback, and scroll reset in one place. Never patch every template again.
PrimaryLayout is that element. Fixed header offsets, safe area insets, recommendation card behavior, loading feedback. It sits alongside HTMX for navigation (see the architecture overview) and keeps the JavaScript budget small (more in The Minimal JavaScript Approach).
The failure mode nobody talks about on desktop
Fixed headers keep navigation visible while scrolling. Good for UX. Bad when HTMX swaps new content and the first paragraph slides underneath the bar like it never existed.
The usual fix is top padding on every page template. Fragile. Every template now couples to header height. Change the header once and you hunt padding values across the site.
I baked the offset into PrimaryLayout instead:
.pl-container {
padding-top: 56px;
}
.pl-main-spacer,
.pl-nav-spacer,
.pl-aside-spacer {
flex: 0 0 56px;
height: 56px;
}
Spacers live in the shadow DOM. The rest of the site does not know header height exists. PrimaryLayout owns it. When HTMX finishes swapping an article, scrollMainToTop() resets scroll so the reader starts at the top without a jarring jump.
Change header height once. Every page works. That is the encapsulation dividend.
Safe areas are not optional polish
Gesture bars on iOS. Navigation pills on Android. Bottom UI that looks fine in Chrome DevTools and sits under the system chrome on a real device.
.pl-main,
.pl-mobile-recs {
padding-bottom: calc(0.5rem + env(safe-area-inset-bottom));
}
env() reads the device inset at render time. The mobile "Show more" button stays above the nav bar. No hardcoded pixel hacks per phone model. One line of CSS that survives hardware churn.
Recommendation cards that feel like product, not bolted HTML
Recommended articles need to work on desktop and mobile without duplicating logic. The static build process generates two containers (#desktop-recs and #mobile-recs). PrimaryLayout exposes slots for each. HTMX out of band swaps (hx-swap-oob) refresh both when a reader loads a new article. Tap a card, it fades, the list repopulates, no full reload.
Small details carry the feel:
- Each card listens for
hx-on::before-requestand addspl-recommend-hiddenimmediately on tap. - That class drops opacity, height, and pointer events in one transition. No double tap accidents.
- The "Show more" button matches card styling so it reads as part of the list.
These are not vanity animations. They are the difference between "web page" and "something I would open twice."
Loading feedback without skeleton theater
Full page skeleton screens make sense for slow apps. Content navigations here run 100 to 300 milliseconds. Skeletons would flash and annoy.
I put a progress line under the header instead:
<header class="pl-header">
<div class="pl-header-container">...</div>
<div class="pl-progress-line" aria-hidden="true"></div>
</header>
HTMX lifecycle hooks drive 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) pulses the bar. setLoading(false) fades it out so nothing lingers. Gradient, height, animation all live inside the custom element. Zero template edits.
Scroll reset that survives DOM replacement
Early versions scrolled the <article> node directly. HTMX replaced that node and scroll broke mid session.
Current approach: scrollMainToTop() on the layout, called from htmx:afterSwap when new content arrives. Reader always lands at the article start, even after tapping through several recommendations.
One component that owns the sharp edges
The value isn't the code listing. It's decision surface eliminated.
Safe area padding, header offsets, loading indicators, scroll management: solve once, encapsulate, forget. Every hour not spent debugging mobile layout is an hour on content, features, or customers.
Framework teams hide this complexity behind abstractions you import wholesale. Small teams benefit more from one honest component that owns the sharp edges.
For how readers interact with this layout in production, see Zero-Server Analytics. For the full stack story, How This Blog Works.
Two experiments still on the bench
Testing two additions:
- Highlight the currently viewed card so readers orient after navigating.
- A "back to top" affordance on long posts, only if it ships without bloating the JS budget.
Until then, PrimaryLayout does quiet work: respect safe areas, coordinate HTMX swaps, make every page feel built for phones first because it was.
Lessons
Mobile first is not a media query. It is owning the failure modes that only show up on real hardware.
If you are a founder or engineer shipping a content site without a UX team, do not scatter layout fixes across templates. One custom element. One shadow DOM. One place to change when the header grows or iOS moves the goalposts again.
That is the whole playbook.