Typography
Headings, display scale, and the long-form treatment for article content.
Stisla has two typographic contexts. The pattern classes on this page (.h1 through .h6, .lead, .display-*) are for app UI like card titles, page section labels, and dashboard headers. You control spacing. For article or CMS content where the typography owns its rhythm, jump to long-form content.
Headings
Six levels rendered against the 14px baseline. Heading weight stays consistent across the scale; size carries the hierarchy.
Heading one
Heading two
Heading three
Heading four
Heading five
Heading six
<div>
<p class="h1">Heading one</p>
<p class="h2">Heading two</p>
<p class="h3">Heading three</p>
<p class="h4">Heading four</p>
<p class="h5">Heading five</p>
<p class="h6">Heading six</p>
</div>Reboot strips the default size and weight from <h1> through <h6> so the heading element doesn’t leak visual treatment into UI contexts (a card title that’s an <h5> shouldn’t pick up the heading’s default margin). Use the matching class for the look; the element carries the document outline.
A paragraph styled as an h3.
<p class="h3">A paragraph styled as an h3.</p>Display
For hero titles and marketing pages. Lighter weight, tighter leading, sized larger than the regular heading scale.
Display 1
Display 2
Display 3
Display 4
Display 5
Display 6
<div>
<p class="display-1">Display 1</p>
<p class="display-2">Display 2</p>
<p class="display-3">Display 3</p>
<p class="display-4">Display 4</p>
<p class="display-5">Display 5</p>
<p class="display-6">Display 6</p>
</div>Lead
Add .lead to the first paragraph under a title for a slightly larger intro.
The cache layer
A tiered cache backed by Redis with per-tenant eviction and a write-through fallback to Postgres for cold reads.
<div>
<p class="h3">The cache layer</p>
<p class="lead">A tiered cache backed by Redis with per-tenant eviction and a write-through fallback to Postgres for cold reads.</p>
</div>Long-form content
Wrap article or CMS content in .prose. It restores the reading typography that the foundation reboot strips (heading scale and rhythm, paragraph margins, list markers, blockquote treatment, inline code chip, tables, figure captions). Everything an article needs and nothing a dashboard does.
The parent decides width. .prose handles typography. It isn't a layout box.
The article below exercises every element .prose styles. It covers headings, lead, paragraphs with inline <a>/<strong>/<em>/<code>/<kbd>/<small>, unordered + ordered + description lists, blockquote, inline code chip, pre/code block, image, figure with caption, table, and horizontal rule.
June 4, 2026 · Engineering · 6 min read
Shipping a 14px baseline
Why we dropped the body text by two pixels and what changed downstream.
The framework used to render body copy at 16px, the common web default. That reads fine on a marketing page but feels heavy in a dashboard where every row counts. We took the baseline to 14px and let the heading scale follow, without touching html. 1rem stays anchored at 16px and every component’s rem-based math keeps its pixel meaning. The whole change is one commit on the v3 branch.
What moved
Every component that inherits from body shrank by about an eighth. That includes the default text in buttons, inputs, badges, and table cells. The grid stayed where it was, so column layouts kept their place.
- Headings dropped proportionally, since the scale is hand-tuned for the 14px baseline.
- Form controls held their pixel heights through
height × density, so dense forms kept their rhythm.- Default control height stays at 36px.
- Compact preset drops it to 31.5px via
--st-density: 0.875.
- Icons sized in
emtracked the new baseline for free.
The full heading scale, side by side with the pixel values at the new baseline:
| Class | Size | Pixels |
|---|---|---|
.h1 | 2rem | 32 |
.h2 | 1.5rem | 24 |
.h3 | 1.25rem | 20 |
.h4 | 1.125rem | 18 |
.h5 | 1rem | 16 |
.h6 | 0.875rem | 14 |
The body rule itself stayed boring:
body {
font-size: 0.875rem;
line-height: 1.5;
color: var(--st-foreground);
background-color: var(--st-background);
}
The line you set as the baseline is the line every screen rounds to. Pick it like you mean it.
Where it shows up
The change reads loudest in three places. Each one earned its own dashboard pass during the migration.
- Card titles inside dashboard widgets. They used to read like page sections; now they read like card chrome.
- Form field labels in dense layouts. The new size keeps the label and its input visually tied.
- Table cells in data-heavy views. More rows fit on screen without horizontal scroll.
Terms used in this post
- Baseline
- The body
font-sizethat every other size derives from, either directly or byemscaling. - Density
- The single root variable that multiplies hard heights on form controls and buttons.
- Reboot
- The foundation layer that strips browser defaults so component contexts don’t inherit them.
What stayed
Inter loads at 400, 500, 600, and 700. The display weights live on the same family, so a hero title and a body paragraph share their letterforms. Press ? on any docs page to see the rest of the shortcuts. Shortcut listing requires the docs JS to be loaded.
The footnotes
- This post does not change the grid system.
- Density presets still target the same physical control heights as before, just relative to the new baseline.
Published in Engineering Notes · Edited 2 weeks later
<article style="max-width: 36rem;">
<div class="prose">
<p class="text-muted-foreground fs-2">June 4, 2026 · Engineering · 6 min read</p>
<h1>Shipping a 14px baseline</h1>
<p class="lead">Why we dropped the body text by two pixels and what changed downstream.</p>
<p>The framework used to render body copy at <code>16px</code>, the common web default. That reads fine on a marketing page but feels heavy in a <strong>dashboard</strong> where every row counts. We took the baseline to <code>14px</code> and let the heading scale follow, <em>without</em> touching <code>html</code>. <code>1rem</code> stays anchored at 16px and every component’s rem-based math keeps its pixel meaning. The whole change is one <a href="#">commit on the v3 branch</a>.</p>
<img alt="Stand-in banner illustrating the type-scale comparison" src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 180'><defs><linearGradient id='g' x1='0' x2='1'><stop offset='0' stop-color='%23e0e7ff'/><stop offset='1' stop-color='%23c7d2fe'/></linearGradient></defs><rect width='600' height='180' fill='url(%23g)'/><text x='300' y='90' text-anchor='middle' font-family='ui-sans-serif,system-ui,sans-serif' font-size='18' fill='%231e1b4b' font-weight='600'>Before / After: type-scale comparison</text><text x='300' y='114' text-anchor='middle' font-family='ui-sans-serif,system-ui,sans-serif' font-size='13' fill='%23312e81'>16px baseline vs 14px baseline</text></svg>">
<h2>What moved</h2>
<p>Every component that <em>inherits</em> from body shrank by about an eighth. That includes the default text in buttons, inputs, badges, and table cells. The grid stayed where it was, so column layouts kept their place.</p>
<ul>
<li>Headings dropped proportionally, since the scale is hand-tuned for the 14px baseline.</li>
<li>Form controls held their pixel heights through <code>height × density</code>, so dense forms kept their rhythm.
<ul>
<li>Default control height stays at 36px.</li>
<li>Compact preset drops it to 31.5px via <code>--st-density: 0.875</code>.</li>
</ul>
</li>
<li>Icons sized in <code>em</code> tracked the new baseline for free.</li>
</ul>
<p>The full heading scale, side by side with the pixel values at the new baseline:</p>
<table>
<thead>
<tr><th>Class</th><th>Size</th><th>Pixels</th></tr>
</thead>
<tbody>
<tr><td><code>.h1</code></td><td>2rem</td><td>32</td></tr>
<tr><td><code>.h2</code></td><td>1.5rem</td><td>24</td></tr>
<tr><td><code>.h3</code></td><td>1.25rem</td><td>20</td></tr>
<tr><td><code>.h4</code></td><td>1.125rem</td><td>18</td></tr>
<tr><td><code>.h5</code></td><td>1rem</td><td>16</td></tr>
<tr><td><code>.h6</code></td><td>0.875rem</td><td>14</td></tr>
</tbody>
</table>
<p>The body rule itself stayed boring:</p>
<pre><code>body {
font-size: 0.875rem;
line-height: 1.5;
color: var(--st-foreground);
background-color: var(--st-background);
}</code></pre>
<blockquote>
<p>The line you set as the baseline is the line every screen rounds to. Pick it like you mean it.</p>
</blockquote>
<h2>Where it shows up</h2>
<p>The change reads loudest in three places. Each one earned its own dashboard pass during the migration.</p>
<ol>
<li>Card titles inside dashboard widgets. They used to read like page sections; now they read like card chrome.</li>
<li>Form field labels in dense layouts. The new size keeps the label and its input visually tied.</li>
<li>Table cells in data-heavy views. More rows fit on screen without horizontal scroll.</li>
</ol>
<figure>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 220" class="d-block w-100" style="height: auto; background: var(--st-surface-2); border-radius: calc(var(--st-radius) - 1px);">
<rect x="24" y="24" width="200" height="28" fill="var(--st-primary)" opacity="0.85" rx="4"/>
<text x="232" y="44" font-family="ui-sans-serif,system-ui,sans-serif" font-size="13" fill="var(--st-foreground)">h1 · 32px</text>
<rect x="24" y="62" width="150" height="22" fill="var(--st-primary)" opacity="0.72" rx="4"/>
<text x="232" y="78" font-family="ui-sans-serif,system-ui,sans-serif" font-size="13" fill="var(--st-foreground)">h2 · 24px</text>
<rect x="24" y="92" width="124" height="20" fill="var(--st-primary)" opacity="0.60" rx="4"/>
<text x="232" y="106" font-family="ui-sans-serif,system-ui,sans-serif" font-size="13" fill="var(--st-foreground)">h3 · 20px</text>
<rect x="24" y="120" width="110" height="18" fill="var(--st-primary)" opacity="0.48" rx="4"/>
<text x="232" y="133" font-family="ui-sans-serif,system-ui,sans-serif" font-size="13" fill="var(--st-foreground)">h4 · 18px</text>
<rect x="24" y="146" width="92" height="16" fill="var(--st-primary)" opacity="0.36" rx="4"/>
<text x="232" y="158" font-family="ui-sans-serif,system-ui,sans-serif" font-size="13" fill="var(--st-foreground)">h5 · 16px</text>
<rect x="24" y="170" width="78" height="14" fill="var(--st-primary)" opacity="0.24" rx="4"/>
<text x="232" y="181" font-family="ui-sans-serif,system-ui,sans-serif" font-size="13" fill="var(--st-foreground)">h6 · 14px</text>
</svg>
<figcaption>The heading scale rendered to scale at the 14px baseline.</figcaption>
</figure>
<h3>Terms used in this post</h3>
<dl>
<dt>Baseline</dt>
<dd>The body <code>font-size</code> that every other size derives from, either directly or by <code>em</code> scaling.</dd>
<dt>Density</dt>
<dd>The single root variable that multiplies hard heights on form controls and buttons.</dd>
<dt>Reboot</dt>
<dd>The foundation layer that strips browser defaults so component contexts don’t inherit them.</dd>
</dl>
<hr>
<h2>What stayed</h2>
<p>Inter loads at 400, 500, 600, and 700. The display weights live on the same family, so a hero title and a body paragraph share their letterforms. Press <kbd>?</kbd> on any docs page to see the rest of the shortcuts. <small>Shortcut listing requires the docs JS to be loaded.</small></p>
<h3>The footnotes</h3>
<ul>
<li>This post does not change the <a href="#">grid system</a>.</li>
<li>Density presets still target the same physical control heights as before, just relative to the new baseline.</li>
</ul>
<p class="text-muted-foreground fs-2">Published in <a href="#">Engineering Notes</a> · Edited 2 weeks later</p>
</div>
</article>Customization
Five variables retune .prose without touching component CSS. Override on .prose itself, or on any ancestor. The cascade scopes the change.
| Variable | Default | Use |
|---|---|---|
--st-prose-line-height |
1.625 | Reading leading |
--st-prose-link-color |
var(--st-primary) | Inline link color |
--st-prose-marker-color |
var(--st-muted-foreground) | List bullets / numbers |
--st-prose-quote-border |
var(--st-border) | Blockquote left rule |
--st-prose-headings-color |
var(--st-foreground) | Heading color |
Components nested inside prose customize through the cascade. To enlarge <kbd> chips inside long-form content, set --st-kbd-padding-y on .prose. The kbd component reads its own variable and the cascade scopes the change. No prose-specific variable is invented for the nested component.