Typesetting a blog nobody asked for
Why this site has no utility framework, how unlayered CSS beats @layer utilities without !important, and every component you can use in a post.
Every technical blog looks the same now, and the reason is not laziness. It is that the default path — a docs theme, a utility framework, a component library — produces a specific visual result, and that result has been the default for long enough that it reads as “generated” whether or not it was.
This post is the build log for going the other way, and it doubles as a reference for everything you can put in a post here.
The layering trick
nextra-theme-blog ships a compiled Tailwind v4 bundle. Every utility is
prefixed x: and — this is the useful part — wrapped in @layer utilities:
@layer theme, base, components, utilities;
@layer utilities {
.x\:prose { max-width: 65ch; /* … */ }
.x\:container { width: 100%; /* … */ }
}CSS cascade layers sort below unlayered author styles. Not by specificity —
by origin. An unlayered article { max-width: none } beats a layered
.x\:prose.x\:container[data-x] every time, because layered styles lose to
unlayered ones before specificity is even consulted.
So the entire design sits on top of the theme with no !important anywhere:
/* Unlayered. Wins over @layer utilities regardless of specificity. */
article[data-pagefind-body] {
max-width: none;
padding: 0;
}The one thing to be deliberate about is where prose styling comes from. Rather
than fight Tailwind Typography rule by rule, every custom subtree carries
not-prose — which is the plugin’s own opt-out — and app/styles/prose.css is
then the only file describing the reading experience.
Three voices, kept apart
The typography is doing one job: separating the machine from the human.
Type assignment
- Display
- Space Grotesk — headings, wordmark, blockquotes
- Reading
- Newsreader — body copy only
- Apparatus
- JetBrains Mono — labels, dates, tags, code, all chrome
- Measure
- 68ch
- Leading
- 1.68 body / 1.18 headings
A serif for body copy in a cyberpunk-adjacent design sounds wrong until you consider what the register actually is: this is a printed field manual that leaked, not a neon street scene. Dossiers are set in serif. The mono is the stamp on top.
Headings hang their numbers
Section numbers sit in the left margin above 68rem and fold inline below it,
which is a counter() and a position: absolute — no JavaScript, no plugin.
.prose-body { counter-reset: h2; }
.prose-body h2 {
counter-increment: h2;
position: relative;
}
.prose-body h2::before {
content: '§' counter(h2);
position: absolute;
left: -4.25rem;
color: var(--acid);
}Authoring surface
Everything below works in any .mdx file under app/blog/, with no imports.
Callouts
GitHub alert syntax is mapped onto Nextra’s Callout. One gotcha, and it cost
me twenty minutes: the marker has to be its own paragraph, so you need the
bare > line after it.
> [!NOTE]
>
> Body goes here.> [!NOTE]
> Body goes here.Nextra’s withGitHubAlert matches /^\s*\[!(?<name>.*?)]\s*$/ against the
first paragraph’s entire text content. Without the blank line the paragraph is
[!NOTE]\nBody goes here., the $ never matches, and you get a blockquote with
a visible [!NOTE] in it. No warning, no error.
Five types are wired up: NOTE, TIP, IMPORTANT, WARNING, CAUTION.
Each gets a different accent on the left rule. Nothing gets a pastel pill.
Code blocks
Fenced blocks take filename, line highlights in {}, and word highlights in
//:
func Concat(frames ...*DataFrame) (*DataFrame, error) {
if len(frames) == 0 {
return nil, ErrNoFrames
}
schema := frames[0].Schema()
for _, f := range frames[1:] {
if !schema.Equal(f.Schema()) {
return nil, fmt.Errorf("concat: %w", ErrSchemaMismatch)
}
}
return concatChecked(frames), nil
}Add showLineNumbers to the meta string when the line numbers are part of the
point.
Tables
| Approach | Hit rate | p99 | Notes |
|---|---|---|---|
| Raw SQL string | 11% | 4.2ms | Formatting kills it |
| Normalised AST | 60% | 4.4ms | Parse cost is real but bounded |
| AST + param split | 71% | 4.4ms | Where we stopped |
Lists
Ordered lists use zero-padded mono counters:
- Parse the statement to an AST.
- Canonicalise predicate order and literal formatting.
- Hash the canonical form, not the text.
- Cache on that key.
Unordered lists get an acid marker:
- The eviction policy mattered less than the key.
- The key mattered less than agreeing on what “the same query” means.
- Nobody agreed on that for three weeks.
Inline apparatus
Inline code is a bordered chip, not a colour change. Keyboard shortcuts render
as ⌘ K. Footnotes work1. Some things are
under NDA.
Blockquotes
Plain blockquotes are pulled into the display face, at size, with an acid rule:
The fastest way to make a system understandable is to delete something.
What this cost
Weight
- Stylesheet
- ~1,300 lines, hand-written
- Utility framework
- None
- Component library
- None
- Runtime JS for search
- 0 — Pagefind indexes at build
- Client components
- Two: the theme switch and the prev/next rail
Which is the actual argument. Not that hand-written CSS is faster — it is roughly a wash — but that when something looks wrong you can open one file and find the rule that did it.
Footnotes
-
And land in their own hairline-ruled block at the end of the post. ↩