Actual CSS

Typography

Quiet global defaults plus an opt-in prose scope for long-form content, with a separate optional module for fluid display type.

Typography has three layers:

  • global defaults that make plain HTML readable without becoming classless CSS
  • .prose, an opt-in rich-text scope for articles, documentation, markdown, CMS content, and long-form text
  • a small optional fluid module for display, title, and lead sizes that scale with the viewport

Components must not depend on .prose. App screens, forms, cards, tables, navigation, dashboards, and component examples should use their own component or layout rules.

Use .prose directly on a semantic content container, usually an article. It is not a page-level wrapper: component demos, navigation, forms, and other application UI belong outside it. Compose prose sections with those siblings using a layout primitive such as .stack.

Why this shape

The framework is a components-oriented, theme-oriented library, not a classless stylesheet and not a utility-first framework. The typography system reflects that.

Global typography should stay mild because:

  • a card, a dialog, a sidebar, or a dashboard must not be accidentally reshaped by selectors that target h1 or p outside .prose
  • form controls, navigation, and dense app UI need predictable inheritance rather than decorative defaults
  • the only place where headings, paragraphs, lists, and blockquotes can be styled as a hierarchy is the .prose scope

The framework avoids exposing a large public type scale. There is no fs-1 to fs-7, no Bootstrap-like heading utilities, no text-display / text-h1 / text-h2 ladder on day one. Theme work, real components, and repeated use cases are what justify new type tokens — not a speculative API.

The framework avoids RFS-style fluid typography as a foundation. Fluid sizes are useful for display text on marketing pages, but they make body text and app UI harder to reason about. A small optional module covers the display use case without dragging the rest of the system along.

Boundaries

The typography system is split into three layers with strict responsibilities.

Global baseline

The baseline owns the minimum needed to make plain HTML readable.

It applies everywhere — cards, dialogs, heroes, app shells — not only in prose.

It does not own visual hierarchy. No global font-size on headings, no global font-weight on headings, no global margins on headings, no global text-decoration on links. These choices would shape app UI in ways the framework cannot predict.

The mildly opinionated defaults at the baseline are few: line-height on headings (tight, so multi-line headings stay readable), text-wrap: balance on headings where supported, the hr rule, the ::selection rule, the code / kbd / samp / pre font family, pre overflow containment, small font size, and a global link underline offset that prose and components reuse. Everything else stays neutral so components and .prose can take over without fighting the baseline.

Global links inherit color. The visual affordance of a link is the responsibility of .prose (for authored content) or of a component (for nav links, button-like links, tabs, breadcrumbs, etc.). Underlining every a globally would collide with all of those.

.prose scope

.prose is the opt-in rich-text scope. It owns the visual hierarchy of authored content.

It owns: readable measure, vertical rhythm, heading hierarchy, link affordance, list spacing, description-list treatment, code and keyboard treatment, blockquote treatment, table treatment, image / figure / figcaption treatment, mark, and text-wrap balance / pretty for authored or generated rich-text descendants.

It does not own: page layout, app cards, alerts, forms, app tables, navigation, component variants, or any automatic styling outside the .prose subtree.

The reason for that boundary: a dialog title, a card heading, or a sidebar section heading should not become a "prose h1". Components own those. Put the component next to the prose section in a layout container; do not ask .prose to infer where an embedded component begins.

Fluid module

src/css/typography/fluid.css ships with the typography family (actual-css/css/typography) and actual-css/full, but not with the minimal core. A core-only entry point that needs display, title, and lead sizes that scale with the viewport (landing pages, marketing pages, hero sections, documentation homepages) imports it explicitly:

@import "actual-css/css/typography/fluid";

The module exposes three size tokens and three composed classes. It is deliberately not the foundation: body text, app UI, and the default prose scale stay static.

TokenClassPurpose
--fluid-display.text-displayHero / display sizes.
--fluid-title.text-titleSection titles.
--fluid-lead.text-leadIntro paragraphs on display surfaces.

There is also a non-fluid .lead utility in the core, for a simple readable intro paragraph. The fluid module does not redefine .lead; it adds a separate .text-lead for display contexts. This avoids a fluid import silently changing how existing markup renders.

Global links are intentionally neutral. Only .prose styles them as prose links, and only components style their own link-like elements (nav links, button-as-link, tabs, breadcrumbs, etc.). The reason is that underlining every a globally fights against button-like links, nav links, clickable cards, menu items, and tab triggers. Components that need link semantics should opt in with their own rule.

--link is the theme-level link ink. It is unset by default, so a bare a inherits its color and .prose links fall back to --primary — nothing changes until a theme opts in. Setting it is that opt-in, and it reaches both places at once:

:root {
  --link: var(--primary); /* bare links become link-colored, prose included */
}

The .prose fallback to --primary breaks down for a theme whose primary is a light or fully saturated accent: it can carry a filled button, where it is the background under its own --primary-fg, but not inline body text, where it has to hold contrast against --surface on its own. --link is the override for exactly that case — hover included, with no rule to restate.

[data-theme="vivid"] {
  --primary: hsl(48 100% 50%); /* fine as a button fill */
  --link: hsl(215 100% 30%);   /* 1.4:1 as body ink, so lend a darker hue */
}

It is a color token only. There are still no --link-decoration-thickness, --link-decoration-thickness-hover, or --link-underline-offset tokens; no global rule consumes them. Prose and components set their own underline metrics inline.

Type scale

There is no exposed type scale.

The framework exposes font weights, line heights, and the prose-specific overrides. It does not expose fs-1 through fs-7, heading-level tokens, or heading-level utility classes. The reason is the same as for the baseline: a public scale is a contract, and adding it now would lock in choices before theme work and real component use tell us what is actually needed.

When a scale is needed, the optional fluid module is the right place to start. A future theme can override its tokens, not invent a parallel scale in components.

Font families

The framework exposes two family slots, --font-sans (the default, applied to the document) and --font-mono (for code / kbd / samp / pre). There is no --font-serif or separate display slot. Add a serif or display family as a theme-level token in your own theme when a project actually needs one; the baseline stays sans so app UI inherits predictably.

Font weights

The framework exposes a small set of weights:

  • --font-weight-normal (400) — body default
  • --font-weight-medium (500) — slightly heavier than body, for nav links and accordion summaries
  • --font-weight-semibold (600) — emphasis, prose headings, badges, tabs, table headers
  • --font-weight-bold (700) — the strongest weight exposed, used by the navbar brand

No --font-weight-light. Light weights depend on the typeface, are rarely a good default, and add surface area without a recurring need.

These weights are also the values used by components. Components reference the tokens instead of literal weights, so theme authors can re-map the scale in one place.

Line heights

The framework exposes three line-height tokens:

  • --line-height-tight (1.25) — used by global headings and by display / title classes
  • --line-height (1.5) — body default
  • --line-height-relaxed (1.75) — used by .lead and .text-lead for intro text

There is no --line-height-normal. It would be redundant with --line-height. Prose overrides line-height through --prose-line-height and --prose-heading-line-height because the readable values for long-form text are different from app UI.

Prose hooks

  • --prose-measure — readable line length cap for .prose (the standalone .measure helper reads its own --measure).
  • --prose-line-height — body line height inside .prose.
  • --prose-heading-line-height — heading line height inside .prose.
  • --prose-flow — vertical rhythm between sibling blocks inside .prose.
  • --link — theme-level link ink, listed here because .prose is its main consumer. Unset, bare links inherit and prose links fall back to --primary.

text-wrap

text-wrap: balance and text-wrap: pretty are used at three levels:

  • global headings, where supported
  • inside .prose for headings (balance) and for paragraphs, list items, and blockquotes (pretty)
  • as opt-in utilities .text-balance and .text-pretty for any element outside .prose

These declarations degrade naturally: browsers that do not support a text-wrap value ignore it and keep normal wrapping. There is no text-align: center hidden inside .text-balance: balance is about wrapping, not alignment.

Headings

Headings are split between the global baseline and .prose.

Globally, headings get a tight line-height, a color from --heading (or inherited), and progressive text-wrap: balance. They do not get a font-size, a font-weight, or margins. The reason is that a card heading, a dialog title, or a sidebar section should not be auto-magically enlarged by selector.

Inside .prose, headings get a font-weight from --font-weight-semibold, a tighter line-height from --prose-heading-line-height, margin rhythm, and keep the global text-wrap: balance. Their color follows the contextual --heading alias, so prose also composes correctly with a contextual surface. The h1 to h4 font sizes are scoped to .prose, remain low-specificity, and are not exposed as a public scale.

The hgroup element gets only structural styling: a grid layout, a gap, and a reset of internal margins. No font-size, no color, no weight. Its visual identity is the responsibility of the headings and paragraphs it contains.

Three logical alignment utilities exist (.text-start, .text-center, .text-end) because alignment is independent of typography hierarchy and is useful in many contexts. They use logical properties so they follow writing direction.

text-wrap balance and pretty are exposed as utilities so they can be applied outside .prose (e.g. on a card title, a dialog description, a hero subtitle).

transition: all is not used. Transitions target specific properties. This is a general rule, not just a typography rule.

Small elements

small gets a smaller font size globally (0.875em) because it is a semantic element, not a utility. There is no .small class competing with the element.

code, kbd, samp, and pre get the mono font family globally. pre also gets overflow-x: auto, because one unbreakable line widens the whole page and nothing in the markup warns you. That is containment, not decoration: background, padding, and radius are still left to .prose or to a code-block component.

For a standalone snippet outside authored content, .prose is the ready-made box — opt out of the reading measure so the code can use the full width:

<div class="prose" style="--prose-measure: none">
  <pre><code>bun add actual-css</code></pre>
</div>

hr resets the UA's margin-inline: auto. In document flow that changes nothing — auto inline margins on an auto-width block resolve to zero — but as a flex or grid item they replace the cross-axis stretch, which made an <hr> inside a .stack compute to zero width and disappear. .stack deliberately leaves the inline axis free (self-centering children keep working), so the reset belongs to the element.

Its block margin is tunable with --hr-space. The --space-60 default suits a rule closing a page section; a rule separating two rows inside a panel wants far less.

.settings-panel hr {
  --hr-space: var(--space-10);
}

The hook applies to an hr in document flow. Inside .stack or .prose the parent owns the distance between siblings and overrides the element's own margin — express the rhythm there instead, with --gap or --prose-flow.

Tables

Prose tables are a different concern from app data tables. Long-form content can contain a small table, but a sortable, dense, row-actionable app table belongs to the table component outside the prose boundary. The prose table recipe is intentionally minimal: it does not define sticky headers, selection states, row actions, or density variants.

Description lists

.prose renders dl / dt / dd in document flow: terms take the strong weight, definitions keep a modest indent, and a term that follows a definition gets breathing room.

There is no grid-based key/value layout by default. A valid <dl> may pair several terms with one definition or the reverse, and a two-column grid silently assumes strict pairs. For true key/value metadata, compose a grid layout around the list yourself.

Fluid module — how to use it

To enable fluid display type on a marketing or documentation surface, import the module after actual.css in the project's stylesheet entry point:

@import "actual-css/css";
@import "actual-css/css/typography/fluid";

The module does not change anything in the core: it adds three size tokens and three classes. Body text, app UI, and the default prose scale are not affected.

Projects on actual-css/css/typography or actual-css/full already have the module. A minimal-core project that does not need fluid display type does nothing — the module is not in the core.

What not to reintroduce

These are decisions that the framework has already made. Re-introducing them silently would regress the system.

  • Do not add font-size on global h1 to h6. Headings inherit size from context (component or .prose).
  • Do not add font-weight on global h1 to h6. Heavier weights are scoped to .prose and to components that need them.
  • Do not add global text-decoration: underline on a. Links get their visual affordance from .prose or from their component.
  • Do not add transition: all. Transitions target specific properties.
  • Do not add a --font-weight-light. It is rarely a good default and depends on the typeface.
  • Do not add a text-align: center shortcut to .text-balance. Balance is about wrapping, not alignment.
  • Do not add RFS-style fluid type to the foundation. Fluid sizing is for display text in the fluid module.
  • Do not add heading-level utility classes (h1, h2, …) or a public fs-* scale until theme work and real components prove they are needed.
  • Do not extend .prose to style cards, alerts, app tables, or navigation. Those belong to their components.
Actual CSS

Search documentation