Where the framework ends and where I begin

A CSS framework gives you the look for free, but charges you in control. The question is not who owns the typography or the colours, which is how it is usually put: it is where the change gets written. The split works as long as every change has one place to go, and only one. It breaks the day you have to touch the framework to move something.
This is the second part of a series about how this site was built: the first one was about how it gets published, and this one is about how it looks.
The layout came out of the content, not the other way round
Before choosing anything I looked at what this site actually had to show: a menu and a list of articles. That's it. There is no user dashboard, no basket, no site-wide search, no sections unfolding into more sections.
Given that inventory, a complicated layout doesn't solve a problem — it creates one. So I picked the simplest thing that did the job: one column with the menu on one side and the content on the other. I also found it the most comfortable to read, which on a site that is almost entirely text is not a minor detail.
Today the layout has three states, not two:
- Phone: a single column. The whole frame of the site — identity, menu, language switch and theme switch — lives in a bar fixed to the bottom, and the content gets the full screen.
- From 768px: the two-column grid appears. On the left, the site identity with the menu below it; on the right, the content; the footer running across the bottom. The bottom bar disappears.
- From 1280px: the identity moves up into a full-width row of its own, leaving the entire left column to the menu.
Those two cut-off points aren't numbers I picked by eye: they are the md and
xl breakpoints Pico already uses. Inventing my own would have meant running two
grids whose sizes don't line up.
Mobile-first isn't about starting with the phone
The name is slightly misleading. Mobile-first doesn't describe the order you
design in; it describes which CSS applies when no condition is met. On this
site the base state — everything written outside any media query — is the phone,
and larger screens are built on top of it with min-width.
I did it that way for two reasons:
The first is what you see when the media queries don't apply. A browser that doesn't evaluate them is left with the CSS that carries no condition — the base state — and nothing else. If that base state describes a two-column desktop, a 360px screen gets two cramped columns. If it describes the phone, that screen gets one column, and one column fits anywhere. It fails in the right direction.
The second is that you write less and think better. With min-width every block
adds: the grid, the sidebar, the full-width header. With max-width every
block undoes the one before it, and you end up with rules whose only job is
to switch off another rule further up.
There's one detail I learned the hard way, and it is written into the CSS itself:
mixing max-width: 767px with min-width: 768px leaves a gap. Screen widths
aren't whole numbers — zoom, or a phone with an unusual pixel density, will hand
you 767.5px — and in that half pixel neither condition is true. That's why the
site never mixes the two directions: either everything stacks upwards, or there
is no reliable boundary.
The menu goes at the bottom
On a phone, the site bar is pinned to the bottom edge of the screen. Almost no website does this, and I want to explain why I think almost no website is right.
The argument is that your thumb doesn't reach the top. And this isn't just an impression of mine: in 2013 Steven Hoober watched more than 1,300 people using their phones out in the street and published how they actually hold them. Close to half were holding the phone one-handed, and thumbs accounted for three out of every four interactions with the screen. That's where the map known as the thumb zone comes from: the bottom band is reached effortlessly, the sides at mid-height ask you to stretch, and the top corners force you to shift the phone in your hand or use the other one. Phones have done nothing but grow since then, so the top-left corner — where so many websites put the menu — is further away today than it was when the measurements were taken.
The striking part is that apps do get this right. The bottom bar is the standard pattern in both Material Design and iOS tab bars. People designing apps have been putting navigation at the bottom for years; people designing websites still put it at the top.
Looking into why the web hasn't followed, I found it isn't only desktop inertia.
There is a genuine technical reason: on the web, the bottom edge isn't yours,
it's the browser's. Safari on iOS put its address bar right there, and that bar
appears and disappears as you
scroll, so a bar
fixed to the bottom ends up fighting it. There is a way round it —
env(safe-area-inset-bottom), which asks the browser how much room it is taking
— but it is a problem a native app simply doesn't have.
In other words, I put the menu at the bottom for comfort and I was right about the comfort, but I also moved into the one part of the screen the web doesn't fully control.
Why Pico
Pico is a minimal, classless CSS framework: instead of
asking you to write class="btn btn-primary btn-lg" everywhere, it styles HTML
elements directly. You write a <button> and it already looks decent.
I chose it for that, and because it's light. It handed me two things I didn't fancy solving from scratch — a coherent look, and light and dark mode — without making me learn a vocabulary of class names that is only good inside that one framework. And if I ever drop it, what's left is ordinary HTML.
Classless doesn't mean there are no classes. My HTML has quite a few —
site-nav, mobile-bar, meta-table — but they're mine and my CSS is what
paints them: they're there to place things, not to make Pico do its job. Of
Pico's own, I use one: container.
The split that came out of that is the one this article is named after: Pico
provides the look, my CSS provides the layout. With one hard rule I have never
broken — pico.min.css is never edited. Ever. Anything that needs changing gets
changed from my own files, by setting the variables Pico itself exposes. When I
update Pico, my CSS will still fit; if I had edited its file, updating would mean
doing the work all over again.
Sticking to that split wasn't free. I had to work out how to override Pico without getting into a specificity fight, and how to stop having a single CSS file that could no longer be read. Both come further down, because I didn't make either decision in the abstract: something concrete I wanted on the site forced each one.
The type scale runs the other way
The first test of the split between Pico and my CSS was the size of the text.
Pico scales typography by window width: the bigger the screen, the bigger the type. Behind that is a reasonable assumption — a large screen is viewed from further away — which on this site turned out to be false. Here the text is 20px on a phone and 17 on the desktop. The phone runs 18% larger, which is a lot, and it's deliberate.
There are two reasons, and they add up.
The first is the measure, meaning characters per line. On the desktop the text column is capped at a fixed reading width, so making the type bigger doesn't widen anything: it shortens the line and brings it closer to the 72 characters you're aiming for. On a phone the column is set by the screen, not by the typography; there, size isn't competing with anything except how much text fits.
The second is angular size. A phone is held considerably closer than a computer, but its pixels are far smaller, and a CSS pixel doesn't reach the eye at the same size in both places.
The interesting part isn't the number, it's how it turned up. You can't see this by making the browser window narrow. A narrow window gives you the same width, but not the same pixel density or the same reading distance: in the emulator the site looked fine and on the phone it read small. The fault was invisible in the tool I was looking through.
Before touching anything I measured the one thing this change can spoil, which is characters per line:
| window | 17px (before) | 18px | 19px | 20px (chosen) |
|---|---|---|---|---|
| 360px | 40 | 33 | 33 | 33 |
| 390px | 42 | 40 | 35 | 33 |
| 430px | 48 | 43 | 42 | 40 |
The numbers said 18: it leaves about 40 characters per line, inside the comfortable 35 to 45 that always gets quoted. The phone said 20. I went with 20, and I think the table was misapplied rather than wrong: that range comes from print and from large screens, and on a phone the line is taken in at a glance. What tires you there isn't finding your way back to the start of the next line, it's straining to see.
And there's something you only learn by trying it on the device itself: 18px wasn't the cautious option, it was the invisible one. Between 17 and 18 there is a 5.9% difference — so little that testing it felt like the change hadn't reached the phone at all. If an adjustment exists to fix something that bothers you and you can't tell once it's in, then by definition it falls short. The jump to 20 is 18%, and there's no arguing with it.
All of this lives in my CSS, in a single declaration, and in percentages, so that whatever text size a visitor has set in their browser is respected. The variable is Pico's, but the value is mine, and its file still hasn't been touched. The step also falls at the same 768px where the menu goes from bottom bar to column: one place where the site changes shape, not two.
The table showed me what was at stake and ruled out the adjustment nobody would notice. The number itself I ended up choosing with the phone in front of me.
The features that pushed the CSS
A two-column layout holds up fine as long as the site has nothing in it. What moves it is the features, and here there were two.
The theme switch — light, dark and system — has three states, and the third is the one that matters: it isn't "dark by default", it's don't decide for me, do whatever the computer says. All three options need room and need to be able to close again, so it sits folded away.
The language switch is two links, ES and EN. I keep the English version of the site without knowing who is going to read it, and that is exactly the point: if somebody lands here looking for how one of these things is solved, the language shouldn't get in their way. It costs little and covers a good deal more.
The two controls live in the same place, together: in the sidebar, under the menu, when there is a column; and in the bottom bar when there isn't. They are the site's two global settings — what changes how you see it, not where you go — and separating them would mean learning two places instead of one.
Neither looks like much, and yet the theme switch is now the largest component in my CSS: nineteen blocks of rules. That, plus the indexes and the record collection, is what eventually broke how the file was organised. And before that, the type step had already broken something else: the way I was overriding Pico.
Who wins when rules collide
Changing the size of the text — 20px on the phone against 17 on the desktop —
was the first place I had to override Pico, and I was overriding it for the worst
possible reason: my stylesheet was linked after Pico's. It works, but it
isn't declared anywhere. Move those two lines in the <head> and the site
changes appearance, with nowhere to read why.
The classic way out is to write selectors heavier than Pico's. That's the one I wanted to avoid: when a selector is long because it has to win a fight, the CSS ends up telling two stories at once — how I want things to look, and which rule beats which — and I only care about the first.
Cascade layers make that fight unnecessary. You declare an order once:
@layer pico, sitio;
and from then on any rule in the sitio layer beats any rule in the pico
layer, however long Pico's is and however short mine is. Selectors go back to
describing intent and nothing else.
Three things gave me trouble:
Anything not in a layer beats everything that is. Layers aren't floors above ordinary CSS; they're an ordered basement below it. So putting my own styles in a layer wasn't enough. If Pico stayed outside, Pico won every time and the layout collapsed entirely. Both have to go in.
A <link rel="stylesheet"> can't be assigned to a layer. The attribute
exists as a proposal and no browser implements it, so the only way to put a whole
file into a layer is @import. Hence main.css, a file without a single style
rule in it: all it does is declare the order of precedence and load the rest.
And layers don't change what you're pointing at. Pico sets list markers with
the rule ul li. Writing list-style: none on the <ul> won't work however
high the layer you put it in, because there is no conflict there to resolve:
Pico's rule is about the <li> and mine was about the <ul>. Layers decide who wins when two
rules point at the same element; they don't point you at the right one. That
one has bitten me three times.
The change didn't move a single pixel — fifteen screenshots, five pages across three widths, identical before and after — and that is precisely the description of what it does: it doesn't change the appearance, it removes a fragility.
A file that could no longer be read
My CSS reached 2,354 lines in a single file. It didn't grow through carelessness: it grew because the site kept gaining things, and each one brought its own.
It's now split into eight files, one per component, and the list of @import
statements in main.css is the map: the tokens, the typography, the utilities,
the theme switch, the parts of Pico I switch off, the indexes, the collection and
the layout.
The order of that list is meaningful, not alphabetical. Within a single layer
the ordinary cascade rules apply again, and between two declarations of equal
weight the last one wins. So the list preserves the order the sections had inside
the big file, which is why splitting it changed nothing. Moving one @import
does change things.
The hard part wasn't breaking the file up; it was deciding where the seams went.
Where does a rule like .mobile-bar .theme-menu go, when it names two
components at once?
A selector like that reads left to right: it says "the theme switch when it is inside the bottom bar". And what that rule decides isn't what the theme switch is like, it's where the bar puts it. It's a rule belonging to the bar, so it goes with the layout. Hence the criterion: a rule lives with the first element of its selector.
Since the change only moved code around, the test could be a strict one: the minified CSS had to come out byte for byte identical. Minifying strips the comments, so the only thing that could differ is actual CSS. It came out at the same 95,410 bytes.
And splitting the file turned up something I wasn't looking for: a convention of my own that hadn't been holding for a long while. Modularising didn't create that fault, it made it visible — which was one of the reasons for splitting the file in the first place.
A typeface that cost 8,584 bytes
The interface and headings on this site weren't always set in this typeface. They were Figtree, and the move to Source Sans 3 didn't come from the typeface at all, it came from a redesign.
When the hierarchy was carried by size — an article heading ran to 49px — the family barely showed, so choosing one over another was almost cosmetic.
Flattening the scale made the family the main signal, and Figtree found itself carrying a role on its own that nobody had chosen it for.
The criterion didn't change, which is the interesting part: it's still contrast without clash, two humanist typefaces contrasting in genre alone, sans against serif. Source Sans 3 is humanist just as Figtree is. What changes is the character, which now shows because size no longer covers it. Six candidates were compared on the real site, not on isolated specimens.
And it comes at a price worth saying out loud: the latin file goes from 20,156 to 28,740 bytes. 8,584 bytes more, 42%. It's accepted knowingly — it's the typeface for the whole interface and it downloads once — but it's a decision with an invoice attached, not a preference.
Images optimise themselves
The goal was not having to prepare anything before uploading a photo: drag it
into the article's directory and that's it. That's handled by the Eleventy image
plugin, which generates the variants
as the site is built and writes the <img> with every width in it.
Measured with a 4032×3024 photo weighing 6.1 MB: a phone downloads 2.3 KB. The original never even reaches the published site, because the plugin reads it from the source directory rather than the output.
The two settings I had to correct say a lot about how far you can trust a plugin's defaults:
widthsincluded the original width. With it, that photo also generated the 4032px variants: 8.6 MB nobody downloads, since the site never paints an image wider than 830px. They ate 43 of the build's 43 seconds; without them, 12.2.sizes="auto"is only understood by Chrome 133 and later. Everything else falls back to the default,100vw, and on a 1920 monitor that requests the largest variant in order to paint it at 830px. The widths had to be measured on the served site: the reading width depends on the typeface, so it can't be worked out on paper.
All of this sounds very tidy
It wasn't. Choosing a framework for what it gives you has a flip side you don't see until you're working with it: it also gives you what you never asked for, and you almost always find out late.
<article> doesn't mean "an article"
Pico decorates by element, not by class. That's the point of it, and also the
catch: <article> isn't "an article" as far as Pico is concerned, it's the card
component.
You choose the tag for what it means and it arrives decorated for how it
looks.
The way out ended up being a question: does this element give me anything beyond its appearance? If not, I change element. If it does, I keep it and switch the decoration off. Sometimes only one property is in the way: I left inline code its background, because telling code apart from text is information, not ornament.
And switching the card off brought something I hadn't expected. Pico pulls the
header and footer of an <article> out to full bleed using negative margins,
offset by the card's own padding. With the padding gone, the offset had
nothing left to offset and the grey band spilled a rem over each side. The
lesson travels: when you switch a decoration off, look at what other rules were
leaning on it.
Eight pixels that weren't mine
The site had a horizontal scrollbar above 768px from the moment I built the layout, and I never once saw it.
The cause is the classic full-bleed fault. The calculation that pulls those bands
out of the margin was calc(50vw - 50%), and those two units don't measure the
same thing: 50vw counts the vertical scrollbar and 50% doesn't. The
difference is half a scrollbar per side, around 8 pixels in total.
Why it went unseen: the text doesn't fall out of alignment. The negative margin and the padding both carry the same calculation and cancel each other out. The only thing sticking out is background.
I tried two fixes and discarded both, each for a different reason that was worth
understanding rather than pushing on. What stayed is clipping the overhang with
overflow-x: clip, and clipping is legitimate here precisely because what
escapes is only background. clip and not hidden: hidden creates a
scroll container and breaks any position: sticky inside it.
The most useful thing about this fault wasn't the fix. It was that my screenshot-comparison tool couldn't see it: it reported 96 identical images before and after, because what stuck out fell outside the crop. An image comparator is not a layout validator.
Winning too much
Layers resolve specificity by taking it out of the equation, and that cuts both
ways. I wanted to switch off one of Pico's decorations on inline code with a bare
code rule. Since the sitio layer wins without looking at specificity,
that four-character rule also beat Pico's pre > code and turned code blocks
into running text.
I didn't catch it myself: what corrected me was looking at the site, where the
blocks already looked right. The fix is to narrow the selector, :not(pre) > code. The general lesson is the one everybody misses when they first discover layers:
when the layer wins the fight for you, the reach of the selector becomes the
only brake left.
Where the line falls
None of these decisions was about aesthetics. Pico still provides the colours and light and dark mode, and its file still hasn't been touched. What I was learning was something else: where to write each change so that I could read it again later. Layers so I don't win by accident, eight files so I can find anything, tokens so a value lives in one place only.
The three faults above have the same cause, and it isn't Pico: a framework makes
decisions you haven't made, and doesn't announce them. As long as the split is
written down, those decisions stay where I can see them. If I had started editing
pico.min.css, there would have been no split left to look at.