CSS for Markdown Content: Sane Defaults That Don’t Break Production

Was this helpful?

You ship a docs page. Someone pastes a Markdown table with eight columns, a code block with a 300‑character line, and a nested list that looks like a Roman census. Suddenly the layout jumps, the mobile viewport gets horizontal scrollbars, and your design system team shows up holding a bug report like it’s evidence.

Markdown is “simple” right up until you run it in production at scale: multiple renderers, unknown content, unpredictable HTML, and CSS that happily leaks across the whole site. Let’s set sane defaults—headings, lists, tables, code, blockquotes—so your content stays readable and your UI stays intact.

The production principles: scope, predictability, and ugly edge cases

Styling Markdown is not a “pick a nice font” exercise. It’s content sandboxing with typography. You’re taking semi-structured input and rendering it inside a product UI that has its own assumptions about spacing, colors, and layout. In practice, you’re defending the rest of the page from the content, and defending the content from the rest of the CSS.

Three principles keep you out of trouble:

  • Scope everything. Put Markdown inside a container like .md and only style inside it. If you ship global rules for h1, pre, or table, you’re not “styling Markdown.” You’re gambling with the entire site.
  • Prefer predictable rhythm over clever visuals. Users read docs under stress: incident response, migration windows, onboarding. Your CSS should be boring in the best way: consistent spacing, readable line length, sane code formatting.
  • Design for worst-case input. Long URLs, unbroken hashes, deeply nested lists, wide tables, and code blocks that refuse to wrap. If you only style the “happy path,” your first power user will do the QA for you.

One more: keep the browser doing less. Fancy CSS that triggers reflow, layout shifts, or paint storms is how “a docs page” becomes “a performance incident.” Yes, really.

Paraphrased idea from Werner Vogels (reliability engineering): you build systems assuming failure, and then you make failure survivable. That applies to Markdown content too.

Joke #1: CSS is the only language where you can be wrong in seventeen different ways and still ship a page that “looks fine” on your laptop.

Facts and historical context that explain today’s mess

Markdown feels timeless, but the ecosystem around it is a patchwork. A few short facts help explain why you can’t treat “Markdown styling” as a single target:

  1. Markdown started as a convenience format, not a publishing standard. John Gruber’s original Markdown (2004) was designed for writing HTML faster, not for consistent semantic output across implementations.
  2. CommonMark exists because “Markdown” was too ambiguous. Different parsers disagreed on edge cases (like underscores in words, nested lists), which means your HTML output can vary by renderer.
  3. GitHub’s flavored Markdown popularized tables and task lists. Those features weren’t part of the original spec, but became “expected,” so your CSS must handle them.
  4. Early web typography assumed short lines and low density. Many defaults come from an era of 800×600 screens. Today, giant monitors and tiny phones both hit your content.
  5. Code highlighting libraries shaped CSS expectations. Tools like highlight.js and Prism pushed class-heavy markup (span soup) inside pre, which affects line height and selection.
  6. CSS resets normalized browser differences—then created new conflicts. A reset might zero out margin on lists and headings, leaving Markdown content looking like a wall of text unless you reintroduce rhythm.
  7. Tables were never mobile-friendly by default. HTML tables predate responsive design by a long time; horizontal scrolling wrappers became the common escape hatch.
  8. Dark mode is a modern requirement, not a nice-to-have. A docs page with white code blocks in dark mode reads like a flashlight app. You need deliberate colors.

A baseline CSS set you can actually deploy

If you take one thing from this piece: use a container class, define a typographic rhythm, and handle overflow. The CSS in the <style> of this document is exactly that: scoped, boring, and resistant to common content failures.

Here’s why the baseline is structured the way it is:

  • CSS variables give you a tuning panel: change the font stack, colors, spacing, and max line length without hunting through selectors.
  • A max width in characters (78ch) keeps line length readable across monitors. It also prevents tables and code blocks from turning into full-width layout wrecking balls.
  • Scroll margin on headings prevents anchored links from hiding under sticky headers. You’ll thank yourself the first time an on-call runbook uses anchors.
  • Table wrapper class (.table-wrap) lets you contain horizontal overflow without making the entire page scroll sideways.
  • Print styles stop dark code blocks from wasting ink and turning into unreadable grey mush.

What you should not do: copy a “blog theme” stylesheet with complex grid layouts, floating callouts, and decorative pseudo-elements. Markdown content is a universal adaptor; your CSS should be a surge protector.

Headings: hierarchy without drama

Headings in Markdown are deceptively expensive. They affect navigation, anchor linking, readability, and the perceived trustworthiness of the document. If headings look inconsistent, the content feels inconsistent—even if the content is correct.

Spacing rules that avoid “accordion docs”

The failure mode: headings with huge top margins create an “accordion” effect where content feels disjointed and users lose context. The opposite is worse: headings with no margin make the page feel like a log file.

Use a consistent cadence:

  • h2 gets a larger top margin to clearly break sections.
  • h3 gets a smaller top margin so subsections don’t feel like separate chapters.
  • Keep heading line-height tighter than body text to prevent awkward multi-line headings.

Anchors and sticky headers

If you have a sticky header, the browser’s default anchor behavior will scroll the heading under it. Users click a link, the page jumps, and the title disappears. That’s not just annoying—it looks broken.

Fix it with scroll-margin-top on headings. It’s simple, effective, and doesn’t require JavaScript. Use a value that matches your header height plus a little comfort.

Don’t style headings globally

If your Markdown renderer lives inside a broader app, global heading styles are how you accidentally restyle modal titles, cards, and navigation. Scope it. Always.

Lists: spacing, nesting, and why bullets lie

Lists are where Markdown content goes to get weird. Nested lists, mixed ordered/unordered lists, task lists, and list items containing paragraphs can produce markup variations across renderers.

Spacing that survives nested content

Default list margins vary by browser. Resets often nuke list padding. The result: bullets flush against the edge, or nested lists that look like someone dropped a staircase in the middle of the page.

Practical rules:

  • Use padding-left on ul/ol rather than relying on marker defaults.
  • Give li a small vertical margin. Not a full 1rem, or your list turns into a brochure.
  • Specifically control nested list margins so they don’t balloon.

Task lists and checkboxes

Some renderers output task lists as input type="checkbox" inside li. If you globally style form elements, you might unintentionally resize or restyle them. Keep Markdown form element styling conservative, or scope it tightly.

Joke #2: Nested lists are like org charts—technically accurate, emotionally exhausting.

Tables: overflow, alignment, and the “eight-column surprise”

Tables are the top cause of “why is this page horizontally scrolling on iPhone?” complaints. The browser will happily force the viewport wider to accommodate a table, and your layout will do a little interpretive dance.

Wrap tables, don’t fight them

You have two realistic strategies:

  1. Allow horizontal scrolling. Put the table inside a wrapper with overflow-x: auto. This is the least bad option for wide data.
  2. Transform tables into cards on small screens. This can be nice, but it’s complex and fragile across arbitrary Markdown tables. Only do it if you control the table schema.

For most Markdown content in the wild, use the wrapper. It’s honest: wide data is wide data.

Stability: avoid layout shifts when tables load

Tables can cause layout shift when fonts load late, or when the page initially renders without the table wrapper styles. Keep your critical CSS inline or loaded early, and keep table styles simple.

Numeric alignment

Docs often include version numbers, sizes, and latency values. Consider font-variant-numeric: tabular-nums on tables to reduce jitter. It doesn’t solve everything, but it improves scanability when your font supports it.

Code blocks and inline code: legible, copyable, and not a UX ransom note

Code is the point of most technical Markdown. If your code styling is wrong, the doc is wrong. Period.

Inline code: readable without breaking line flow

Inline code should look like code, but it should not shout. The common failure modes:

  • Inline code has too much padding and looks like a button.
  • Inline code doesn’t wrap and causes overflow on mobile due to long tokens.
  • Inline code uses a tiny font that turns into pixel soup at 125% zoom.

Use a subtle background, a thin border, modest padding, and white-space: break-spaces so long tokens can wrap when needed.

Code blocks: overflow is a feature

Don’t wrap code blocks by default. Wrapped code breaks copy/paste, hides indentation, and creates ambiguous commands. The right move is horizontal scroll inside the block (overflow: auto on pre).

Yes, horizontal scrolling isn’t “pretty.” It’s also how terminals work. Your users can handle it.

Line height and tab size

Code blocks need a slightly tighter line height than prose, but not cramped. Also set tab-size to something sane (2 or 4). Default tab rendering varies and can distort indentation in snippets.

Syntax highlighting won’t save bad contrast

Highlighting libraries add colors, not legibility. Start with a high-contrast foreground/background pair, then layer highlight colors on top. In dark mode, avoid pure black backgrounds: they amplify halos and make selection harder to see.

Blockquotes: callouts without turning into a motivational poster

Blockquotes in Markdown often get abused as “note/warning” callouts. That’s fine, but your styling should keep them readable and clearly distinct from main text without eating the page.

Use:

  • A left border (strong affordance, low noise).
  • A subtle background tint (optional, but useful).
  • Comfortable padding so multi-paragraph quotes don’t look cramped.

Avoid oversized quote marks, giant italics, or dramatic font changes. This isn’t a wedding invitation.

Accessibility and readability: contrast, focus, selection, and motion

Docs are for everyone, including the person reading your incident runbook at 3 a.m. with one eye half-open and their laptop brightness set to “regret.” Accessibility isn’t charity; it’s operational correctness.

Contrast and color choices

Links should be clearly links. Use underline by default or at least on hover with strong contrast. Don’t rely on color alone to differentiate meaning inside code blocks either: syntax highlighting colors should complement, not replace, base contrast.

Selection color

People copy from docs constantly. A custom ::selection that has sufficient contrast makes copying less error-prone, especially in code. Keep it subtle but visible.

Focus outlines

If your app removes focus outlines globally, your Markdown links become keyboard-hostile. Fix it globally in the app, or restore it inside .md. “We support keyboard navigation” is either true or it isn’t.

Zoom and font sizes

Don’t set the Markdown container font size in px and then also set everything else as px. Use rem and let users zoom. The design goal is “readable,” not “identical.”

Performance and stability: prevent layout shifts and CSS collisions

Most CSS problems show up as “looks weird.” The expensive ones show up as “feels slow” and “jumps around.” That’s where SRE instincts help: look for systemic causes, not just local ugliness.

Prevent layout shift (CLS) in Markdown pages

Common sources of layout shift in Markdown content:

  • Late-loading fonts. The text reflows when the font swaps.
  • Images without dimensions. The page collapses, then expands when the image loads.
  • Tables and code blocks expanding after render. If your CSS loads late, the initial render might be unstyled.

Practical mitigations:

  • Prefer system font stacks for docs, or preload the font you use.
  • Ensure your Markdown pipeline adds width and height for images when possible, or use CSS aspect-ratio patterns if you control the output.
  • Inline minimal critical CSS for .md (spacing, code blocks, tables) so the first render is stable.

CSS collisions: why scoping is non-negotiable

Markdown output is HTML: headings, lists, tables, pre, code, blockquote. Your app almost certainly already styles these globally. If you don’t scope Markdown styles, you’ll either:

  • Break app UI components with “doc” styles, or
  • Break docs by inheriting component styles you never intended for content.

Scope by container class. If you want extra safety, use cascade layers and keep Markdown in a lower-priority layer than your design system components—or vice versa, depending on your product’s rule of law. But pick one and enforce it.

Fast diagnosis playbook

When someone reports “Markdown page is broken” you need to find the bottleneck quickly: rendering, CSS collisions, overflow, or performance. Here’s the order that saves time.

First: isolate scope and collisions

  • Inspect the Markdown container element. Confirm it has a dedicated scope class (like .md).
  • In DevTools, check computed styles for h2, ul, pre, table. If the styles come from a global reset or component library, you have a collision.
  • Temporarily disable global typography/reset styles to see if the Markdown becomes sane. If yes: fix scoping and cascade order.

Second: identify the overflow culprit

  • Look for horizontal scroll on the page. Find which element exceeds the viewport.
  • Common offenders: table, pre, long unbroken strings in paragraphs, images with fixed widths.
  • Add containment: wrap tables, set overflow: auto on code blocks, and apply word-breaking rules only to prose—not code blocks.

Third: check performance and layout shift

  • Measure CLS and font swap behavior. If the page jumps, the CSS or fonts are arriving late.
  • Verify images have dimensions. If not, fix at the Markdown processing layer.
  • Confirm syntax highlighting isn’t doing heavy DOM work on every render.

Practical tasks: commands, outputs, and what you decide next

These are the tasks I actually run when a “docs page styling” problem hits production. They focus on the pipeline: build artifacts, CSS payload, caching, and regressions. Each includes a command, sample output, and the decision you make from it.

Task 1: Verify the Markdown HTML output is what you think it is

cr0x@server:~$ node -e "const fs=require('fs');const md=fs.readFileSync('sample.md','utf8');const {marked}=require('marked');console.log(marked.parse(md).slice(0,800));"
<h1>Runbook: Database failover</h1>
<p>If replication is behind, stop and verify.</p>
<h2>Prereqs</h2>
<ul>
<li>Access to <code>prod-bastion</code></li>
<li>Current primary is <code>db-01</code></li>
</ul>
<pre><code class="language-bash">...</code></pre>

What it means: You confirm whether your renderer emits <pre><code> with classes, how lists are nested, and whether tables exist.

Decision: If the output differs between environments (server vs client), stop. Align the renderer or normalize output before touching CSS.

Task 2: Check which CSS rules win (collision audit)

cr0x@server:~$ rg -n "pre\\s*\\{|code\\s*\\{|table\\s*\\{|blockquote\\s*\\{|h2\\s*\\{" dist/assets/*.css | head
dist/assets/app.4f19c8.css:231:pre{white-space:pre-wrap;word-break:break-word}
dist/assets/app.4f19c8.css:877:table{width:100%;font-size:12px}
dist/assets/docs.8a02b1.css:44:.md pre{overflow:auto;background:#0b1220}
dist/assets/docs.8a02b1.css:71:.md table{border-collapse:collapse}

What it means: You see global rules like pre{white-space:pre-wrap} that can break code blocks.

Decision: Move Markdown styles into a scoped file loaded after the reset, or change the reset to avoid affecting pre globally.

Task 3: Measure CSS payload size (docs styles should be lean)

cr0x@server:~$ ls -lh dist/assets/*css | sed -n '1,10p'
-rw-r--r-- 1 cr0x cr0x 412K Dec 29 11:20 dist/assets/app.4f19c8.css
-rw-r--r-- 1 cr0x cr0x  18K Dec 29 11:20 dist/assets/docs.8a02b1.css

What it means: Docs CSS is separate and small. Good. If it’s gigantic, you’re probably importing your entire design system for a runbook page.

Decision: If docs CSS is bloated, split bundles or inline only critical doc styles.

Task 4: Confirm gzip/brotli is active for CSS

cr0x@server:~$ curl -sI -H 'Accept-Encoding: br' http://localhost:8080/assets/docs.8a02b1.css | sed -n '1,12p'
HTTP/1.1 200 OK
Content-Type: text/css; charset=utf-8
Content-Encoding: br
Cache-Control: public, max-age=31536000, immutable
Vary: Accept-Encoding
ETag: "8a02b1"

What it means: Compression is on, caching is immutable, and the server varies by encoding. That’s the baseline for fast docs delivery.

Decision: If compression is missing, fix server config before micro-optimizing selectors.

Task 5: Verify caching headers for rendered Markdown pages

cr0x@server:~$ curl -sI http://localhost:8080/docs/runbook/db-failover | sed -n '1,14p'
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: no-store
Vary: Accept-Encoding

What it means: The page is not cached. That might be correct for authenticated runbooks, or it might be accidental.

Decision: If content is static and public, you want caching. If it’s private or per-user, keep no-store but ensure assets are cached aggressively.

Task 6: Find which element causes horizontal scrolling

cr0x@server:~$ node -e "console.log('Use DevTools: run in console: [...document.querySelectorAll(\"body *\")].filter(e=>e.scrollWidth>e.clientWidth).slice(0,5).map(e=>[e.tagName,e.className,e.scrollWidth-e.clientWidth]) )')"
Use DevTools: run in console: [...document.querySelectorAll("body *")].filter(e=>e.scrollWidth>e.clientWidth).slice(0,5).map(e=>[e.tagName,e.className,e.scrollWidth-e.clientWidth])

What it means: You’re using a reliable method to find overflow offenders rather than guessing.

Decision: If offenders are TABLE or PRE, add wrappers/overflow. If offenders are paragraphs, add safe word-break rules for prose.

Task 7: Detect unbroken strings in Markdown that will overflow

cr0x@server:~$ perl -ne 'while(/(\S{80,})/g){print "$ARGV:$.:$1\n"}' docs/**/*.md | head
docs/runbook/net-debug.md:42:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
docs/howto/api.md:118:sha256:8c3d0f0a1c7b9d2e4f6a9b1c3d5e7f9a0b2c4d6e8f0a2b4c6d8e0f2a4c6d8e0f2

What it means: You found tokens that will break layout unless you allow breaking in prose.

Decision: Add overflow-wrap: anywhere on .md p if needed, but do not apply it to pre/code blocks.

Task 8: Confirm code blocks aren’t being wrapped by a reset

cr0x@server:~$ rg -n "pre\\s*\\{[^}]*white-space:\\s*pre-wrap" dist/assets/app.*.css
dist/assets/app.4f19c8.css:231:pre{white-space:pre-wrap;word-break:break-word}

What it means: The reset forces wrapping and breaks indentation-sensitive code.

Decision: Override inside .md with .md pre{white-space:pre;} and remove the global rule if possible.

Task 9: Validate that Markdown headings have stable anchor IDs

cr0x@server:~$ node -e "const s=require('fs').readFileSync('sample.html','utf8');const ids=[...s.matchAll(/id=\"([^\"]+)\"/g)].map(m=>m[1]);console.log(ids.filter(x=>x.includes(' ')).slice(0,10));"
[]

What it means: No IDs with spaces. Good. Anchor IDs with unsafe characters often break TOCs and deep links.

Decision: If IDs are unstable, fix slug generation in the renderer rather than hacking CSS.

Task 10: Ensure tables are wrapped (if your renderer supports post-processing)

cr0x@server:~$ node -e "const {JSDOM}=require('jsdom');const fs=require('fs');const html=fs.readFileSync('sample.html','utf8');const dom=new JSDOM(html);const d=dom.window.document;d.querySelectorAll('.md table').forEach(t=>{const w=d.createElement('div');w.className='table-wrap';t.parentNode.insertBefore(w,t);w.appendChild(t);});console.log(d.querySelectorAll('.table-wrap table').length);"
3

What it means: You can enforce a wrapper even if authors don’t remember to add it.

Decision: If tables regularly overflow, make wrapping automatic in the pipeline. Don’t depend on author discipline.

Task 11: Check if your syntax highlighter bloats the DOM

cr0x@server:~$ node -e "const fs=require('fs');const html=fs.readFileSync('sample.html','utf8');const spans=(html.match(/

What it means: Highlighting injected lots of spans. That can impact performance, especially on low-end devices.

Decision: If span count is huge, consider server-side highlighting for static docs, limit highlighting to important languages, or use lighter themes.

Task 12: Inspect Lighthouse-like signals for layout shift in CI (quick and dirty)

cr0x@server:~$ node -e "console.log('Run a headless audit in CI (example): npx playwright test --project=chromium; capture CLS via PerformanceObserver and fail if above threshold')"
Run a headless audit in CI (example): npx playwright test --project=chromium; capture CLS via PerformanceObserver and fail if above threshold

What it means: You’re turning “someone noticed it jumps” into a regression test.

Decision: If CLS regresses, treat it like performance debt: fix fonts, image dimensions, or CSS loading order.

Task 13: Confirm fonts are not blocking rendering

cr0x@server:~$ rg -n "@font-face" dist/assets/*.css | head
dist/assets/app.4f19c8.css:12:@font-face{font-family:Inter;src:url(/assets/Inter.woff2) format("woff2");font-display:swap}

What it means: font-display: swap is set, reducing FOIT (flash of invisible text).

Decision: If swap is missing and you see invisible text, add it. Or use a system font stack for Markdown pages.

Task 14: Validate CSS scoping is enforced (no global tag selectors in docs CSS)

cr0x@server:~$ awk 'length($0)<500{print}' dist/assets/docs.8a02b1.css | rg -n "^(h1|h2|h3|p|pre|code|table|ul|ol|blockquote)\b" | head

What it means: No matches: the docs stylesheet doesn’t contain raw tag selectors at the top level.

Decision: If you do see global selectors, refactor them to .md h2 etc. Don’t negotiate with future outages.

Common mistakes: symptoms → root cause → fix

1) Symptom: code blocks wrap and commands become uncopyable

Root cause: A global reset sets pre { white-space: pre-wrap; word-break: break-word; } or applies word-break to all code.

Fix: Inside the Markdown scope, explicitly set .md pre { overflow:auto; } and .md pre code { white-space: pre; }. Remove global rules if you can.

2) Symptom: mobile page has horizontal scrolling even with no tables

Root cause: Unbroken tokens in prose (hashes, long URLs, base64 blobs) combined with white-space: nowrap inherited from a component style.

Fix: Set .md p { overflow-wrap: anywhere; } or word-break: break-word; for prose only. Keep code blocks as white-space: pre.

3) Symptom: lists look like one dense paragraph

Root cause: CSS reset removed list margins/padding; no replacement rhythm provided.

Fix: Restore list padding and margins inside .md. Add small li vertical margins and control nested list spacing.

4) Symptom: tables blow out the layout or shrink to unreadable text

Root cause: Table width forced to 100% plus a small global font size, or tables are not wrapped for overflow.

Fix: Wrap tables in overflow-x:auto. Keep font size at baseline. If necessary, allow min-width on columns or rely on horizontal scroll.

5) Symptom: headings overlap sticky header when using anchors

Root cause: No scroll offset behavior configured.

Fix: Add scroll-margin-top to headings inside Markdown scope. Use a value aligned to the header height.

6) Symptom: inline code looks like clickable buttons and distracts

Root cause: Overstyled code with large padding, heavy background, and high contrast.

Fix: Reduce padding, use subtle background and thin border, and keep font size near body size.

7) Symptom: dark mode docs are unreadable (either too dim or too bright)

Root cause: Colors chosen for light mode are inverted poorly; code blocks remain light or use pure black.

Fix: Define separate dark mode variables. Use dark navy backgrounds, not pure black, and keep link colors readable.

8) Symptom: “why did this doc page change the styling of our checkout page?”

Root cause: Markdown stylesheet uses global tag selectors (h2 { ... }, table { ... }) shipped on every page.

Fix: Scope everything under a container class; load docs CSS only where needed; consider cascade layers.

Three corporate mini-stories from the markdown trenches

Incident caused by a wrong assumption: “Our Markdown renderer always outputs the same HTML”

A company I worked with had two Markdown render paths: server-side for public docs, client-side for internal runbooks. It started as an optimization: internal pages would render faster after navigation, and the internal portal team could iterate without redeploying backend.

Everyone assumed “Markdown is Markdown,” meaning the HTML would be the same. It wasn’t. The server renderer produced <pre><code class="language-bash">. The client renderer produced <pre class="language-bash"><code>. The CSS, naturally, targeted only one of those shapes.

The failure showed up during an incident: an on-call engineer pasted a command from a runbook, but the code block had wrapped mid-flag due to a global pre rule. The pasted command failed. The engineer retried with edits, then stopped trusting the runbook and started improvising. Time went sideways.

The postmortem root cause was not “bad CSS,” it was “two renderers, one mental model.” The fix was twofold: normalize Markdown output (pick one renderer or post-process to a canonical HTML shape) and scope code block CSS to handle both forms. After that, they added a test that renders the same Markdown through both paths and diffs the DOM structure for key nodes.

Optimization that backfired: “Let’s reduce DOM nodes by stripping wrappers”

Another team wanted to speed up docs pages by simplifying the generated HTML. They removed wrapper elements around tables and code blocks because “extra divs are bad.” Philosophically clean. Operationally messy.

Without a table wrapper, the only way to prevent a wide table from blowing out the layout was to apply overflow rules directly to the table or its parent container. But overflow doesn’t work on tables in the way people wish it did, and the parent container also contained normal text. Suddenly the entire page had horizontal scroll behavior, because one table was wide.

They then “optimized” further by adding word-break: break-all to the Markdown container to prevent overflow. That stopped the scrolling, and also broke copy/paste of hashes, URLs, and inline code. On-call started seeing tickets like “your docs corrupt commands.” Which is a special kind of embarrassing because the content itself was correct; the CSS was lying.

They eventually reintroduced wrappers for tables and code blocks, but in a controlled way: the renderer adds wrappers only when needed (table present, code block present). The page became stable again, and the performance gain from removing wrappers turned out to be mostly imaginary compared to the cost of reflow from layout issues.

Boring but correct practice that saved the day: “We snapshot docs styles and test the weird cases”

One org I respect treated docs styling like an API. They had a tiny fixture set of “evil Markdown”: deeply nested lists, long tokens, wide tables, mixed inline code, blockquotes with lists inside, images, and a code block containing a long command line.

Every change to the typography CSS ran through visual snapshots in CI at three viewport widths and two color schemes. It wasn’t glamorous. Nobody got promoted for “reduced li margin by 2px.” But it prevented regressions that would have hit the worst possible audience: people trying to do work.

During a redesign, a global reset landed that removed all list indentation and changed pre to wrap. The fixtures caught it before release. The fix was a scoped override inside .md and a rule: global resets can’t affect raw HTML tags without a design system owner signing off.

The boring practice—fixtures, snapshots, scoped CSS—meant the redesign shipped without docs becoming collateral damage. That’s what “reliability” looks like when the system is mostly text.

Checklists / step-by-step plan

Step-by-step plan to implement sane Markdown CSS

  1. Introduce a Markdown container class. Wrap all rendered Markdown in <div class="md">...</div>. No exceptions.
  2. Define variables for typography and spacing. Set base font stack, text color, link color, borders, and spacing scale.
  3. Restore typographic rhythm. Set margins for p, h2, h3, and lists inside .md.
  4. Harden overflow behavior. Code blocks: overflow:auto. Tables: wrap in a scroll container. Prose: allow breaking long tokens.
  5. Make anchors work. Add scroll-margin-top to headings and ensure IDs are stable.
  6. Handle dark mode deliberately. Use CSS variables with prefers-color-scheme: dark overrides.
  7. Add print styles. Especially for code blocks and links.
  8. Create fixture Markdown. Include worst-case examples. Keep it small but nasty.
  9. Automate verification. Snapshot tests and a simple overflow detector. Fail builds on regressions.
  10. Ship scoped CSS only where needed. If docs are on a dedicated route, don’t load the Markdown stylesheet everywhere.

Pre-flight checklist for a Markdown styling change

  • Does the change affect only .md scope?
  • Have you tested a wide table on a narrow viewport?
  • Have you tested a long unbroken token in a paragraph?
  • Does a long code line remain copyable?
  • Do anchor links land correctly under a sticky header?
  • Does dark mode maintain contrast for links and code?
  • Does printing produce readable code blocks?

Rollback checklist

  • Can you disable the new docs CSS via feature flag or route-level config?
  • Do you have a previous known-good stylesheet artifact you can redeploy quickly?
  • Do you have a minimal “safe mode” style (system fonts, basic spacing, overflow rules) to fall back to?

FAQ

1) Should I use a CSS reset for Markdown content?

Not directly. Use a reset at the application level if you must, but then explicitly rebuild spacing and typography inside .md. Markdown needs margins; zero-margin docs are hostile.

2) Is it okay to wrap code blocks instead of scrolling?

Only if you’re willing to accept broken copy/paste and ambiguous indentation. For runbooks and commands, default to horizontal scrolling. If you need wrapping for prose-like code (rare), make it opt-in.

3) How do I make tables responsive without horizontal scroll?

You can transform rows into cards with CSS, but it assumes you know the schema. With arbitrary Markdown tables, horizontal scroll is the most reliable behavior.

4) Why does my Markdown look different between environments?

Different Markdown renderers (or different versions) produce different HTML structures. Fix the renderer alignment first. CSS can paper over differences, but you’ll be chasing edge cases forever.

5) Do I need separate styles for server-rendered and client-rendered Markdown?

You need styles that match the emitted HTML. The better solution is to make the emitted HTML consistent across render paths, then write one scoped stylesheet.

6) How do I prevent Markdown styles from affecting the rest of the app?

Scope with a container class and avoid global tag selectors. Also consider loading docs CSS only on docs routes. “We’ll be careful” is not a strategy.

7) What about syntax highlighting themes—should I pick one and call it done?

Pick a theme that has good contrast and doesn’t depend on tiny font sizes. Then verify selection visibility and copy/paste. Highlighting that looks pretty but is unreadable in dark mode is a regression.

8) Should Markdown content use the same typography as the product UI?

Usually yes for body text, but code blocks should always use a solid monospaced stack. Keep docs familiar, but don’t force UI constraints that harm readability (like narrow cards or tiny fonts).

9) Can I just use a popular “Markdown CSS” library?

You can, but treat it like third-party code: audit it, scope it, and test your worst-case content. Many libraries assume blog posts, not runbooks.

10) What’s the minimum CSS I need for acceptable Markdown rendering?

Scoped margins for headings/paragraphs/lists, code block overflow styling, table overflow wrapper, and link styling. Everything else is quality-of-life.

Conclusion: next steps you can do this week

Markdown styling becomes “hard” when you pretend it’s decorative. Treat it like a production interface: semi-trusted input, variable output, and real users depending on it to do real work.

Do these next:

  1. Wrap rendered Markdown in a dedicated scope class and stop styling raw tags globally.
  2. Implement the baseline: consistent heading/list spacing, code block overflow, and table wrappers.
  3. Build a small fixture set of ugly Markdown and snapshot it at multiple viewports and color schemes.
  4. Add a quick overflow detector and a CLS guardrail to CI so “looks fine on my machine” stops being your QA plan.

When your docs are stable, your on-call is faster, your onboarding is smoother, and your UI team stops treating Markdown like a rogue subsystem. That’s a real reliability win—just made of text.

← Previous
Why Synthetic Benchmarks Lie (and How to Catch Them)
Next →
Fans Installed Backwards: When Airflow Goes the Wrong Way

Leave a comment