Moving away from Tailwind, and learning to structure my CSS
When I started thinking about structuring CSS, I was intimidated at first: I’m not very good at structuring my CSS! But then I started reading blog posts talking about how to structure CSS (like A whole cascade of layers or How I write CSS in 2024) and I realized a couple of things:
- Every CSS code base has a bunch of different things going on (layouts! fonts! colours! common components!)
- It’s extremely useful to have systems or guidelines to manage each of those things, otherwise things descend into chaos
- Tailwind has systems for some of these, and I already know those systems! Maybe I can imitate the systems I like!
For example, Tailwind has:
- a reset stylesheet
- a colour palette
- a font scale
I’m going to talk about a few aspects of my CSS codebase and my thoughts so far what kind of rules I want to impose on the codebase for each one. Some of them are copied from Tailwind and some aren’t.
- reset
- components
- colours
- font sizes
- utility classes
- the base
- spacing
- responsive design
- the build system
I just copied Tailwind’s “preflight styles” by going into tailwind.css and copying the first 200 lines or so.
I noticed that I’ve developed a relationship with Tailwind’s CSS reset over time, for example Tailwind sets box-sizing: border-box on every element (which means that an element’s width includes its padding):
* { box-sizing: border-box; }
I think it would be a real adjustment for me to switch to writing CSS without these, and I’m sure there are lots of other things in the Tailwind reset (like html {line-height: 1.5;}) that I’m subconsciously used to and don’t even realize are there.
I want to start working through Julia's article and see if I can gradually apply each piece here. Is the tailwind preflight a well regarding and suitable normalization system nowadays?
Tailwind's Preflight is well-regarded and a perfectly reasonable choice. A few key points:
- It's built on top of modern-normalize, which is itself the modern successor to the classic normalize.css. So you're standing on solid, widely-used foundations.
- It's less of a pure "normalizer" and more of an opinionated reset. It does the cross-browser smoothing (like modern-normalize) but also adds opinions: removing default margins, unstyling headings/lists, making images block-level, etc.
That second point matters for your goal. A pure normalizer (like modern-normalize alone) just irons out browser inconsistencies and keeps sensible defaults. Preflight goes further and strips defaults so you decide everything. As Julia found, you may have unconsciously absorbed some of those opinions (e.g. box-sizing: border-box on everything, removed heading margins).
So the question for you is really: do you want a plain normalizer (keeps browser defaults, less to override) or an opinionated reset (blank slate, you style everything)?
Since you're learning to structure CSS deliberately, what's your instinct — start from a blank-slate reset like Preflight, or a gentler normalizer that keeps more defaults?
- Source: https://unpkg.com/tailwindcss@2.2.19/dist/base.css
- Cleanup:
perl -0777 -pe 's{/\*.*?\*/}{}gs; s{,\s*\n\s*(?=[^{}]*\{)}{, }g; s{\n{3,}}{\n\n}g' base.css > base.oneline.css
- NB: styles below; edit to view them.
I'll follow Julia's lead here then. I've pasted in the resets above.
Nice — that's step 1 (reset) done. A couple of observations worth noting as you work through it:
- Putting it inside
@layer baseis a smart move. It means everything in your reset has the lowest priority, so any component CSS you write later will win over it without you needing to fuss about specificity. That matches Julia's interest in@layeras a structuring tool. - This is the full ~200-line Preflight you described copying — it includes both the modern-normalize parts and Tailwind's opinionated bits (the
margin: 0on headings/p, the unstyledol/ul, block-level images,border-width: 0on everything).
That last point is the one to keep in mind: with border-width: 0 on every element, a plain border: 1px solid won't show up unless you also set a color — and you've lost default heading sizes and list bullets. That's intentional (blank slate), but it means you'll be adding those back deliberately as you build components.
Want to do a quick sanity check that the reset is actually taking effect before moving on to the next system (components)? Or would you rather press straight ahead?
FYI the reason I added the layer is so it's a lower priority to the unlayered solveit css, so it doesn't break the UI here.
Can you give me a concise example I can try out showing 2 little @layers (with new unused names) we define and use?
Here's a small one — define two named layers, declare their order, then put a rule in each:
@layer theme, widgets;
@layer theme {
p { color: rebeccapurple; }
}
@layer widgets {
p { color: teal; }
}
The key idea: the @layer theme, widgets; line at the top sets the priority order (later = wins). So even though theme and widgets both target p, the text comes out teal — widgets is declared last, so it beats theme, regardless of source order or specificity.
Try dropping that into a <style> block with a <p> and see.
%%html
<style>
@layer l1, l2;
@layer l1 { .demo { color: rebeccapurple; } }
@layer l2 { .demo { color: teal; } }
</style>
<p class="demo">I am teal</p>I am teal
%%html
<style>
@layer l1 { .demo { color: red; } }
</style>
<p class="demo">I am still teal!</p>I am still teal!
This next part is the bulk of the CSS!
The idea here is to organize CSS by “components”, in a way that’s spiritually related to Vue or React components. (though there might not actually be any Javascript at all in the site)
Basically the idea is that:
- Each “component” has a unique class
- The CSS for one component never overrides the CSS for any other component
- Each component has its own CSS file
So editing the CSS for one component won’t mysteriously break something in another component. And probably like 80% of the CSS that I would actually want to change is in various component files, so if I’m editing a 100-line component, I just have to think about those 100 lines. It’s way easier for me to think about.
For example, this HTML might be the .zine “component”.
<figure class="zine horizontal">
<img src="whatever.jpg">
</figure>
And the CSS looks something like this, using nested selectors:
.zine {
...
&.horizontal {
...
}
&.vertical {
...
}
&:hover {
...
}
}
I haven’t done anything programmatic (like web components or @scope) that ensures that components won’t interfere with each other, but just having a convention and trying my best already feels like a big improvement.
OK I'm thinking we'll actually use TAC, but without web components (but will use some new HTMX approaches instead). We might make some little modifications too. Help me start with a small component example, perhaps from the TAC article and examples above.
Let's start with the Badge — it's the simplest self-contained TAC component, and it's pure CSS (the JS evolution comes later). Here's a version you can drop into an %%html cell:
%%html
<style>
@layer components {
x-badge {
display: none;
min-width: 1.25rem;
height: 1.25rem;
border-radius: 0.625rem;
place-content: center;
background-color: blue;
color: white;
}
x-badge[count]::before { content: attr(count); padding: 0 6px; }
x-badge[count]:not([count=""]):not([count="0"]) { display: inline-flex; }
}
</style>
<p>Cart <x-badge count="3"></x-badge></p>
<p>Empty <x-badge count="0"></x-badge></p>
The TAC ideas in play here:
- Tag: a custom
x-badgetag (HTML has no badge element, so we invent one) - Attribute:
countcarries the data and drives visibility — show only when count is present and non-zero - No classes, no JS — just a tag styled by its attribute
I wrapped it in @layer components to match the structure you're building. Give it a run and see the badge appear only on the non-zero one.
%%html
<style>
x-badge {
display: none; min-width: 1.25rem; height: 1.25rem; border-radius: 0.625rem;
place-content: center; background-color: blue; color: white;
}
x-badge[count]::before { content: attr(count); padding: 0 6px; }
x-badge[count]:not([count=""]):not([count="0"]) { display: inline-flex; }
</style>
<p>Cart <x-badge count="3"></x-badge></p>
<p>Empty <x-badge count="0"></x-badge></p>Cart
Empty
FYI you don't need to write %%html for me - just create a fenced block, and solveit adds the magic automatically. I don't think we need a layer for this because solveit doesn't define anything like this, so there's no precedence issues.
Explain place-content, inline-flex, and the selectors please.
The badge works! Here's what those pieces do:
place-content: center — shorthand for align-content + justify-content in one. On a flex/grid container it centres the content both ways. Here it's what centres the count number inside the badge circle (once display becomes inline-flex).
inline-flex — like flex (children laid out with flexbox), but the badge itself sits inline with surrounding text rather than breaking onto its own line. That's why the badge nestles next to "Cart" instead of dropping below it. This is also the value that reveals the badge — the base rule sets display: none, so the badge is hidden until a selector flips it to inline-flex.
The selectors:
x-badge[count]::before— targets the::beforepseudo-element (generated content inserted before the tag's contents) of anyx-badgethat has acountattribute.content: attr(count)pulls the attribute's value in as text, socount="3"renders a "3".x-badge[count]:not([count=""]):not([count="0"])— matches anx-badgethat hascount, and whose count is not empty string, and not"0". Each:not(...)excludes a case. So an empty or zero count staysdisplay: none; anything else flips toinline-flexand becomes visible. That's the whole show/hide logic, done purely by attribute value.
The neat trick: the data (count="3") and the visibility rule live in the same attribute — no separate "hidden" class needed.
Sorry, explain "align-content + justify-content". Give me all the context I need to understand this.
Sure! To understand these you need the idea of flexbox axes. A flex container lays its children out along two axes:
- Main axis — the direction items flow. For
flex-direction: row(the default) that's horizontal (left→right). Forcolumnit's vertical. - Cross axis — always perpendicular to the main axis. So for a row, the cross axis is vertical.
Each axis has a property that controls positioning along it:
justify-content— positions content along the main axis. (Mnemonic: "justify" like justifying text left/right — it's the flow direction.)align-content— positions content along the cross axis.
So place-content: center (which expands to both set to center) means "centre along both axes" — horizontally and vertically centred.
Now, an important wrinkle. There are actually two families of alignment properties:
| Property | Axis | Acts on |
|---|---|---|
justify-content |
main | the group of items as a whole |
align-content |
cross | the lines of items as a whole (only matters with multiple wrapped lines) |
align-items |
cross | each item within its line |
Here's the subtlety with the badge: align-content only does something visible when there are multiple lines (i.e. content has wrapped). With a single line of content, align-content: center often behaves the same as align-items: center in practice in modern browsers — so for the badge's single number, place-content: center ends up centring it nicely either way.
So the short version: place-content: center is a convenient one-liner that centres the badge's content both horizontally (justify-content) and vertically (align-content), which is exactly what you want for a small circular badge with a number in the middle.
%%html
<style>
badge {
display: none; min-width: 1.25rem; height: 1.25rem; border-radius: 0.625rem;
place-content: center; background-color: blue; color: white;
}
badge[count]::before { content: attr(count); padding: 0 6px; }
badge[count]:not([count=""]):not([count="0"]) { display: inline-flex; }
</style>
<p>Cart <badge count="3"></badge></p>
<p>Empty <badge count="0"></badge></p>Cart
Empty
I quite like using these "unofficial" tags too - even although they don't work with web components, that's fine since we won't use them.
Good choice, and you've correctly spotted the one real tradeoff. The reason TAC used x-badge rather than bare badge is the hyphen: a custom element name must contain a hyphen to be registrable via customElements.define(). A bare <badge> can never become a web component — the browser parses it as a generic unknown element (HTMLUnknownElement, styled inline by default). Since you're explicitly opting out of web components, that limitation costs you nothing.
The one thing worth keeping in the back of your mind is the same caveat the TAC article raised about adding striped to tables: a bare, un-prefixed tag could in principle collide with a future real HTML element if WHATWG ever ships a <badge>. The x- (or any hyphenated) prefix is insurance against that, since the spec guarantees hyphenated names are reserved for custom elements and will never be standardised. Low risk for a personal site, but that's the bet you're making.
For styling purposes the bare tag behaves identically — unknown elements accept CSS just fine, which is why your badge renders perfectly.
Yeah it could even be a benefit - if an official <badge> appears, it's likely with some little style updates we could simply use it.
Exactly — and that's very much in the TAC spirit of "leverage what HTML gives you." If a native <badge> ever shipped, you'd likely get the baseline behaviour for free and just layer your own tweaks on top, the same way TAC treats <button ord="..."> or <table striped>: native element first, custom styling added only where needed.
There's even a nice safety net: if a future <badge> came with its own default display, your existing rules (display: none base + inline-flex when count is non-zero) would simply override it, so your show/hide logic keeps working regardless. You'd be enhancing the native element rather than fighting it.
So you're not really making a risky bet — you're writing CSS that degrades (or upgrades) gracefully either way.
Great what else shall we try?
A natural next step is the ordinal buttons — they show TAC's second idea: instead of inventing a tag, you add a custom attribute to a native HTML element. The article left the styles as ..., so let's fill them in with something real:
<style>
button[ord] {
border: none; border-radius: 6px; padding: 6px 14px;
font: inherit; cursor: pointer;
}
button[ord=primary] { background: royalblue; color: white; }
button[ord=secondary] { background: lightsteelblue; color: black; }
button[ord=tertiary] { background: transparent; color: royalblue; text-decoration: underline; }
</style>
<button ord="primary">Save</button>
<button ord="secondary">Cancel</button>
<button ord="tertiary">Learn more</button>
The key contrast with the badge: here we leverage the real <button> (so we keep all its built-in behaviour — focus, keyboard, form submission, accessibility) and only add an ord attribute to carry the variation. A bare <button> with no ord stays completely untouched.
Give it a run and we can tweak the look, or move on to another TAC pattern (striped table, dialog close-button, or the site-header relationships).
%%html
<style>
button[ord] {
border: none; border-radius: 6px; padding: 6px 14px;
font: inherit; cursor: pointer;
}
button[ord=primary] { background: royalblue; color: white; }
button[ord=secondary] { background: lightsteelblue; color: black; }
button[ord=tertiary] { background: transparent; color: royalblue; text-decoration: underline; }
</style>
<button ord="primary">Save</button>
<button ord="secondary">Cancel</button>
<button ord="tertiary">Learn more</button>This is super cool - really nice approach. And the last one removes the background so it doesn't look like a button...
Right — the tertiary deliberately strips the background and reads almost like a plain link. That matches the article's point about ordinal ranking carrying meaning: primary shouts "do this", secondary is a quieter alternative, and tertiary recedes to a low-emphasis action that's barely more than text.
It's a nice illustration of why this is a variation (an attribute) rather than a separate component. All three are still the same native <button> — same behaviour, same accessibility — and the ord value just dials the visual emphasis up or down. The styling can range from "obviously a button" to "looks like a link" without ever changing what the element is.
A small thing you might play with: tertiary buttons often drop the padding too (so they align flush with surrounding text), but keeping it as-is gives a consistent hit-area across all three, which is friendlier for clicking. Either choice is defensible.
OK next?
Let's try the striped table — it shows TAC's boolean attribute pattern (an attribute whose mere presence, with no value, switches on a style):
<style>
table[striped] tbody tr:nth-of-type(odd) { background-color: #f0f0f0; }
table[striped] th, table[striped] td { padding: 6px 12px; text-align: left; }
</style>
<table striped>
<tbody>
<tr><td>Apple</td><td>Red</td></tr>
<tr><td>Banana</td><td>Yellow</td></tr>
<tr><td>Grape</td><td>Purple</td></tr>
<tr><td>Lemon</td><td>Yellow</td></tr>
</tbody>
</table>
The key idea: table[striped] matches purely on the presence of the attribute — no =value needed, just like native booleans such as disabled or checked. A plain <table> stays unstyled; add the word striped and the zebra rows switch on.
The :nth-of-type(odd) is what does the alternating — it selects the 1st, 3rd, 5th... <tr> so only those get the grey background.
%%html
<style>
table[striped] tbody tr:nth-of-type(odd) { background-color: #f0f0f0; }
table[striped] th, table[striped] td { padding: 6px 12px; text-align: left; }
</style>
<table striped>
<tbody>
<tr><td>Apple</td><td>Red</td></tr>
<tr><td>Banana</td><td>Yellow</td></tr>
<tr><td>Grape</td><td>Purple</td></tr>
<tr><td>Lemon</td><td>Yellow</td></tr>
</tbody>
</table>| Apple | Red |
| Banana | Yellow |
| Grape | Purple |
| Lemon | Yellow |
Cool, although we already saw that idea with button[ord]. Let's move on!
The remaining TAC idea we haven't touched is relationships — using parent/child selectors to compose elements. The <dialog> is a great one because it leverages a real native element that already handles focus-trapping, Escape-to-close, and the backdrop for you:
<style>
dialog button[slot=close] {
position: absolute; top: 8px; right: 8px;
border: none; background: none; font-size: 1.25rem; cursor: pointer;
}
dialog button[slot=close]::before { content: '×'; }
dialog { position: relative; border: none; border-radius: 8px; padding: 24px; }
</style>
<dialog open>
<button slot="close" aria-label="Close dialog"></button>
<div>Dialog content here...</div>
</dialog>
The TAC relationship: dialog button[slot=close] styles a button only when it's inside a dialog and carries slot=close. The button has meaning by virtue of its relationship to its parent — the same bare <button> elsewhere stays untouched.
I added open so it shows inline for now. The × comes from the ::before content, and position: absolute tucks it in the corner relative to the position: relative dialog.
Let's use htmx's hx-on for js. I'm not sure we actually need slots, if we're not using web components? Or are they useful for CSS somehow?
You're right to question it. The slot attribute only has real functionality inside Shadow DOM — it assigns a node to a named <slot> in a component's shadow tree. Outside web components it does literally nothing functional. TAC is just using it as an arbitrary attribute to hang a CSS selector on — button[slot=close] is no more special than button[close] or button.close would be. So no, it gives you no CSS-specific benefit; it was just a naming choice to signal intent.
Since you're not using web components, I'd use a plain custom attribute instead, and wire the close up with hx-on:
<style>
dialog { position: relative; border: none; border-radius: 8px; padding: 24px; }
dialog button[close] {
position: absolute; top: 8px; right: 8px;
border: none; background: none; font-size: 1.25rem; cursor: pointer;
}
dialog button[close]::before { content: '×'; }
</style>
<dialog open>
<button close aria-label="Close dialog" hx-on:click="this.closest('dialog').close()"></button>
<div>Dialog content here...</div>
</dialog>
The relationship idea from TAC still holds — dialog button[close] only styles the button by virtue of its dialog parent — but now close is an honest custom attribute doing exactly one job (a CSS hook), and hx-on:click handles the behaviour inline without a separate script.
%%html
<style>
dialog { position: relative; border: none; border-radius: 8px; padding: 24px; }
dialog button[close] {
position: absolute; top: 8px; right: 8px;
border: none; background: none; font-size: 1.25rem; cursor: pointer;
}
dialog button[close]::before { content: '×'; }
</style>
<dialog open>
<button close aria-label="Close dialog" hx-on:click="this.closest('dialog').close()"></button>
<div>Dialog content here...</div>
</dialog>Let's add a button to show the dialog, and have the dialog centered and more dialog-ish :)
Using showModal() (rather than the open attribute) gives you automatic centering, a ::backdrop, and Escape-to-close for free:
<style>
dialog { border: none; border-radius: 12px; padding: 24px; min-width: 300px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
dialog::backdrop { background: rgba(0,0,0,0.4); }
dialog { position: relative; }
dialog button[close] { position: absolute; top: 8px; right: 8px; border: none; background: none; font-size: 1.25rem; cursor: pointer; }
dialog button[close]::before { content: '×'; }
</style>
<button hx-on:click="document.getElementById('demo').showModal()">Open dialog</button>
<dialog id="demo">
<button close aria-label="Close dialog" hx-on:click="this.closest('dialog').close()"></button>
<h2>Hello!</h2>
<div>This dialog is centered with a backdrop.</div>
</dialog>
showModal() is what makes it "dialog-ish" — the browser promotes it to the top layer, centers it in the viewport, and dims everything behind with ::backdrop. The close button still works via the same hx-on:click.
Actually it appeared in the top left.
Maybe something in solveit is impacting it? Oh wait we explicitly added resets earlier...
Good catch — that's almost certainly it. The Preflight reset is in @layer base, but layered author styles still sit in the author origin, which beats the UA origin where the browser's dialog:modal { margin: auto; inset: 0 } centering lives. So the reset's resets quietly knock out the native centering.
Easiest fix is to just assert the centering yourself in the (unlayered, so higher-priority) inline style:
<style>
dialog { inset: 0; margin: auto; border: none; border-radius: 12px; padding: 24px; min-width: 300px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
dialog::backdrop { background: rgba(0,0,0,0.4); }
dialog button[close] { position: absolute; top: 8px; right: 8px; border: none; background: none; font-size: 1.25rem; cursor: pointer; }
dialog button[close]::before { content: '×'; }
</style>
<button ord="primary" hx-on:click="document.getElementById('demo').showModal()">Open dialog</button>
<dialog id="demo">
<button close aria-label="Close dialog" hx-on:click="this.closest('dialog').close()"></button>
<h2>Hello!</h2>
<div>This dialog is centered with a backdrop.</div>
</dialog>
inset: 0 + margin: auto is exactly what the UA stylesheet does for modal dialogs — we're just restoring it explicitly so the reset can't win.
%%html
<style>
dialog { inset: 0; margin: auto;
border: none; border-radius: 12px; padding: 24px; min-width: 300px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
dialog::backdrop { background: rgba(0,0,0,0.4); }
dialog button[close] { position: absolute; top: 8px; right: 8px; border: none; background: none; font-size: 1.25rem; cursor: pointer; }
dialog button[close]::before { content: '×'; }
</style>
<button ord="primary" hx-on:click="document.getElementById('demo').showModal()">Open dialog</button>
<dialog id="demo">
<button close aria-label="Close dialog" hx-on:click="this.closest('dialog').close()"></button>
<h2>Hello!</h2>
<div>This dialog is centered with a backdrop.</div>
</dialog>Super. Anything else?
The one TAC example left from the article is the site header — it pushes the "relationships" idea further by enforcing a rigid nested structure (x-siteheader > header > nav > a) while still leaving the content open. It also gets to reuse your badge inside it:
<style>
x-siteheader { padding: 12px; background-color: lightgray; }
x-siteheader, x-siteheader > header { display: flex; align-items: center; }
x-siteheader::before { content: '🍔'; font-size: 2rem; margin-right: 12px; }
x-siteheader > header > nav > a { padding: 6px; }
x-siteheader > header > nav > a:hover { background-color: lightslategray; }
x-siteheader-extras { display: flex; align-items: center; gap: 12px; margin-left: auto; }
</style>
<x-siteheader>
<header>
<nav>
<a href="home">Home</a>
<a href="products">Products</a>
<a href="support">Support</a>
</nav>
</header>
<x-siteheader-extras>
<a href="cart">Cart <badge count="1"></badge></a>
<a href="account">Account</a>
</x-siteheader-extras>
</x-siteheader>
This shows the deepest TAC pattern: the structure itself is part of the component contract. The descendant/child selectors (>) enforce that nav links live inside header > nav, the ::before bakes in a logo, and margin-left: auto on the extras pushes them to the right edge. Your badge from earlier slots right in.
%%html
<style>
x-siteheader { padding: 12px; background-color: lightgray; }
x-siteheader, x-siteheader > header { display: flex; align-items: center; }
x-siteheader::before { content: '🍔'; font-size: 2rem; margin-right: 12px; }
x-siteheader > header > nav > a { padding: 6px; }
x-siteheader > header > nav > a:hover { background-color: lightslategray; }
x-siteheader-extras { display: flex; align-items: center; gap: 12px; margin-left: auto; }
</style>
<x-siteheader>
<header>
<nav>
<a href="home">Home</a>
<a href="products">Products</a>
<a href="support">Support</a>
</nav>
</header>
<x-siteheader-extras>
<a href="cart">Cart <badge count="1"></badge></a>
<a href="account">Account</a>
</x-siteheader-extras>
</x-siteheader>Is x-siteheader::before here idiomatic? I'd normally expect to just have another child element.
You've put your finger on a real tension. The ::before logo is a common TAC-style trick, but it has a meaningful downside: pseudo-element content is presentational only. It's invisible to screen readers, can't be a link (a logo usually clicks through to home), can't have meaningful alt text, and isn't in the DOM for JS to grab. For a decorative flourish that's fine; for a logo — which is usually a real, clickable, named thing — it's a bit of a stretch.
So your instinct is the more idiomatic one for anything load-bearing:
<x-siteheader>
<a href="home" class="logo"><img src="logo.png" alt="Acme Foods"></a>
<header>…</header>
…
</x-siteheader>
Rough rule of thumb: ::before/::after for decoration (icons, the badge's ×, separators, the attr(count) text), real elements for content (logos, anything clickable, anything that needs alt text or semantics).
The article leaned on ::before partly to make the point that the component can bake in its own logo with zero required markup — nice for a demo, but in practice a real <a><img></a> is the sturdier choice.
Makes sense. What if we wanted the extras to have the same formatting/hover as the headers, except on the right, without repeating those
The simplest move is to broaden the selector so it matches any a inside the header — both the nav and the extras — instead of the deep > header > nav > path.
The margin-left: auto on x-siteheader-extras still pushes that group to the right, so you get identical link styling/hover on both sides with the positioning handled purely by the container. The tradeoff: x-siteheader a now matches all descendant links, so if you ever add a link you don't want styled this way, you'd need to be more specific again.
%%html
<style>
siteheader { padding: 12px; background-color: lightgray; }
siteheader, siteheader > header { display: flex; align-items: center; }
siteheader::before { content: '🍔'; font-size: 2rem; margin-right: 12px; }
siteheader a { padding: 6px; }
siteheader a:hover { background-color: lightslategray; }
siteheader-extras { display: flex; align-items: center; gap: 12px; margin-left: auto; }
</style>
<siteheader>
<header>
<nav>
<a href="home">Home</a>
<a href="products">Products</a>
<a href="support">Support</a>
</nav>
</header>
<siteheader-extras>
<a href="cart">Cart <badge count="1"></badge></a>
<a href="account">Account</a>
</siteheader-extras>
</siteheader>from fasthtml.components import Siteheader,Siteheader_Extras,Header,Nav,A,BadgeCreate a little mk_siteheader(items:list, extras:list) function for me.
add_msg(content="def mk_siteheader(items:list, extras:lis…", msg_type="code", placement="add_after")→"_57ba5013"
{
"id": "toolu_01BSxnYLbEzKULfULQaXnZPx",
"server": false,
"call": {
"function": "add_msg",
"arguments": {
"content": "def mk_siteheader(items:list, extras:list):\n nav = Nav(*[A(t, href=h) for t,h in items])\n return Siteheader(Header(nav), SiteheaderExtras(*[A(t, href=h) for t,h in extras]))",
"msg_type": "code",
"placement": "add_after"
}
},
"result": "_57ba5013"
}
I've added a mk_siteheader function that builds the nav links from items and the extras from extras, each a list of (text, href) pairs. Give it a run and try it with something like mk_siteheader([('Home','home'),('Products','products')], [('Account','account')]).
def mk_siteheader(items:list, extras:list):
nav = Nav(*items)
return Siteheader(Header(nav), Siteheader_Extras(*extras))Show me how to use it. As you see I decided to let the caller create the A tags. That's more flexible (e.g for the badge).
Nice, that's more flexible. Here's how you'd call it:
mk_siteheader(
[A('Home', href='home'), A('Products', href='products'), A('Support', href='support')],
[A('Cart ', Badge(count=1), href='cart'), A('Account', href='account')])
The payoff of your design: the Cart link can carry a Badge inside it, which wouldn't be possible if mk_siteheader built the A tags from plain (text, href) pairs.
navs = dict(Home='home', Products='products', Support='support')
mk_siteheader(
[A(k,href=v) for k,v in navs.items()],
[A('Cart ', Badge(count=1), href='cart'), A('Account', href='account')])Looks good!
The FT components render exactly the structure your CSS expects — siteheader > header > nav > a for the links, and siteheader-extras holding the badge-bearing Cart link. Your mk_siteheader is now a clean reusable building block.
Is that it for components?
Yes — we've now worked through every example from the TAC article, covering all three of its core ideas:
- Tag (invent a new element):
badge,siteheader - Attribute (extend a native element):
button[ord],table[striped] - Relationship (compose via structure):
dialog button[close], thesiteheader > header > nav > ahierarchy
The only piece we skipped is the Badge's JavaScript evolution (the notification bell). The article did that with a web component, but since you're going the htmx route, that'd look quite different.
colours.css has a bunch of variables like this which I can use as necessary. Colour is really hard and I didn’t want to revisit my use of colour in this refactor, so I left this alone.
The only guideline I’m trying to enforce here is that all colours used in the site are listed in this file.
:root {
--pink: #fea0c2;
--pink-light: #F9B9B9;
--red: #f91a55;
--orange: rgb(222, 117, 31);
...
}
OK I guess Julia kinda skipped over this bit. I don't want to however! :) I kinda had an idea in mind. I thought we could define a pallete of, say, 7 colors, col1-col7, and then for each define 7 shades, from lightest to darkest, say col11 (lightest of col1) to col17 (darkest of col1). I think we'd also need to define fg and bg so one can choose white and black if wanted (since I'm assuming even the lightest of a color wouldn't be totally white).
By "lightest to darkest" I actually mean the inverse in the case of dark mode themes. Is there some better nomenclature I could use to explain that?
Yes — the word you're reaching for is tone (or emphasis/prominence), not lightness. The key insight in modern theming is that you want to name colours by their role relative to the background, not their absolute value. That way the same name works in both light and dark mode.
A few established vocabularies you could borrow from:
Tonal / "tones" — Material Design 3 uses a tonal palette with numbers like 0–100 where the number is absolute lightness. But crucially, light mode picks tone 40 for a role while dark mode picks tone 80 for the same role. So the role name stays fixed; the tone number flips.
Surface vs. on-surface — Material's role language:
surface(background-ish) andon-surface(foreground that sits on it). This sidesteps light/dark entirely by naming the relationship.Subtle → bold / "steps" — Radix Colors uses a 12-step scale where step 1 is "app background" and step 12 is "high-contrast text". In their dark theme the same step numbers map to inverted lightness automatically. They describe steps by purpose (backgrounds, borders, text) rather than light/dark.
So rather than "lightest to darkest", the cleaner framing is something like "least prominent to most prominent" or "step 1 = closest to background, step 7 = closest to foreground". Then:
- Light mode: step 1 ≈ near-white, step 7 ≈ near-black
- Dark mode: step 1 ≈ near-black, step 7 ≈ near-white
The number means "how far from the background toward the foreground", which is exactly the inversion you described — and it reads identically in both themes.
For your fg/bg point: that maps neatly too. bg is "step 0" (the surface itself) and fg is "step ∞" (maximum contrast — true white/black if you want it), with your 7 steps as the graded territory in between.
So your col17 could instead be something like col17 meaning "palette 1, prominence 7" — the highest-contrast version of colour 1, whichever direction that is in the current theme.
Does that "prominence/step" framing fit what you had in mind?
Yes exactly. "prominence" is good. And the 7 colors are then theme specific. Is 7 a reasonable number for a palette? We'll map colors to things like primary buttons etc.
For roles mapped to UI things, 7 distinct hues is actually a touch generous — most systems get by with fewer. The common set is roughly:
- primary — main brand actions (primary buttons, links)
- secondary — supporting accent
- neutral/gray — the workhorse for text, borders, surfaces (used far more than any hue)
- success — green-ish (confirmations)
- warning — amber-ish (caution)
- danger/error — red-ish (destructive actions)
- info — blue-ish (notices)
That's 7 if you count neutral, and it lines up almost exactly with your palette size. So 7 is very reasonable — it covers the standard semantic roles without sprawling.
One thing worth deciding early: is col1..col7 meant to be semantic (primary, danger, ...) or abstract (just 7 brand hues you map later)? Julia's file used literal hue names (--pink, --red). The role-based naming (--primary, --danger) tends to age better because the markup says intent not appearance — a primary button stays --primary even if you recolour the brand from blue to green.
You could even keep both layers: abstract hues at the bottom, semantic roles pointing at them:
:root {
--col14: #3b6ea5;
--primary: var(--col1-4);
}
That gives you the "all colours listed in one place" guarantee Julia wanted, plus a role layer that the components actually reference.
Yeah I figured we should first define abstract hues, and later map them to roles. I didn't want to use hue names, to make it easy to switch out themese using just styles. Instead, the hues should be in themes defined such that generally, say, col3 would work well for danger, col1 would work well for neutral, etc. Make sense?
Makes sense. So the position carries a stable, loose semantic convention (col1 = neutral-ish, col3 = danger-ish, col5 = success-ish, ...) while the actual hue lives in the theme. A role layer maps onto positions (--danger: var(--col3?)), so swapping themes just means swapping which hues fill those slots — the role→position mapping stays put, and so does all your component CSS.
The nice consequence: a theme author only has to honour the convention ("slot 3 should read as alarming"), not match exact colours. A warm theme and a cool theme can pick totally different reds-or-oranges for col3 and both still work.
So the structure is three layers:
- hues by position (theme-specific):
--col31…--col37 - roles → positions (stable):
--danger: var(--col36) - components → roles:
button[ord=danger] { background: var(--danger) }
One thing to pin down before we write it: when you say col3 "would work well for danger", is that a hard convention (every theme guarantees col3 is the alarming one) or just a typical tendency? That decides whether the role layer is shared across all themes or redefined per-theme.
Well the theme author should assume that the dev will also use a vibrant col3 on a bright background for danger. It's up to the theme author how that should be displayed. They can make it all grayscale if they want. Or high contrast theme. Or colorblind friendly. Etc. So yes, the col3==danger mapping is part of the theme design. And then our framework will set one or more danger maps like danger=col32 for instance. But col32 doesn't only mean danger; it might make sense to be used in other ways too.
We tell theme authors "hey make sure col32 works well as danger, since that'll be used for that role", for instance.
Make sense?
Makes sense. So the convention is hard but lives in the theme, not the framework:
- Framework owns the stable role→position map:
--danger: var(--col32). This never changes across themes. - Theme author owns what col3-2 actually looks like, with a contract: "col3 is the danger slot, so make col3-2 read well as danger on a bright bg" — but they're free to honour that via vibrant red, grayscale, high-contrast, or colourblind-safe hues.
- Positions stay general-purpose: col3-2 is "the danger colour" by role, but nothing stops a component using it decoratively elsewhere. It's a colour that happens to be suitable for danger, not a colour that only means danger.
So the framework promises themes a fixed set of slots and tells authors what each slot will predominantly be used for; authors promise those slots work for their stated roles. The component CSS only ever references roles, so it's fully theme-agnostic.
That gives a clean separation: swap the theme → hues change; the role map and every component stay untouched.
Would the 2nd number be some combination of saturation/brightness? What's a good way to scale the "intensity"?
Not quite just saturation/brightness — and that's the crux of why this is hard. The naive approach is "take the hue, then ramp HSL lightness from high to low" but that produces perceptually uneven steps: equal numeric jumps in HSL lightness don't look like equal jumps to the eye, and saturation drifts oddly (mid-tones look washed out, darks look muddy).
A few ways to scale "intensity", roughly worst→best:
- HSL lightness ramp — easy, but perceptually lumpy and hue shifts as you darken. Fine for a quick start.
- HSB/HSV with paired L+S curves — better; you usually lower lightness and raise saturation together toward the dark end so darks stay rich rather than greying out. This is what a lot of hand-tuned palettes (Tailwind's old scales) effectively do.
- A perceptual space — OKLCH — the modern best answer. You vary L (perceptual lightness) in roughly even steps and keep H (hue) fixed, adjusting C (chroma) along a curve. Because OKLCH lightness is perceptually uniform, equal L steps look equal, and hue stays put as you darken.
So your second number is best thought of as perceptual lightness (L), with chroma as a secondary curve riding along it — not a raw saturation+brightness blend.
For 7 steps, a clean OKLCH starting point:
--col31: oklch(0.97 0.03 25);
--col32: oklch(0.90 0.07 25);
--col33: oklch(0.80 0.12 25);
--col34: oklch(0.68 0.18 25);
--col35: oklch(0.55 0.20 25);
--col36: oklch(0.43 0.17 25);
--col37: oklch(0.30 0.12 25);
Notice the pattern: L marches down fairly evenly (0.97→0.30), H is fixed (25, a red), and C rises to a peak around the mid-vivid steps then eases off at the very light and very dark ends (pale tints and deep shades can't hold high chroma). That chroma "arch" is the trick that keeps both ends from looking muddy.
For the dark-mode inversion you wanted: keep the same L values mapped to the same step numbers, but flip which end the role layer reaches for — or simply redefine the steps with L ascending. Either way the step number keeps meaning "distance from background toward foreground".
Does OKLCH feel like the right level to work at, or would you rather start simpler with an HSL ramp and refine later?
Yes that sounds perfect.
Let's pick a starting pallete. We need 7 colors (including neutral) that are visually distinctive at 7 lightness levels. Is there a well-regarded standard we should pick for general use?
For distinct hues there isn't one universal "the standard", but a few are genuinely well-regarded references, and which fits depends on whether you weight semantic convention or maximal distinctiveness:
- Open Color — open-source, built specifically for UI, 13 hues each with 10 perceptually-tuned shades. The closest thing to a "just use this" answer for general web UI.
- Radix Colors — 12-step scales with light/dark pairs already solved; hues chosen to be distinguishable. Great if you want the dark-mode inversion handled for you conceptually.
- ColorBrewer (qualitative sets) — the gold standard when the priority is distinctness and colourblind-safety, though it's aimed at data viz rather than UI roles.
Given your role-driven plan (neutral + the semantic six), the pragmatic path is to anchor the four conventional ones to their expected hues and space the rest evenly around the wheel:
- col1 neutral — gray (hue barely matters; pick a slightly cool or warm gray)
- col2 primary — blue ~250 (the safe brand default)
- col3 danger — red ~25
- col4 warning — amber ~75
- col5 success — green ~145
- col6 info — cyan ~220
- col7 secondary — violet ~300
Those hue angles are roughly evenly distributed (except where convention pins them), so the seven stay mutually distinguishable. Since we're working in OKLCH, the hue numbers above drop straight into the H slot and we reuse the same L/C arch for every column.
Want to go with that role-anchored set as the starting theme, or start from an existing palette like Open Color and adapt its hues to our 7-slot layout?
Yup happy with that. So should we just code-gen a CSS file with the colors, using oklch for the variants? I'm thinking ideally we'd first define col1-col7,
Let's generate it. We define the shared L and C arches, the per-column hues, then build the :root block:
add_msg(content="hues = dict(col1=250, col2=250, col3=25,…", msg_type="code", placement="add_after")→"_d86e3221"
{
"id": "toolu_01WHzp1fcC6YaFDMsFDoV7ME",
"server": false,
"call": {
"function": "add_msg",
"arguments": {
"content": "hues = dict(col1=250, col2=250, col3=25, col4=75, col5=145, col6=220, col7=300)\nls = [0.97,0.90,0.80,0.68,0.55,0.43,0.30]\ncs = [0.03,0.07,0.12,0.18,0.20,0.17,0.12]\ndef col_var(n,i,l,c,h): return f\" --{n}{i+1}: oklch({l} {c} {h});\"",
"msg_type": "code",
"placement": "add_after"
}
},
"result": "_d86e3221"
}
add_msg(content="lines = [col_var(n,i,ls[i],0 if n=='col1…", msg_type="code", id="_d86e3221", placement="add_after")→"_571504b7"
{
"id": "toolu_01FkJnn9mFr3jGt5Gx6UpXHf",
"server": false,
"call": {
"function": "add_msg",
"arguments": {
"content": "lines = [col_var(n,i,ls[i],0 if n=='col1' else cs[i],h) for n,h in hues.items() for i in range(7)]\ncss = \":root {\\n\" + \"\\n\".join(lines) + \"\\n}\"\nprint(css)",
"msg_type": "code",
"id": "_d86e3221",
"placement": "add_after"
}
},
"result": "_571504b7"
}
I've added two cells: the first defines the hues map, the ls/cs arches, and a col_var helper; the second builds the :root block (forcing chroma to 0 for col1 so neutral stays a true gray) and prints it. Give them a run and we can eyeball the output.
hues = dict(col1=260, col2=250, col3=25, col4=75, col5=145, col6=220, col7=300)
ls = [0.97,0.90,0.80,0.68,0.55,0.43,0.30]
cs = [0.03,0.07,0.12,0.18,0.20,0.17,0.12]
def col_var(n,i,l,c,h): return f" --{n}{i+1}: oklch({l} {c} {h});"from fasthtml.components import Div,P
from fasthtml.common import Stylelines = [col_var(n,i,ls[i],0 if n=='col1' else cs[i],h) for n,h in hues.items() for i in range(7)]
css = ":root {\n" + "\n".join(lines) + "\n}"
Style(css)sw = [Div(style=f"background:var(--col{n}{i});height:40px", title=f"col{n}{i}")
for n in range(1,8) for i in range(1,8)]
Div(*sw, style="display:grid;grid-template-columns:repeat(7,1fr);gap:4px;width:420px")Wow that looks pretty great to me!
It really does! The OKLCH approach paid off — look at how the lightness marches down evenly across every column, so row 4 reads as "mid" for every hue, not just some. That's the perceptual uniformity doing its job.
A few things worth noticing in your grid:
- col1 (neutral) is a clean true gray since we zeroed its chroma — no colour cast creeping in.
- The chroma arch shows up nicely: the lightest tints (row 1) and darkest shades (row 7) stay calm, while the vivid middle (rows 4–5) carries the punch. No muddy darks, no washed-out mids.
- All seven hues stay mutually distinct at every lightness level, which was the goal.
This is a solid base theme. The next natural step whenever you're ready is the role layer — mapping --danger: var(--col34) and friends — plus deciding your fg/bg anchors.
The theme author should pick fg/bg IMO. My possibly contraversial take is they should be pure black and white for our default theme - users can adjust their brightness if they want less contrast. As an old fogie, I want as much contrast as I can get!
Totally reasonable — max contrast is a defensible default, and it keeps the theme author in control. Pure black/white as the anchors:
add_msg(content="extra = \" --bg: #fff;\\n --fg: #000;\"\nc…", msg_type="code", placement="add_after")→"_29ab99c6"
{
"id": "toolu_01VxLsEU1LHRnHCvdecvUMNS",
"server": false,
"call": {
"function": "add_msg",
"arguments": {
"content": "extra = \" --bg: #fff;\\n --fg: #000;\"\ncss = \":root {\\n\" + extra + \"\\n\" + \"\\n\".join(lines) + \"\\n}\"\nStyle(css)",
"msg_type": "code",
"placement": "add_after"
}
},
"result": "_29ab99c6"
}
Added a cell that prepends --bg: #fff and --fg: #000 to the :root block. Run it and you'll have your pure black/white anchors sitting alongside the seven hue columns.
Actually slow down a bit on the tool use. It's preferable to me for you to simply show code in fenced blocks most of the time. And this code is not even worth showing/doing yet - we still defining our next step. I think we're ready to discuss roles?
You're right, let's discuss before writing anything.
The role layer maps semantic names onto positions. The open questions I see:
1. Which prominence step does each role default to? A role like danger for a button background needs a step that's vivid enough to read as alarming but dark enough for white text to sit on it — probably step 4 or 5. So --danger: var(--col34). But...
2. Does a role need one value or several? A danger button wants a solid fill (~step 4), but a danger alert box might want a pale tint background (step 1) with dark danger text (step 6). So a single --danger often isn't enough — you tend to end up with something like --danger, --danger-bg, --danger-fg (or a subtle/bold pair).
3. Where does neutral fit? Text, borders, and surfaces all come from col1. Those are arguably the most-used roles of all — --fg/--bg you've pinned to pure black/white, but you'll likely want --border, --surface, --muted (dimmed text) pulling from neutral's mid steps.
So before mapping, the key decision is: how rich is a "role"? Is it a single colour, or a little bundle (fill / on-fill / subtle-bg)?
My instinct: start with the simplest thing that works — each semantic role points at a single "solid" step (the fill), and we add -bg/-fg variants only when a component actually needs them. Matches the incremental spirit you've been working in.
How rich do you want a role to be?
Give me code that shows a few prominence levels of bold col3 text on a black background, then do the same on a white background. Let's see.
def swatch(i, bg): return Div(f"col3{i} danger text", style=f"color:var(--col3{i});background:{bg};padding:8px;font-weight:bold")
Div(*[swatch(i,'#000') for i in range(1,8)], *[swatch(i,'#fff') for i in range(1,8)])
This renders col31→col37 bold on black, then the same on white, so you can see which steps stay legible against each background.
def swatch(i, bg): return Div(f"col3{i} danger text", style=f"color:var(--col3{i});background:{bg};padding:8px;font-weight:bold")
Div(*[swatch(i,'#000') for i in range(1,8)], *[swatch(i,'#fff') for i in range(1,8)], style='display:flex')col34 in both cases looks the most alert-y to me. Does that match standard wisdom?
Pretty much, yes — but with a nuance worth knowing.
col34 (L=0.68) is your peak-chroma-meets-mid-lightness step, so it's the most saturated, vivid red — which is exactly what "alert-y" means perceptually. The eye reads high chroma as urgency.
The nuance from standard wisdom: a single mid step like col34 looks alarming on both black and white, but it's usually not the one you'd use for the two most common danger jobs:
- Danger button fill (white text on red): you'd typically go a touch darker — col35 — so white text clears the ~4.5:1 contrast bar. col34 is often a hair too light for white text to pass.
- Danger text on a white page: col34 can be borderline for legibility; col35/col36 read as safer body text.
So col34 is the "warning light" colour — perfect when the red itself is the signal (an icon, a badge, a border, a focus ring). The darker steps are for when the red is a surface carrying text.
That actually reinforces the "a role is a little bundle" idea from earlier: danger as a vivid accent (col34) and danger as a text/fill colour (col35/36) genuinely want different steps.
Yeah I meant for "danger text on standard bg", which is what we tested. Now show me a few swatches of black text a few col3 backgrounds, and ditto for white.
def swatch(i, fg): return Div(f"col3{i} bg", style=f"background:var(--col3{i});color:{fg};padding:8px;font-weight:bold")
Div(*[swatch(i,'#000') for i in range(1,8)], *[swatch(i,'#fff') for i in range(1,8)], style='display:flex')
This shows black text over col31→col37 backgrounds, then white text over the same, so you can see which steps each text colour sits on comfortably.
def swatch(i, fg): return Div(f"col3{i} bg", style=f"background:var(--col3{i});color:{fg};padding:8px;font-weight:bold")
Div(*[swatch(i,'#000') for i in range(1,8)], *[swatch(i,'#fff') for i in range(1,8)], style='display:flex')OK yeah clearly col33 (which would be col35 for dark theme / light text) is better here. So I guess we need danger=col34 and danger-bg=col33? Is there some usual way to say "danger but with opposite precedence text" too, to get col35 when needed?
Yes — there's standard vocabulary for exactly this. What you're describing is the well-trodden subtle / default / bold triad (Material calls it the "container" model, GitHub Primer calls the strong one "emphasis"). The pattern recognises that one semantic role needs three surfaces, not one:
--danger= col34 — the vivid accent: danger text/icon/border on the standard bg--danger-subtle(or-bg) = col33 — a soft tinted surface that takes default fg text (black here)--danger-bold(or-emphasis/-solid) = col35 — a strong fill that takes contrasting text (white)
And the companion to that last one is the "on" colour — the text that rides on the bold fill:
--on-danger= white (your--bg, effectively)
So "danger but with opposite-precedence text" is the bold/emphasis variant: col35 paired with --on-danger. That's the danger button; col33 is the danger alert box; col34 is danger as a signal (icon, ring, link).
The neat part for your theme-inversion: these are all role names, so the col-step mapping lives in the theme. Dark theme just redefines:
--danger: var(--col34);
--danger-subtle: var(--col35);
--danger-bold: var(--col33);
--on-danger: var(--fg);
Same four names, flipped steps — every component referencing --danger-bold keeps working untouched.
So the convention I'd suggest: each semantic role expands to {role}, {role}-subtle, {role}-bold, on-{role}. Start by defining only the ones a component actually needs, and fill in the rest as they come up.
We may as well define the 7 roles you mentioned earlier, since we're trying to create something generally useful.
Here's a generator for the role layer, mapping each semantic role to its column and expanding to the four-name triad+on:
roles = dict(neutral=1, primary=2, danger=3, warning=4, success=5, info=6, secondary=7)
def role_vars(r,n): return [f" --{r}: var(--col{n}4);", f" --{r}-subtle: var(--col{n}3);", f" --{r}-bold: var(--col{n}5);", f" --on-{r}: var(--bg);"]
rlines = [l for r,n in roles.items() for l in role_vars(r,n)]
print("\n".join(rlines))
Each role points at step 4 (accent), step 3 (subtle bg), step 5 (bold fill), and --bg for the on-colour — exactly the steps we validated with the danger swatches.
I'm not convinced about --on-*, since it's a constant.
You're right — it's var(--bg) for all seven, so it carries no information. That's a direct consequence of OKLCH lightness being perceptual: every bold fill sits at L=0.55, so white clears contrast equally across all hues. There's no hue where the on-colour needs to flip.
So drop the per-role --on-* entirely. Two options:
- Just reference
--bgdirectly in components (color: var(--bg)on a bold fill). - Keep a single named handle
--on-bold: var(--bg)if you'd like the intent documented in one place — useful only if some future theme makes bold fills light enough to need dark text, in which case that one line flips instead of seven.
I'd lean to the single --on-bold — it's one line, names the intent, and gives a theme author exactly one knob if they ever break the constant-contrast assumption.
Agreed.
def role_vars(r,n): return [f" --{r}: var(--col{n}4);", f" --{r}-subtle: var(--col{n}3);", f" --{r}-bold: var(--col{n}5);"]
rlines = [l for r,n in roles.items() for l in role_vars(r,n)] + [" --on-bold: var(--bg);"]
print("\n".join(rlines))
Dropped the per-role --on-*, kept the triad, and appended a single --on-bold at the end.
roles = dict(neutral=1, pri=2, danger=3, warning=4, success=5, info=6, sec=7)
def role_vars(r,n): return [f" --{r}: var(--col{n}4);", f" --{r}-subtle: var(--col{n}3);", f" --{r}-bold: var(--col{n}5);"]
rlines = [l for r,n in roles.items() for l in role_vars(r,n)] + [" --bg: #fff;", " --fg: #000;", " --on-bold: var(--bg);"]
print(("\n".join(rlines))[:180]) --neutral: var(--col14);
--neutral-subtle: var(--col13);
--neutral-bold: var(--col15);
--pri: var(--col24);
--pri-subtle: var(--col23);
--pri-bold: var(--col25);
--da
What's a good way to show these as swatches for each of light and dark now?
A clean way is to make one swatch-row function for a role, then render the whole set twice inside two themed wrappers — light keeps the :root mapping, dark is a scoped override that flips subtle↔bold and swaps bg/fg.
def role_row(r): return Div(Div('subtle',style=f"background:var(--{r}-subtle);color:var(--fg);padding:6px;flex:1"), Div(r,style=f"background:var(--{r});color:var(--on-bold);padding:6px;flex:1"), Div('bold',style=f"background:var(--{r}-bold);color:var(--on-bold);padding:6px;flex:1"), style="display:flex;font-weight:bold")
def roles_grid(): return Div(*[role_row(r) for r in roles], style="display:flex;flex-direction:column;gap:4px;width:320px;padding:12px")
For the dark override we generate flipped role vars (subtle=col5, bold=col3) plus inverted bg/fg, scoped to a .dark class:
def dark_vars(r,n): return [f" --{r}-subtle: var(--col{n}5);", f" --{r}-bold: var(--col{n}3);"]
dlines = [" --bg:#000;", " --fg:#fff;"] + [l for r,n in roles.items() for l in dark_vars(r,n)]
dark_css = ".dark {\n" + "\n".join(dlines) + "\n}"
print(dark_css)
Then show them side by side:
Div(roles_grid(), Div(roles_grid(), cls="dark", style="background:#000"), style="display:flex;gap:16px")
The base values (--neutral etc. at col4) stay put since the mid step works on either background; only the subtle/bold ends and bg/fg flip.
def role_row(r):
return Div(style="display:flex;font-weight:bold")(
Div('subtle',style=f"background:var(--{r}-subtle);color:var(--fg);padding:6px;flex:1"),
Div(r,style=f"background:var(--bg);color:var(--{r});padding:6px;flex:1"),
Div('bold',style=f"background:var(--{r}-bold);color:var(--on-bold);padding:6px;flex:1"))
def roles_grid(): return Div(*[role_row(r) for r in roles], style="display:flex;flex-direction:column;gap:4px;width:320px;padding:12px")def dark_vars(r,n): return [f" --{r}-subtle: var(--col{n}5);", f" --{r}-bold: var(--col{n}3);"]
dlines = [" --bg:#000;", " --fg:#fff;"] + [l for r,n in roles.items() for l in dark_vars(r,n)]
dark_css = ".darktheme {\n" + "\n".join(dlines) + "\n}"
Style(":root {\n" + "\n".join(lines+rlines) + "\n}\n" + dark_css)Div(roles_grid(), Div(roles_grid(), cls="darktheme", style="background:#000;--on-bold:var(--bg)"), style="display:flex;gap:16px")Then, if we want to simply use one of these colors or roles directly, we should probably make them available as classes with the same name - wdyt?
Good idea — that's the natural bridge from "design tokens" to "usable in markup". The one decision: a class name like .danger is ambiguous about which surface it means. Common convention is each role gives you a small family:
.danger→ bold fill + on-colour (the solid button look).danger-subtle→ subtle bg + normal fg (the alert-box look).danger-text→ just coloured text (the accent look)
That mirrors the three things we already validated. Here's a generator:
def role_cls(r): return [f".{r} {{ background:var(--{r}-bold); color:var(--on-bold); }}",
f".{r}-subtle {{ background:var(--{r}-subtle); color:var(--fg); }}",
f".{r}-text {{ color:var(--{r}); }}"]
clines = [l for r in roles for l in role_cls(r)]
print("\n".join(clines))
Each role expands to three classes pulling from the exact tokens we tested, so the classes inherit the light/dark flipping for free.
def col_cls(n,i): return [f".col{n}{i} {{ color:var(--col{n}{i}); }}", f".col{n}{i}-bg {{ background:var(--col{n}{i}); }}"]
cclines = [l for n in range(1,8) for i in range(1,8) for l in col_cls(n,i)]One thing I appreciated about Tailwind was that if I wanted to set a font size, I could just think “hm, I want the text to be big”, write text-lg, and be done with it! And maybe if it’s not big enough I’d use xl or 2xl instead. No trying to remember whether I’m using em or px or rem.
So I defined a bunch of variables, taken from Tailwind, like this:
--size-xs: 0.75rem;
--line-height-xs: 1rem;
--size-sm: 0.875rem;
--line-height-sm: 1.25rem;
Then if I want to set a font size, I can do it like this. It’s a little more verbose than Tailwind but I’m happy with it for now.
h3 {
font-size: var(--size-lg);
line-height: var(--line-height-lg);
}
What theory and practices do I need to know about re font sizes? My guess is we should define some vars a few font sizes and a few line-height-multipliers for them.
A few key ideas worth having in your head:
1. Use a scale, not arbitrary sizes. Pick a base (usually 1rem ≈ 16px) and multiply by a fixed ratio to step up/down. Common ratios: 1.2 (minor third, subtle), 1.25 (major third), 1.333 (perfect fourth, punchy). So each step is prev × ratio. This gives visual harmony for free, the same way your OKLCH lightness arch did for colour.
2. rem for sizes. rem is relative to the root font size, so if a user bumps their browser's default text size (accessibility!), everything scales. px ignores that wish; em compounds when nested (a 1.2em inside a 1.2em...). So rem is the safe default for a type scale.
3. line-height should be unitless. This is the big one, and it's where I'd diverge from Julia's file. She paired each size with an absolute line-height (--line-height-xs: 1rem). A unitless multiplier like 1.5 means "1.5× this element's font-size", so it scales correctly per size. An absolute value doesn't — a 1rem line-height on a giant heading would cramp it. Unitless also avoids an inheritance trap where children inherit the computed pixel value rather than the ratio.
4. Tighter line-height for big text, looser for small. Body text wants ~1.5 for readability; large headings want ~1.1–1.2 (long lines of huge text with airy spacing look disconnected). So line-height isn't one constant — it loosens as size shrinks.
So your instinct is right, with one refinement: define a handful of sizes (as a ratio-based scale in rem), and a couple of unitless line-height multipliers (say tight for headings, normal for body) — rather than one line-height per size. That's fewer vars and they compose better.
Want to pick a base + ratio and generate the size scale, the same code-gen way we did colours?
I feel like the modern tendency to 1.5 line height is not great - I prefer denser. Am I right in thinking traditionally in classic newpapers and books that lines were tighter before?
You're largely right, with some nuance.
Traditional print body text typically runs tighter than the web's 1.5 — books and newspapers commonly sit around 1.2–1.4 (in typographic terms, "leading" of a few points over the type size). Dense newspaper columns especially trended tight, partly to fit more text per column inch.
But there are a few confounds worth knowing:
Line length interacts with line-height. The narrower the column, the tighter you can set the leading without the eye losing its place returning to the next line. Newspapers used very narrow columns (a few words wide), which is exactly the condition where tight leading reads fine. The web's 1.5 convention assumes wider measures (60–75 characters), where looser leading helps the return sweep. So the rule isn't "print = tight, web = loose" so much as "long lines need more leading than short lines."
Ink and contrast. Black ink on cream paper has gentler contrast than
#000on#fffscreens. Your max-contrast pure-black-on-white choice actually pushes slightly toward wanting a touch more breathing room than print did, since high contrast makes tight lines feel more crowded.x-height of the typeface. Fonts with large x-heights (most screen fonts) look more crowded at a given line-height than the smaller-x-height faces common in book printing, so they tend to want a bit more leading to feel equivalent.
So your preference for denser is well-founded and historically grounded — just bear in mind that if you go tight, you'll want to keep your line length shorter to match (which dovetails with Julia's --inner-width column idea). A reasonable dense-but-safe body might be ~1.4 rather than 1.5, going tighter (~1.15–1.25) for headings.
In my software I set prose at 1.3, which feels perfect to my eye.
1.3 is a sensible spot — it sits right in that classic book/newspaper band we just talked about, denser than the web's reflexive 1.5 but still open enough to avoid lines visually colliding. Given your other choices it's especially coherent: max contrast and a constrained column width are exactly the conditions where 1.3 reads cleanly, since shorter lines don't need the extra leading to guide the return sweep.
A reasonable plan then: 1.3 for prose/body, and step down a touch for large headings (~1.15) where tighter looks more deliberate. We can fold those into the type scale as two unitless multipliers whenever you're ready to generate it.
When should I use em, rem, and px nowadays? Is there consensus?
There's reasonable consensus now, and it breaks down by what you're sizing:
rem — the default for most things. Font sizes especially. Because rem is relative to the root, it respects the user's browser font-size preference (a real accessibility win) without the compounding problem em has. Use it for type scales, and increasingly for spacing/layout too so the whole UI scales with user preference.
em — when you want sizing relative to the current element's font-size. Its compounding (each nested level multiplies) is a bug for font-size but a feature for properties that should track the local text. Classic uses:
- padding/margin on a button that should grow with the button's own text
line-height(though there you usually want it unitless, which is subtly different again)- a
::beforeicon sized inemso it matches whatever text it sits beside
So em shines for "this should scale with this component's font", which is exactly the badge/button pattern you've been building.
px — when you genuinely want a fixed, non-scaling value. Consensus has softened here; it's fine for things that aren't really "type" and shouldn't scale:
- borders (
1px solid— a hairline should stay a hairline) - the root font-size itself is best not set in px (or left unset) so you don't override the user's preference
- occasionally box-shadows, fine positioning, tiny radii
The one near-universal "don't": avoid px for font-size, because it silently overrides users who've bumped their default text size.
OK so maybe we should set text sizes at about the same that tailwind uses, and then add line heights with the same sz modifiers that decrease. Suggest some numbers.
Here's a Tailwind-aligned scale, with line-heights that tighten as size grows (anchored on your 1.3 prose preference):
sizes = dict(xs=0.75, sm=0.875, base=1.0, lg=1.125, xl=1.25, xl2=1.5, xl3=1.875, xl4=2.5)
lhs = dict(xs=1.4, sm=1.35, base=1.3, lg=1.3, xl=1.25, xl2=1.2, xl3=1.15, xl4=1.1)
The idea: tiny text gets the airiest leading since short glyphs on long lines need help; body/base sits at your 1.3; and the big display sizes tighten toward 1.1 where dense looks deliberate.
sizes = dict(xs=0.75, sm=0.875, base=1.0, lg=1.125, xl=1.25, xl2=1.5, xl3=1.875, xl4=2.5)
lhs = dict(xs=1.4, sm=1.35, base=1.3, lg=1.3, xl=1.25, xl2=1.2, xl3=1.15, xl4=1.1)OK so now gen some variables and classes?
def size_var(k,v): return f" --size-{k}: {v}rem;\n --lh-{k}: {lhs[k]};"
szlines = [size_var(k,v) for k,v in sizes.items()]
def size_cls(k): return f".text-{k} {{ font-size:var(--size-{k}); line-height:var(--lh-{k}); }}"
szcls = [size_cls(k) for k in sizes]
print(":root {\n" + "\n".join(szlines) + "\n}\n" + "\n".join(szcls))
Each size gets a --size-* and matching --lh-* var, plus a .text-* class that sets both font-size and its paired line-height together.
def size_var(k,v): return f" --size-{k}: {v}rem;\n --lh-{k}: {lhs[k]};"
szlines = [size_var(k,v) for k,v in sizes.items()]
def size_cls(k): return f".text-{k} {{ font-size:var(--size-{k}); line-height:var(--lh-{k}); }}"
szcls = [size_cls(k) for k in sizes]
szs = ":root {\n" + "\n".join(szlines) + "\n}\n" + "\n".join(szcls)
print(szs[:120], '\n...')
print(szs[-120:])
Style(szs):root {
--size-xs: 0.75rem;
--lh-xs: 1.4;
--size-sm: 0.875rem;
--lh-sm: 1.35;
--size-base: 1.0rem;
--lh-base
...
t-size:var(--size-xl3); line-height:var(--lh-xl3); }
.text-xl4 { font-size:var(--size-xl4); line-height:var(--lh-xl4); }
OK how can we show them nicely?
add_msg(content="Div(*[P(f\"text-{k} — Sphinx of black qua…", msg_type="code", placement="add_after")→"_6df23129"
{
"id": "toolu_01RjikviBieNWp5rcRee1hVW",
"server": false,
"call": {
"function": "add_msg",
"arguments": {
"content": "Div(*[P(f\"text-{k} — Sphinx of black quartz\", cls=f\"text-{k}\") for k in sizes], style=\"padding:12px\")",
"msg_type": "code",
"placement": "add_after"
}
},
"result": "_6df23129"
}
Added a cell that renders one P per size with its .text-* class, each showing a sample line so you can eyeball the scale and the tightening line-heights together. You'll need P imported from fasthtml.components.
txt = "Sphinx of black quartz, judge my vow. " * 3
Div(*[P(f"text-{k} — {txt}", cls=f"text-{k}", style="max-width:32em;margin:0.7em 0") for k in sizes])text-xs — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
text-sm — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
text-base — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
text-lg — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
text-xl — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
text-xl2 — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
text-xl3 — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
text-xl4 — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
Looks very reasonable. Anything else that can help with text sizing?
A few things we haven't touched that are worth knowing:
1. Fluid type with clamp(). Instead of fixed rem sizes plus breakpoints, clamp(min, preferred, max) lets a size scale smoothly with viewport width. E.g. clamp(1.5rem, 1rem + 2vw, 2.5rem) grows a heading as the window widens, then stops at the bounds. Great for big display sizes; usually overkill for body text. This is the "fluid type" idea behind tools like Utopia.
2. Line length (the "measure"). Already came up via your column width, but it's a typographic control too: ~60–75 characters is the readability sweet spot, set with max-width in ch units (max-width: 66ch), where 1ch ≈ one character's width. Pairs naturally with your dense 1.3 leading.
3. Letter-spacing (tracking) at the extremes. Big headings often look better very slightly tightened (letter-spacing: -0.02em); tiny all-caps labels benefit from loosening (+0.05em). Subtle, but it's the polish layer on a type scale.
4. A couple of useful font properties:
text-wrap: balance— evens out ragged headings across lines (modern, well-supported now).font-variant-numeric: tabular-nums— equal-width digits, handy for your badge counts or tables so numbers don't jiggle.
Of these, the measure (ch-based max-width) is the one I'd fold into the system next, since it directly supports your dense-leading choice. The rest are per-component polish you can reach for as needed.
Not sure there's anything a foundation framework like this can/should do re max width. That's really a downstream issue. clamp() is cool, nice to know about - how do I interpret the 3 vals you showed?
clamp(min, preferred, max) picks the preferred value, but never lets it go below min or above max. So it's effectively max(min, min(preferred, max)).
Taking clamp(1.5rem, 1rem + 2vw, 2.5rem):
- min =
1.5rem— the floor. On narrow screens the size never drops below this. - preferred =
1rem + 2vw— the "live" value that tracks viewport width.2vwis 2% of the viewport width, and the1rembase keeps it from getting too tiny at the low end. As the window widens, this term grows. - max =
2.5rem— the ceiling. On wide screens the size stops growing here.
So the heading sits at 1.5rem on a phone, grows smoothly as the viewport widens (driven by the vw term), then caps at 2.5rem on a big monitor. The vw in the middle is what makes it fluid — without a vw (or %/vh) term there, clamp would just be a static pick and the smooth scaling wouldn't happen.
One gotcha: using vw alone as the preferred (e.g. 4vw) makes text scale only with viewport, which can hurt accessibility since it ignores the user's zoom/font preference. Mixing in a rem term (the 1rem + part) keeps it partly tied to user settings, which is the recommended pattern.
There are some things like buttons that appear in many different components. I’m calling these “utilities”.
I copied some utility classes from Tailwind (like .sr-only for things that should only appear for screenreader users).
This section is pretty small and I try to be careful about making changes here.
What utilities are most helpful based on seeing tailwind folks using them in practice?
Based on what actually gets used heavily in real Tailwind codebases, the high-value utilities cluster into a few groups:
Flexbox/layout (by far the most used) — these dominate real markup:
flex,items-center,justify-between,gap-*— the workhorse combo for rows of things (toolbars, nav, card headers)grid,grid-cols-*— for actual grids
Spacing — the single most-typed utilities:
gap-*(on flex/grid parents — the "no outer margin" friendly way to space children, matching that article from earlier)- the occasional
p-*for padding inside bordered/filled surfaces
The accessibility staple:
.sr-only— visually hidden but readable by screen readers (Julia mentioned this one). Genuinely useful and you can't easily reproduce it ad-hoc.
A handful of "can't easily do inline" helpers:
truncate(ellipsis overflow — fiddly to remember: needs three properties together)hidden/ display togglessr-only's visual cousin for focus states
The interesting pattern from practice: people think they need hundreds of utilities, but the 80/20 reality is that flex + gap + items/justify-center covers the overwhelming majority of layout, and the rest are occasional reaches.
For your foundation, I'd suggest the small careful set Julia hints at: .sr-only, a flex helper or two, and truncate — the things that bundle multiple properties you'd hate to retype. Things like a single gap or padding are arguably better left to the component, since they're one property and context-specific.
Which would you like to start with?
We'll come back to spacing later. So show me the utilities you recommend.
Here are the ones worth baking in — each bundles properties you'd hate to retype:
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row { display: flex; align-items: center; }
.row-between { display: flex; align-items: center; justify-content: space-between; }
.hidden { display: none; }
.sr-only hides content visually but keeps it for screen readers; .truncate is the three-property ellipsis combo; .row/.row-between cover the most common flex layouts; .hidden is a plain display toggle.
Write me a little %%css magic.
A simple cell magic that wraps the cell body in a <style> and displays it:
from IPython.core.magic import register_cell_magic
@register_cell_magic
def css(line, cell): display(HTML(f"<style>{cell}</style>"))
from IPython.core.magic import register_cell_magic@register_cell_magic
def css(line, cell): display(HTML(f"<style>{cell}</style>"))%%css
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hide { display: none; }Show me a truncate example. (I'll come back to flex etc later.)
add_msg(content="Div(\"Sphinx of black quartz, judge my vo…", msg_type="code", placement="add_after")→"_4c068697"
{
"id": "toolu_01QmKtBTyyY4Lw2Cx1GXkna5",
"server": false,
"call": {
"function": "add_msg",
"arguments": {
"content": "Div(\"Sphinx of black quartz, judge my vow. The five boxing wizards jump quickly.\", cls=\"truncate\", style=\"width:200px\")",
"msg_type": "code",
"placement": "add_after"
}
},
"result": "_4c068697"
}
Added a cell with a long line of text in a 200px-wide Div using the truncate class — run it and you'll see the text cut off with an ellipsis where it overflows.
Div("Sphinx of black quartz, judge my vow. The five boxing wizards jump quickly.", cls="truncate", style="width:200px")“base” styles are styles that apply across the whole site that I chose myself. I have to keep this section really small because I’m not confident enough to enforce a lot of styles across the whole site. These are the only two I feel okay about right now, and I might change the <section> one:
/* put a 950px column in the middle of each <section> */
section {
--inner-width: 950px;
padding: 3rem max(1rem, (100% - var(--inner-width))/2);
}
a {
color: var(--orange);
}
I think for the base styles it’s going to be easiest for me to work kind of bottom up – first start with almost nothing in the base styles, and then move some styles from the components into base styles as I identify common things I want.
This is probably site-specific?
Largely, yes. Base styles are where a foundation framework and a site part ways. Julia's two examples are a good illustration of the split:
a { color: var(--orange) }— that's a site decision (which brand colour links take). A framework shouldn't impose it, because every site wants something different.section { ...950px column... }— even more site-specific: it bakes in a layout opinion (everysectionis a centred column) that many sites wouldn't want.
So for a reusable foundation, the base layer should stay close to empty — just things that are near-universal and unopinionated:
- maybe
body { background: var(--bg); color: var(--fg); font-size: var(--size-base); line-height: var(--lh-base) }to wire your tokens to the page - perhaps a sensible default font-family
Everything with a taste in it (link colours, column widths, heading sizes) belongs to the downstream site, not the framework. That matches Julia's own instinct to "start with almost nothing and move things up from components as patterns emerge" — except for a framework the bar is even higher, since you can't see the sites that'll use it.
So I'd keep the framework's base to just the token-wiring on body, and leave link colours and layout to the consumer.
body {
background: var(--bg);
color: var(--fg);
font-size: var(--size-base);
line-height: var(--lh-base)
}
I haven’t completely worked out an approach to managing padding and margins yet. I’m definitely trying to be more principled than how I was doing it in Tailwind though, where I would just haphazardly put padding and margins everywhere until it looked the way I wanted.
Right now I’m working towards making the outer layout components in charge of spacing as much as possible. For example if I have a <section> with a bunch of children that I want to have space between them, I might use this to space the children evenly:
section > *+* {
margin-top: 1rem;
}
Some inspiration blog posts:
I love this approach. Solves so many problems. It feels like discussion of flex belongs here too. Where should we start?
Both flex and the owl selector are really the same idea wearing two hats: the container owns the spacing, not the children. That's the unifying principle behind "no outer margin" — a child never reaches out and pushes its siblings; the parent decides the gaps.
The difference is just which layout mode you're in:
- Owl selector (
* + *) — for content in normal flow (a stack of prose, form fields, sections). It adds space between siblings only (the first child gets nothing, so no leading outer margin). Works without making the parent a flex/grid container — handy when you don't want to change the layout model. - flex
gap— for when you're already laying things out with flex (a row of buttons, a toolbar).gapspaces the items with no edge margins, and it's two-dimensional withflex-wrap.
The historical wrinkle worth knowing: the owl selector existed because for years gap only worked in grid, not flex. Now that gap works in flex everywhere, a lot of owl-selector use has migrated to flex + gap. But owl still wins for the don't-touch-the-layout-mode case — spacing a column of prose where you want plain block flow.
So a natural place to start: the stack — a single primitive that spaces children vertically using the owl selector, with the gap controllable via a variable (the way Julia hinted at with --flow-space). It's the most broadly useful, and it sets up the "container owns spacing" mental model before we layer flex on top.
OK so what, if anything, can we add to help people with that?
For the stack primitive, a single class with a tunable gap covers the common case:
.stack > * + * { margin-top: var(--stack-gap, 1rem); }
The * + * ("owl") targets every child except the first, so you get gaps between children with no outer margin. The --stack-gap variable means any instance can override the spacing locally (style="--stack-gap: 2rem") without a new class, and the 1rem fallback applies when it's unset.
That's the whole primitive — one rule, one knob. We can add a flex row companion next for horizontal spacing whenever you want.
Might it be helpful to provide s, xs, lg, xl versions varying the gap, for conciseness?
Yes — a small set of preset gaps keeps the markup terse. We can code-gen them from a spacing scale:
gaps = dict(xs=0.25, s=0.5, base=1.0, lg=1.5, xl=2.5)
def stack_cls(k,v): return f".stack-{k} > * + * {{ margin-top: {v}rem; }}"
print("\n".join(stack_cls(k,v) for k,v in gaps.items()))
Each gives a .stack-xs … .stack-xl that sets the between-child spacing directly, so .stack-lg reads at a glance as "stack with a roomy gap".
from fasthtml.components import Safegaps = dict(xs=0.25, sm=0.5, base=1.0, lg=1.5, xl=2.5)
def stack_cls(k,v): return f".stack-{k} > * + * {{ margin-top: {v}rem; }}"
gapcls = ("\n".join(stack_cls(k,v) for k,v in gaps.items()))
Style(".stack > * + * { margin-top: var(--stack-gap, 1rem); }\n" + gapcls)What's a nice concise way to show these now? Stacks of 3 colored blocks?
add_msg(content="def blocks(): return [Div(style=f\"backgr…", msg_type="code", placement="add_after")→"_0f9b6d40"
{
"id": "toolu_01NqPAPn4TCN2KW6PJriTz1u",
"server": false,
"call": {
"function": "add_msg",
"arguments": {
"content": "def blocks(): return [Div(style=f\"background:var(--col{i}4);height:24px\") for i in range(2,5)]\nDiv(*[Div(P(f\"stack-{k}\"), *blocks(), cls=f\"stack-{k}\", style=\"width:120px\") for k in gaps], style=\"display:flex;gap:24px\")",
"msg_type": "code",
"placement": "add_after"
}
},
"result": "_0f9b6d40"
}
Added a cell that lays out one labelled stack per gap size side by side, each holding three coloured blocks — so you can directly compare the stack-xs … stack-xl spacing.
I'd prefer you showed me the code and discussed it a little rather than diving to tools.
Fair — here's what I'd show:
def blocks(): return [Div(style=f"background:var(--col{i}4);height:24px") for i in range(2,5)]
Div(*[Div(P(f"stack-{k}"), *blocks(), cls=f"stack-{k}", style="width:120px") for k in gaps], style="display:flex;gap:24px")
The idea: blocks() makes three thin coloured bars (pulling col24/col34/col44 from the palette). Then for each gap size we build a .stack-{k} column containing a label plus those bars, and lay the columns out in a flex row so they sit side by side for comparison.
One neat thing to notice: the outer Div uses flex gap:24px to space the columns, while inside each column the .stack-* class uses the owl selector to space the bars — so you're seeing both spacing approaches in the one demo, which ties back to the two-hats point from earlier.
def blocks(): return [Div(style=f"background:var(--col{i}4);height:24px") for i in range(2,5)]
divs = [Div(P(f"stack-{k}"), *blocks(), cls=f"stack-{k}", style="width:120px") for k in gaps]
Div(*divs, style="display:flex;gap:24px")stack-xs
stack-sm
stack-base
stack-lg
stack-xl
Great. What next?
A natural next step is the flex row companion — the horizontal counterpart to the stack we just built. Where the stack owns vertical spacing via the owl selector, a row owns horizontal spacing via flex gap:
def row_cls(k,v): return f".row-{k} {{ display:flex; align-items:center; gap:{v}rem; }}"
rowcls = "\n".join(row_cls(k,v) for k,v in gaps.items())
print(".row { display:flex; align-items:center; gap:1rem; }\n" + rowcls)
This reuses the same gaps scale, so .row-lg is the horizontal twin of .stack-lg — consistent spacing vocabulary across both axes. The align-items:center default covers the most common case (things sitting on a shared centre line, like a button row or toolbar).
That gives you the two core layout primitives — stack for columns, row for rows — both driven by one shared scale.
Do we want align-items in there? Or should alignment be a separate class? We need flexibility. We probably also want a class to switch flex axes?
Good instinct — baking align-items:center into .row couples two independent decisions (lay out in a row vs. how to align cross-axis). Centre is the common default, but plenty of rows want align-items:start (e.g. a label next to a multi-line block) or stretch. If it's hardcoded, every exception has to fight it with an override.
So I'd separate the two concerns:
.row/.stackdecide the axis (and.rowcarriesgap)- a small set of alignment classes decide cross-axis placement, applied on top
On the axis-switching point: .row and .stack-* (with flex) are really the same flex container with flex-direction flipped. But there's a subtlety — our .stack uses the owl selector (margin), not flex, so it's not a flex container at all. That means alignment classes wouldn't work on it. You've got two coherent options:
- Keep stack as owl (margin-based), row as flex. Two different mechanisms; alignment classes only apply to
.row. Simpler rows, but the two primitives aren't symmetric. - Make both flex, with
.stack=flex-direction:column+gap,.row=flex-direction:row+gap. Then one shared set of alignment classes works on both, and an axis-switch class is justflex-direction. More uniform, but.stackloses the "doesn't change layout mode" benefit (it'd now establish a flex context, which can affect child sizing).
The cross-axis alignment naming also differs by axis (align-items vs justify-content swap meaning when you rotate), which is exactly why a unified flex model is appealing — you align "across" and "along" consistently.
Which way do you lean — keep the two mechanisms distinct, or unify on flex for symmetry?
Oh I'm thinking stack is separate and we now create a distinct flex mechanism supporting both axes. What are our options?
Good — keeping stack as the owl-based prose-stacker, and building a separate flex primitive for "real" layout. The options for how to shape that flex layer:
Option A — one .flex class + modifier classes for axis.
.flex is the base (display:flex; gap), and .col flips to flex-direction:column. Default is row. So you write flex or flex col. Alignment classes (.align-center, .justify-between) sit on top, shared across both axes.
Option B — two named classes .row / .col.
Each is a full flex container with its direction baked in (.row = row, .col = column). No base class. Reads very clearly in markup, but the two definitions duplicate the display:flex; gap part.
Option C — .flex base, axis as a data attribute.
.flex + [data-axis=col], very TAC-flavoured (matches the attribute approach you used for button[ord]). Keeps it one class with the variation expressed as an attribute.
The cross-axis question cuts across all three. Flex alignment uses two properties whose meaning rotates with direction:
justify-content= along the main axis (the gap direction)align-items= across the cross axis
So if you name alignment classes after the physical result (.align-center) they have to know the axis; if you name them after the flex role (.justify-center / .items-center, Tailwind-style) they're axis-agnostic but the user has to think in main/cross terms.
Given you've leaned TAC/attribute-based already (button[ord], table[striped]), Option C would be the most consistent with the framework's voice — axis and alignment as attributes on one flex element. But Option B is the most immediately readable.
Which naming philosophy appeals — physical (row/col, align-center) or flex-role (justify/items)?
Maybe .flexrow and .flexcol then. And stick to flex role. Keep things close to the CSS. Show we add helper classes for justify et al and other common requirements?
Here's a compact set — two container classes plus flex-role helpers that work on either:
def flex_base(d): return f".flex{d} {{ display:flex; flex-direction:{'row' if d=='row' else 'column'}; gap:var(--flex-gap, 1rem); }}"
jc = dict(start='flex-start', center='center', end='flex-end', between='space-between', around='space-around')
ai = dict(start='flex-start', center='center', end='flex-end', stretch='stretch')
def jc_cls(k,v): return f".justify-{k} {{ justify-content:{v}; }}"
def ai_cls(k,v): return f".items-{k} {{ align-items:{v}; }}"
wrap = ".wrap { flex-wrap:wrap; }\n.grow > * { flex:1; }"
css = "\n".join([flex_base('row'), flex_base('col'), *[jc_cls(k,v) for k,v in jc.items()], *[ai_cls(k,v) for k,v in ai.items()], wrap])
print(css + "\n" + wrap)
The pieces:
.flexrow/.flexcol— the two containers, sharing a--flex-gapknob (default 1rem) just like the stack.justify-*— main-axis distribution (the common five).items-*— cross-axis alignment.wrap— opt-inflex-wrapfor rows that should reflow.grow > *— make children share space equally (toolbars, equal columns)
Because the helpers use flex-role names, .items-center means the same thing whether it's on a .flexrow or .flexcol — the user thinks in main/cross, and the class maps straight to the CSS property.
Maybe actually it should be .flex, .flex-xs, etc for the sizes, and .dircol to add column direction. (Plus the helpers.)
Here's that shape — .flex base (row), gap-size variants, .dircol to flip direction, plus the helpers:
def flex_cls(k,v): return f".flex-{k} {{ display:flex; gap:{v}rem; }}"
flexes = ".flex { display:flex; gap:1rem; }\n" + "\n".join(flex_cls(k,v) for k,v in gaps.items())
dir_ = ".dircol { flex-direction:column; }"
jc = dict(start='flex-start', center='center', end='flex-end', between='space-between', around='space-around')
ai = dict(start='flex-start', center='center', end='flex-end', stretch='stretch')
helpers = "\n".join([f".justify-{k} {{ justify-content:{v}; }}" for k,v in jc.items()] + [f".items-{k} {{ align-items:{v}; }}" for k,v in ai.items()] + [".wrap { flex-wrap:wrap; }", ".grow > * { flex:1; }"])
print("\n".join([flexes, dir_, helpers]))
The structure:
.flex+.flex-xs….flex-xl— flex container with a gap from the sharedgapsscale (so the spacing vocabulary matches.stack-*).dircol— opt-in switch to column; row stays the default, so you only add a class for the less-common case.justify-*/.items-*— flex-role alignment, axis-agnostic.wrapand.grow > *— reflow and equal-sizing
Because .dircol just sets flex-direction, all the .justify-*/.items-* helpers keep working on it unchanged — the user thinks in main/cross terms and the class names map straight to the CSS.
def flex_cls(k,v): return f".flex-{k} {{ display:flex; gap:{v}rem; }}"
flexes = ".flx { display:flex; gap:1rem; }\n" + "\n".join(flex_cls(k,v) for k,v in gaps.items())
dir_ = ".dircol { flex-direction:column; }"
jc = dict(start='flex-start', center='center', end='flex-end', between='space-between', around='space-around')
ai = dict(start='flex-start', center='center', end='flex-end', stretch='stretch')
helpers = [f".justify-{k} {{ justify-content:{v}; }}" for k,v in jc.items()] + [f".items-{k} {{ align-items:{v}; }}" for k,v in ai.items()]
helpers += [".wrap { flex-wrap:wrap; }"] #, ".grow > * { flex:1; }"]
Style("\n".join([flexes, dir_, "\n".join(helpers)]))OK what's a good way to show it? (I use flx to avoid breaking tailwind)
A clean way is to show a few .flx containers with bordered blocks, each demoing a different helper so the effect is visible:
def blk(t): return Div(t, style="background:var(--col34);color:var(--bg);padding:8px")
def demo(cls): return Div(P(cls), Div(blk('A'), blk('B'), blk('C'), cls=f"flx {cls}", style="border:1px solid var(--col13);height:80px"))
Div(*[demo(c) for c in ['justify-start','justify-center','justify-between','items-center','items-end']], cls="stack")
blk makes a small coloured box, demo wraps three of them in a .flx container plus the extra helper class and a visible border/height so alignment shows up. Stacking the demos with .stack spaces them vertically. The items-* ones need the container height (80px) to have room to move the boxes along the cross axis — without it they'd just fill the line and you'd see no difference.
def blk(t): return Div(t, style="background:var(--col34);color:var(--bg);padding:8px")
def demo(cls): return Div(P(cls), Div(blk('A'), blk('B'), blk('C'), cls=f"flx {cls}", style="border:1px solid var(--col13);height:50px"))
Div(*[demo(c) for c in ['justify-start','justify-center','justify-between','items-center','items-end']], cls="stack-sm", style="width:500px")justify-start
justify-center
justify-between
items-center
items-end
The way I was doing responsive design in Tailwind was to use a lot of media queries. Tailwind has this md:text-xl syntax that means “apply the text-xl style at sizes md or larger”.
I’m trying something pretty different now, which is to make more flexible CSS grid layouts that don’t need as many breakpoints. This is hard but it’s really interesting to learn about what’s possible with grid, and it’s a good example of something that I don’t think is possible with Tailwind.
For example, I’ve been learning about how to use auto-fit to automatically use 2 columns on a big screen and 1 column on a small screen like this:
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 400px), max-content));
justify-content: center;
I also used grid-template-areas a lot which is an amazing feature that I don’t think you can use with Tailwind.
Some inspiration:
- A responsive grid layout with no media queries from CSS Tricks
Are there any approaches to grid classes that are genuinely useful and flexible? I guess we needn't cover everything, but some idiomatic opinionated basics?
The single most useful grid class is the auto-responsive grid — it's the one that genuinely earns its place because it does something flex can't: reflow column count with zero media queries.
.grid { display:grid; gap:1rem; grid-template-columns:repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); }
The idiomatic pieces worth understanding:
auto-fitcollapses empty tracks so the items stretch to fill the row. Its siblingauto-fillkeeps empty phantom columns — soauto-fitis what you want when you have few items and want them to spread,auto-fillwhen you want a fixed grid that leaves gaps.auto-fitis the more common default.minmax(16rem, 1fr)says "each column is at least 16rem, but share leftover space equally". So columns grow until another 16rem column fits, then it wraps — the count adapts to the container automatically.min(100%, 16rem)is the crucial guard: on a screen narrower than 16rem, a bareminmax(16rem, …)would overflow horizontally.min(100%, 16rem)caps the minimum at the container width, so it never overflows. This little trick is what makes the pattern actually robust.
That one class covers the overwhelming majority of real "responsive grid" needs (card galleries, tile layouts, etc.).
The other two grid approaches, and why I'd be cautious:
- Fixed-column classes (
.grid-2,.grid-3=grid-template-columns:repeat(N,1fr)) — simple and occasionally handy, but they don't reflow, so on a phone a.grid-4squishes to four tiny columns. Useful only when you truly want exactly N columns regardless of width. grid-template-areas— wonderful, but inherently layout-specific (you're naming named regions for one particular design). That belongs in a component, not a generic utility — there's no sensible one-size class for it.
So my opinionated basics would be: the one auto-fit .grid (plus gap-size variants from your shared scale, like the stack/flex), and maybe a couple of fixed .grid-N for the rare exact-count case.
Want to start with the auto-grid plus its gap variants, matching the .stack-*/.flex-* pattern?
%%css
.autogrid { display:grid; gap:1rem; grid-template-columns:repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); }OK give me the variants, and a couple of fixed -N ones.
def autogrid_cls(k,v): return f".autogrid-{k} {{ display:grid; gap:{v}rem; grid-template-columns:repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); }}"
def gridn_cls(n): return f".grid-{n} {{ display:grid; gap:1rem; grid-template-columns:repeat({n}, 1fr); }}"
agcls = "\n".join(autogrid_cls(k,v) for k,v in gaps.items())
gncls = "\n".join(gridn_cls(n) for n in (2,3))
Style(agcls + "\n" + gncls)
The .autogrid-* variants reuse the shared gaps scale so spacing matches .stack-*/.flex-*, and the two fixed .grid-2/.grid-3 give exact column counts for the rare case you want them.
def autogrid_cls(k,v): return f".autogrid-{k} {{ display:grid; gap:{v}rem; grid-template-columns:repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); }}"
def gridn_cls(n): return f".grid{n} {{ display:grid; gap:1rem; grid-template-columns:repeat({n}, 1fr); }}"
agcls = "\n".join(autogrid_cls(k,v) for k,v in gaps.items())
gncls = "\n".join(gridn_cls(n) for n in (2,3))
Style(agcls + "\n" + gncls)Hmmm rethinking... I'm not sure about -xl, -lg, etc, in general. What about always just adding a number 1-5, and 3 is the same as no number?
That's a nice convention — a numeric scale reads as "more = bigger", the middle is the neutral default, and there's no need to remember whether sm comes before xs. It also matches how Tailwind's spacing scale works (numbers), even though their type scale uses names.
The mapping would be:
gaps = {1:0.25, 2:0.5, 3:1.0, 4:1.5, 5:2.5}
So .flex-3 is identical to .flex, .stack-3 to .stack, etc. — the bare class is the "3" case. A couple of small decisions that follow:
- We could skip generating
-3entirely (since the bare class covers it), or generate it anyway for regularity so.flex-3exists as an explicit synonym. I'd lean to skipping it — less CSS, and the bare name is the idiomatic way to say "default". - The same
gapsdict now drives.stack-N,.flex-N, and.autogrid-Nuniformly, so the whole spacing vocabulary becomes a single 1–5 scale.
One thing worth pinning down: should this numeric scale also eventually cover padding/margins (a general spacing scale), so 1–5 means the same step everywhere? That'd unify spacing the way the OKLCH steps unified colour — but we can defer it.
Want me to show the regenerated stack/flex/autogrid using the numeric scale?
No -! :)
gaps = {1:0.25, 2:0.5, 3:1.0, 4:1.5, 5:2.5}
def stack_cls(k,v): return f".stack{k} > * + * {{ margin-top: {v}rem; }}"
def flex_cls(k,v): return f".flex{k} {{ display:flex; gap:{v}rem; }}"
def autogrid_cls(k,v): return f".autogrid{k} {{ display:grid; gap:{v}rem; grid-template-columns:repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); }}"
def cls_set(f): return "\n".join(f(k,v) for k,v in gaps.items() if k!=3)
Style("\n".join(cls_set(f) for f in (stack_cls,flex_cls,autogrid_cls)))
3 is skipped since the bare .stack/.flex/.autogrid cover it, and the names are now hyphen-free (.flex1….flex5).
gaps = {1:0.25, 2:0.5, 3:1.0, 4:1.5, 5:2.5}
def stack_cls(k,v): return f".stack{k} > * + * {{ margin-top: {v}rem; }}"
def flex_cls(k,v): return f".flex{k} {{ display:flex; gap:{v}rem; }}"
def autogrid_cls(k,v): return f".autogrid{k} {{ display:grid; gap:{v}rem; grid-template-columns:repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); }}"
def cls_set(f): return "\n".join(f(k,v) for k,v in gaps.items())
Style("\n".join(cls_set(f) for f in (stack_cls,flex_cls,autogrid_cls)))OK how do we nicely show the autogrids?
A clean demo is a row of cards in an .autogrid — resize the output pane and watch the column count change:
def card(i): return Div(f"Card {i}", cls="text-lg", style="background:var(--col24);color:var(--bg);padding:1.5rem")
Div(*[card(i) for i in range(1,7)], cls="autogrid")
Six cards, each needing ≥16rem; the auto-fit reflows them from many columns on a wide pane down to one on a narrow one. Try autogrid5 for a roomier gap to see the spacing variants too.
txt_clr = ".text-fg { color:var(--fg); }\n.text-bg { color:var(--bg); }"
Style(txt_clr)We can use classes for those colors?
Yes — drop the inline color and use the class instead:
def card(i): return Div(f"Card {i}", cls="text-lg text-bg", style="background:var(--col24);padding:1.5rem")
Div(*[card(i) for i in range(1,7)], cls="autogrid")
text-bg sets the text to var(--bg), so only the background and padding need to stay inline now.
def card(i): return Div(f"Card {i}", cls="text-lg text-bg", style="background:var(--col24);padding:1.5rem")
Div(*[card(i) for i in range(1,7)], cls="autogrid")And grid2/3?
Div(Div(*[card(i) for i in range(1,5)], cls="grid2"), Div(*[card(i) for i in range(1,7)], cls="grid3"), cls="stack")
.grid2 holds four cards in two fixed columns, .grid3 six in three; unlike .autogrid these keep their column count regardless of width, so on a narrow pane they'll squish rather than reflow.
Div(Div(*[card(i) for i in range(1,5)], cls="grid2"), Div(*[card(i) for i in range(1,7)], cls="grid3"), cls="stack")What if we instead called text sizes: txt-2,txt-1,txt,txt1,txt2,... ?
Numeric, centred on txt (=base):
sizes = {-2:0.75, -1:0.875, 0:1.0, 1:1.125, 2:1.25, 3:1.5, 4:1.875, 5:2.5}
lhs = {-2:1.4, -1:1.35, 0:1.3, 1:1.3, 2:1.25, 3:1.2, 4:1.15, 5:1.1}
def nm(n): return '' if n==0 else str(n)
def size_var(n,v): return f" --size-txt{nm(n)}: {v}rem;\n --lh-txt{nm(n)}: {lhs[n]};"
def size_cls(n): return f".txt{nm(n)} {{ font-size:var(--size-txt{nm(n)}); line-height:var(--lh-txt{nm(n)}); }}"
szs = ":root {\n" + "\n".join(size_var(n,v) for n,v in sizes.items()) + "\n}\n" + "\n".join(size_cls(n) for n in sizes)
Style(szs)
So txt-2/txt-1 step down from body, txt is base, and txt1..txt5 step up — the offset reads directly as "how many steps from normal".
sizes = {-2:0.75, -1:0.875, 0:1.0, 1:1.125, 2:1.25, 3:1.5, 4:1.875, 5:2.5}
lhs = {-2:1.4, -1:1.35, 0:1.3, 1:1.3, 2:1.25, 3:1.2, 4:1.15, 5:1.1}
def nm(n): return '' if n==0 else str(n)
def size_var(n,v): return f" --size-txt{nm(n)}: {v}rem;\n --lh-txt{nm(n)}: {lhs[n]};"
def size_cls(n): return f".txt{nm(n)} {{ font-size:var(--size-txt{nm(n)}); line-height:var(--lh-txt{nm(n)}); }}"
szs = ":root {\n" + "\n".join(size_var(n,v) for n,v in sizes.items()) + "\n}\n" + "\n".join(size_cls(n) for n in sizes)
Style(szs)txt = "Sphinx of black quartz, judge my vow. " * 3
Div(*[P(f".txt{nm(n)} — {txt}", cls=f"txt{nm(n)}", style="max-width:32em;margin:0.7em 0") for n in sizes]).txt-2 — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
.txt-1 — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
.txt — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
.txt1 — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
.txt2 — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
.txt3 — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
.txt4 — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
.txt5 — Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow. Sphinx of black quartz, judge my vow.
Are there better/other concise ways to show relative sizes to a base?
A few established conventions, each with a different flavour:
1. T-shirt sizes (what you started with): xs, sm, base, lg, xl, 2xl.... Familiar from Tailwind/Bootstrap, but the naming is asymmetric — there's no natural "two steps below base" without inventing 2xs, and you can't tell at a glance how far xl is from lg.
2. Signed offsets (your current txt-2..txt5): the number is the distance from base. Self-documenting and symmetric. The only wart is the visual txt-1 could read as "txt minus one" or a hyphenated name.
3. Step numbers with a fixed origin: like Radix/Material — txt1..txt9, base pinned at some middle value (e.g. txt4). Reads as an absolute ladder rather than relative. Good for "more rungs" but you lose the "0 = normal" anchor.
4. The ratio itself, computed: skip named steps entirely and expose one --ratio + a --step count, letting CSS do font-size: calc(1rem * pow(var(--ratio), var(--step))). CSS now has pow() so this is finally viable. Maximally concise (two vars, infinite steps) but the markup carries a raw number rather than a friendly name.
For your goals I think your signed-offset scheme is the sweet spot: it's the only one where the class name tells you the relationship to body text, which matches how you actually think ("I want this a notch bigger than normal" → txt1). The t-shirt names hide that; the absolute ladders lose the zero anchor.
We could use naming convention 2 for all our sized things - eg gaps etc...
Symmetric, centred on the bare class. Same nm helper does the job:
gaps = {-2:0.25, -1:0.5, 0:1.0, 1:1.5, 2:2.5}
def stack_cls(n,v): return f".stack{nm(n)} > * + * {{ margin-top: {v}rem; }}"
def flex_cls(n,v): return f".flex{nm(n)} {{ display:flex; gap:{v}rem; }}"
def autogrid_cls(n,v): return f".autogrid{nm(n)} {{ display:grid; gap:{v}rem; grid-template-columns:repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); }}"
def cls_set(f): return "\n".join(f(n,v) for n,v in gaps.items())
Style("\n".join(cls_set(f) for f in (stack_cls,flex_cls,autogrid_cls)))
Now .stack/.flex/.autogrid are the base (0), with 1/2 roomier and -1/-2 tighter — the offset reads the same way as the text sizes.
gaps = {-2:0.25, -1:0.5, 0:1.0, 1:1.5, 2:2.5}
def stack_cls(n,v): return f".stack{nm(n)} > * + * {{ margin-top: {v}rem; }}"
def flex_cls(n,v): return f".flx{nm(n)} {{ display:flex; gap:{v}rem; }}"
def autogrid_cls(n,v): return f".autogrid{nm(n)} {{ display:grid; gap:{v}rem; grid-template-columns:repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); }}"
def cls_set(f): return "\n".join(f(n,v) for n,v in gaps.items())
Style("\n".join(cls_set(f) for f in (stack_cls,flex_cls,autogrid_cls)))Should our hues then be cola-colg, and use a similar number system for +/- precedence?
Letters for hue, signed offset for prominence centred on the mid step:
hueletters = dict(a=260, b=250, c=25, d=75, e=145, f=220, g=300)
ls = {-3:0.97, -2:0.90, -1:0.80, 0:0.68, 1:0.55, 2:0.43, 3:0.30}
cs = {-3:0.03, -2:0.07, -1:0.12, 0:0.18, 1:0.20, 2:0.17, 3:0.12}
def col_var(h,n,l,c,deg): return f" --col{h}{nm(n)}: oklch({l} {0 if h=='a' else c} {deg});"
collines = [col_var(h,n,ls[n],cs[n],deg) for h,deg in hueletters.items() for n in ls]
Style(":root {\n" + "\n".join(collines) + "\n}")
So --cola is neutral mid, --colc1/--colc2 step toward higher prominence (darker on light bg), --colc-1/--colc-2 toward lower — same offset logic as the text and gap scales.
hueletters = dict(a=260, b=250, c=25, d=75, e=145, f=220, g=300)
ls = {-3:0.97, -2:0.90, -1:0.80, 0:0.68, 1:0.55, 2:0.43, 3:0.30}
cs = {-3:0.03, -2:0.07, -1:0.12, 0:0.18, 1:0.20, 2:0.17, 3:0.12}
def col_var(h,n,l,c,deg): return f" --col{h}{nm(n)}: oklch({l} {0 if h=='a' else c} {deg});"
collines = [col_var(h,n,ls[n],cs[n],deg) for h,deg in hueletters.items() for n in ls]
Style(":root {\n" + "\n".join(collines) + "\n}")We have classes for them too I think?
Yes — color and background classes for each, using the same nm offset:
def col_cls(h,n): return [f".col{h}{nm(n)} {{ color:var(--col{h}{nm(n)}); }}", f".col{h}{nm(n)}-bg {{ background:var(--col{h}{nm(n)}); }}"]
cclines = [l for h in hueletters for n in ls for l in col_cls(h,n)]
Style("\n".join(cclines))
Each hue/prominence gets a .colc1 text class and a .colc1-bg background class, matching the variable names.
def col_cls(h,n):
return [f".col{h}{nm(n)} {{ color:var(--col{h}{nm(n)}); }}",
f".col{h}{nm(n)}-bg {{ background:var(--col{h}{nm(n)}); }}"]
cclines = [l for h in hueletters for n in ls for l in col_cls(h,n)]
Style("\n".join(cclines))Show me some little examples.
Here's a small swatch grid using the bg classes, plus a couple of text-colour examples:
def sw(h,n): return Div(f"col{h}{nm(n)}", cls=f"col{h}{nm(n)}-bg txt-1", style="padding:8px")
Div(*[Div(*[sw(h,n) for n in ls], cls="flx-2") for h in hueletters], cls="stack-1")
Each row is one hue stepping through its prominence offsets -3..3; the bare colc etc. sit in the middle. For text colours:
Div(P("colc1 danger-ish text", cls="colc1 txt2"), P("cole2 success-ish text", cls="cole2 txt2"), cls="stack-1")
def sw(h,n): return Div(f"col{h}{nm(n)}", cls=f"col{h}{nm(n)}-bg txt-1 {'text-bg' if n>0 else ''}", style="padding:8px; width:60px")
Div(*[Div(*[sw(h,n) for n in ls], cls="flx-2") for h in hueletters], cls="stack-1")I wonder if I should modify fasthtml to let cls take a set/tuple/list... WDYT?
I like it — that conditional case we just hit (f"... {'text-bg' if n>0 else ''}") is exactly the pain point, and it leaves ugly trailing/empty gaps in the output. A list form lets you write the intent directly:
cls=[f"col{h}{nm(n)}-bg", "txt-1", n>0 and "text-bg"]
and have the joiner drop the falsy entries. That's the real win — not just "a list instead of a string", but conditional classes without f-string contortions.
A few design thoughts:
- List/tuple over set. A set reorders, and while class order doesn't affect the cascade, stable order matters for readable output and clean diffs. So preserve order.
- Filter falsy, not just
None. AcceptingFalse/None/''and dropping them is what makescond and "cls"andcond if x else Noneboth work cleanly. - Flatten one level. Handy so you can splat groups, e.g.
cls=[base_classes, "extra"]wherebase_classesis itself a list. Worth deciding whether to support nesting or keep it flat. - Keep strings working. Pure additive change —
cls="a b c"stays valid, list is just an alternative.
It's a small, contained change in the attribute-handling path, and it composes especially well with your code-gen style here, where you're assembling classes programmatically. The main thing to pin down is the flatten depth — flat-only is simpler and predictable; one-level-nested is more convenient for splatting. I'd lean flat with falsy-filtering as the minimal, predictable version.
Div(
P("colc1 danger-ish text", cls="colc1 txt2"),
P("cole2 success-ish text", cls="cole2 txt2"),
cls="stack-1")colc1 danger-ish text
cole2 success-ish text
WDYT "ish"?
I think "-ish" actually captures the whole design philosophy rather neatly. It's the verbal form of the separation we built: colc isn't danger, it's the hue that works well as danger. The suffix encodes that looseness — "alarming in flavour, but not bound to the role." Calling it flatly "danger" would overclaim, since the same hue might do duty as a decorative accent elsewhere.
So it's a nice honest label for the abstract layer: position carries a loose semantic tendency, and "-ish" is exactly the right amount of commitment. Once you map it through the role layer (--danger: var(--colc)), then you've earned the unqualified word "danger" — the "-ish" drops away precisely at the point the convention becomes a promise.
It's a small thing, but it'd actually make decent documentation language: "colc is danger-ish; bind it to --danger to make it danger."
In development, I don’t need a build system: CSS now has both built in import statements, like this:
@import "reset.css";
@import "typography.css";
@import "colors.css";
and built in nested selectors, like this:
.page {
h2 { ...}
}
Nothing really for us to worry about here?
Right, mostly nothing — and for a slightly different reason than Julia's. She avoids a build step by leaning on native CSS @import and nesting. You've sidestepped it even more thoroughly: your CSS is code-generated from Python, so "concatenation" is just joining strings, and you never have multiple files to import in the first place.
The two things Julia relies on are both fine for you anyway:
- Native
@import— useful when you're hand-authoring separate.cssfiles. You're emitting one generated blob, so you don't need it. If you ever did split sources, note@importhas a real runtime cost (sequential fetches), which is exactly why people bundle — but that's a production serving concern, not a dev one. - Native nesting — handy for hand-written component CSS, and it'll Just Work in modern browsers. Your generators could emit nested rules too if you ever want them.
The only thing worth a passing thought is production: at some point you'll want the generated CSS written to a static .css file and served once, rather than injected via Style(...) per page. But that's a trivial "write the string to a file" step whenever you're ready — no esbuild, no toolchain. So yes, nothing to worry about here.
Would any of our generators have been more concise using nesting?
Honestly, most wouldn't — .stack, .flx, .autogrid, .txt are each a single flat rule with no shared selector prefix, so nesting buys nothing.
The one real candidate is the colour classes, where .colc and .colc-bg share a stem. Native nesting's & concatenation handles that:
def col_cls(h,n):
s = f"col{h}{nm(n)}"
return f".{s} {{ color:var(--{s}); &-bg {{ background:var(--{s}); }} }}"
The &-bg expands to .colc-bg, so one nested block emits both rules. The role classes (.danger/-subtle/-bold) are the same shape and would benefit identically via &-subtle/&-bold.
But it's worth noting the win is small and arguably negative here: since you're generating from Python, you already have the stem in a variable, so the flat two-line version is just as DRY and produces output that's easier to read and grep. Nesting shines when hand-writing (you avoid retyping the prefix), which is exactly the case your code-gen removes. So: technically yes for the col/role pairs, but I wouldn't bother.
While doing this I learned about a lot of CSS features that I didn’t use but am curious about learning about one day:
We've already looked at layers. I've briefly experimented with @scope and it feels like there's something there for fasthtml/htmx/TAC components, but I'm not quite sure what yet...
@scope gives two distinct powers, and one of them maps very naturally onto TAC components:
1. Lower boundaries ("donut scoping") — this is the TAC-relevant one. Your components use descendant/child selectors like siteheader > header > nav > a. The moment a component nests inside another instance of itself (a card in a card, a menu in a menu), those descendant selectors leak inward. @scope ... to (...) lets you say "style down to, but not past, the next nested component root":
@scope (siteheader) to (siteheader) {
header > nav > a { padding: 6px; }
}
Now an inner siteheader is excluded — the styles stop at the boundary. That's the exact "non-overlapping scopes" trick from the spec example pasted earlier, and it's the cleanest answer yet to TAC's one real weakness: descendant-selector bleed.
2. Proximity weighting — the closest scope root wins, regardless of specificity. This is something the cascade cannot otherwise do. It's the natural fit for your .darktheme override: nest a light island inside a dark page inside a light page, and each subtree picks up its nearest theme without specificity wars.
Where I think it gets interesting for fasthtml/htmx specifically: an HTMX component could carry its styles inline with itself via a scoped <style>:
<siteheader>
<style>@scope { header > nav > a { padding: 6px; } }</style>
<header>...</header>
</siteheader>
A bare @scope with no (root) scopes to the enclosing parent of the <style> — so the component's CSS travels with its markup and auto-scopes to itself. For htmx, where you're swapping in fragments of HTML, that's a way to ship a component's styling in the fragment without a global stylesheet or web-component shadow DOM — TAC's "leverage HTML" spirit, but with real encapsulation.
That last pattern feels like the unexplored seam you're sensing: self-scoping style islands that ride along with swapped-in htmx content.
- isn't that clear as a win since:
- There could be other nested components that doesn't match
- And anyway
>is pretty safe.
You're right on both counts, and they actually undercut donut scoping from opposite ends:
Point 1:
to (siteheader)only stops at a nested siteheader. If the real worry is "my deep selector reaches into some other component that happens to contain ana", a single named boundary does nothing — you'd need the boundary to match every component root (some universal[data-component]marker), at which point you're maintaining a convention that's more work than the problem.Point 2: with
>chaining, cross-component bleed basically can't happen — a different component won't reproduce the exactheader > nav > aparent chain, so there's nothing to leak into.
And the case donut uniquely solves — self-nesting (a siteheader inside a siteheader) — is the one case where > genuinely doesn't save you (the inner siteheader re-matches the same pattern), but it's also the case where you usually want the inner one styled identically anyway, since it's the same component. So the donut is solving a problem you rarely have.
So I'll walk back point 1 from before: donut scoping is mostly a non-feature for a >-disciplined TAC system. The parts of @scope that actually earn their keep are the two that the cascade can't otherwise do:
- proximity weighting — nearest scope root wins (your
.darkthemeislands) - self-scoping
<style>that rides along with an htmx fragment
That second one is the genuinely novel seam for what you're building; the boundary stuff is a distraction.
- is the thing I've wondered about a lot too. It feels like you end up potentially polluting the DOM a lot though; so I'm concerned about perf on large pages with oft-repeated widgets with complex styling. It fits the HTMX locality-of-behavior goal beautifully. Although I also wonder if only the non-standard override pieces might go there... I'm not sure tbh. In jsx I think it's pretty common to carry styles around like that, right?
On the perf concern: it's real. N copies of an identical <style> aren't free — each is a separate stylesheet the engine parses, and more importantly each contributes its rules to style recalc, which roughly scales with rules × candidate elements. A page with 500 cards each shipping a complex <style> is 500 stylesheets and a much bigger matching set than one shared rule. Browsers don't dedupe identical <style> content for you. So oft-repeated widgets are exactly the wrong case for ship-styles-with-every-instance.
On the JSX comparison — and this is the key bit — CSS-in-JS looks like it carries styles around, but at runtime it does the opposite of per-instance pollution. styled-components/emotion hash the style content and inject one <style> per unique style into <head>, backed by a registry so repeated components share that single rule (often atomic-CSS style, one declaration per class). The locality is purely an authoring convenience; by the time it hits the DOM it's collapsed to shared sheets. So JSX isn't evidence for inline-per-instance — it's evidence for "author local, emit shared."
Which is exactly your "only the non-standard override pieces" instinct, and it points at the cleanest split:
- Static/bulk styling → lives once in your generated global stylesheet, keyed by class (
.card,.colc1, etc). Shared by every instance, matched once. - Per-instance variation → ride inline, but as custom-property values, not rules. The global rule says
background: var(--card-accent); the instance setsstyle="--card-accent: var(--cole)". One declaration, no duplicate selectors, no extra stylesheet.
That gives you htmx's locality-of-behavior — the fragment carries everything that makes this instance look like it does — while the heavy, repeated CSS stays a single shared rule. The custom-property indirection is the bridge: it's literally values travelling with the markup, which is the part that legitimately varies, while the structure stays global.
So <style> islands earn their place for genuinely one-off complex components (a unique page header), where there's no repetition to multiply. For repeated widgets, the var-on-the-instance pattern is the one that scales — and it's the natural payoff of the token system you've already built.