Skip to content

Component

prose

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and prose.css. No scripts.

Source

Copy both files, or run npx nojsui add prose.

Source for prose
<!-- The eyebrow is a <p>, not a heading: it labels the heading below it, and
     promoting it would put the document outline in the wrong order for a
     visual effect. It is written in sentence case and uppercased in CSS, so a
     screen reader is not handed an all-caps string.

     The heading is styled by class, so this works just as well on an <h1> or
     an <h3> — the level is a document decision, not a size one. -->
<div class="sk-prose">
  <p class="sk-prose__eyebrow">What we do</p>
  <h2 class="sk-prose__heading">Interfaces that get out of the way</h2>
  <p class="sk-prose__lede">
    The one-sentence version of the section, set larger and quieter than the
    body copy under it.
  </p>
  <p>
    Body copy sits at a reading measure — roughly 65 characters, expressed in
    <code>ch</code> so it follows whatever typeface and text size the reader
    has, rather than fighting them.
  </p>
  <p>
    Only unclassed elements pick up this rhythm. Anything you name keeps the
    styling you gave it, which is what stops the body rule from overriding the
    lede above.
  </p>
  <ul>
    <li>Lists get the same treatment.</li>
    <li>And keep their markers.</li>
  </ul>
  <p>
    Every value is a theme property, so
    <a href="https://nojsui.com/components/prose/">a consumer</a> can restyle
    the block without touching a selector.
  </p>
</div>
/* .sk-prose — eyebrow, heading, lede and body copy at a reading measure.

   Separate from .sk-section on purpose. The lint rule that forbids a component
   from naming another's classes is the immediate reason, but the better one is
   reuse: this triplet belongs inside a card, a docs page or a hero just as
   often as inside a section band.

   THE ONE SUBTLETY: body copy is matched with :not([class]). .sk-prose__lede is
   a <p>, and a plain `.sk-prose p` rule outranks `.sk-prose__lede` on
   specificity (0-1-1 against 0-1-0), so it would quietly override the lede's
   own spacing. Excluding classed elements means every named part keeps what it
   was given and everything unnamed gets the default rhythm.

   Specs:
   - max-inline-size ........ https://drafts.csswg.org/css-sizing-3/#preferred-size-properties
   - text-transform ......... https://drafts.csswg.org/css-text-4/#text-transform-property */

/* Public theme knobs (ADR 0011). Read, never declared. */
.sk-prose {
  --_measure: var(--sk-prose-measure, var(--sk-size-measure-reading));
  --_eyebrow-fg: var(--sk-prose-eyebrow-fg, var(--sk-color-accent));
  --_heading-size: var(--sk-prose-heading-size, var(--sk-text-3xl));
  --_lede-fg: var(--sk-prose-lede-fg, var(--sk-color-text-muted));
  --_link-fg: var(--sk-prose-link-fg, var(--sk-color-accent));

  max-inline-size: var(--_measure);
}

/* A <p>, not a heading: it labels the heading below it, and promoting it to a
   heading element would put the document outline in the wrong order for a
   purely visual effect. Written in sentence case in the markup and uppercased
   here, so a screen reader is not handed an all-caps string to spell out. */
.sk-prose__eyebrow {
  margin: 0;
  color: var(--_eyebrow-fg);
  font-size: var(--sk-text-xs);
  font-weight: var(--sk-weight-semibold);
  letter-spacing: var(--sk-tracking-wide);
  text-transform: uppercase;
}

/* Styled by class, so it works on h1, h2 or h3 — heading level is a document
   structure decision and is not the same decision as text size. */
.sk-prose__heading {
  margin-block-start: var(--sk-space-xs);
  font-size: var(--_heading-size);
  letter-spacing: var(--sk-tracking-tight);
  line-height: var(--sk-leading-tight);
}

.sk-prose__lede {
  margin-block-start: var(--sk-space-md);
  color: var(--_lede-fg);
  font-size: var(--sk-text-lg);
  line-height: var(--sk-leading-snug);
}

/* See the header comment: the :not([class]) is what keeps this from
   outranking .sk-prose__lede. */
.sk-prose p:not([class]),
.sk-prose ul:not([class]) {
  margin-block-start: var(--sk-space-md);
}

/* base.css strips list styling from ul[class] only, so an unclassed list here
   keeps the browser's markers and needs only its indent named. */
.sk-prose ul:not([class]) {
  padding-inline-start: var(--sk-space-lg);
}

.sk-prose li {
  margin-block-start: var(--sk-space-2xs);
}

.sk-prose a {
  color: var(--_link-fg);
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS

Usage

<div class="sk-prose">
  <p class="sk-prose__eyebrow">What we do</p>
  <h2 class="sk-prose__heading">Interfaces that get out of the way</h2>
  <p class="sk-prose__lede">The one-sentence version, set larger and quieter.</p>
  <p>Body copy, at a reading measure.</p>
</div>

The eyebrow / heading / lede triplet is the pattern every marketing section on earth repeats, and it is hand-written every time — three elements whose only real content is the space between them. That spacing is the component.

The heading is a class, not an element

.sk-prose__heading styles the class, so it works on <h1>, <h2> or <h3>. Heading level is a document structure decision — it depends on what is above this block on the page — and it is not the same decision as how large the text should be. Tying the two together is how pages end up with an <h3> chosen for its font size.

Body copy is unclassed

p and ul inside .sk-prose are styled only when they carry no class:

.sk-prose p:not([class]),
.sk-prose ul:not([class]) { … }

The :not([class]) is load-bearing. .sk-prose__lede is a <p>, and a plain .sk-prose p rule has higher specificity than .sk-prose__lede (0-1-1 against 0-1-0), so it would win and the lede would lose its own spacing. Excluding classed elements means any part you name keeps the styling you gave it, and everything you did not name gets the default rhythm.

Theming

PropertyDefaultControls
--sk-prose-measure--sk-size-measure-readingLine length for the whole block
--sk-prose-eyebrow-fg--sk-color-accentEyebrow colour
--sk-prose-heading-size--sk-text-3xlHeading size
--sk-prose-lede-fg--sk-color-text-mutedLede colour
--sk-prose-link-fg--sk-color-accentInline link colour

Keyboard contract

None — it is text. Links inside keep their native behaviour and the focus ring base.css draws.

Accessibility notes

  • The eyebrow is text-transform: uppercase, which changes the rendering only. Screen readers announce the underlying text, so write it in sentence case in the markup — “What we do”, not “WHAT WE DO”. Some screen readers spell out short all-caps strings letter by letter.
  • The eyebrow is a <p>, not a heading. It reads as a label for the heading below it, and promoting it to <h3> above an <h2> would put the document outline in the wrong order for a purely visual effect.
  • --sk-prose-eyebrow-fg defaults to the accent colour, which tools/tokens/check-contrast.mjs verifies against both surfaces.
  • The measure is in ch, so it follows the reader’s typeface and text size instead of fighting them.

Degradation

Baseline column from support.json — regenerate with pnpm support.

Feature usedBaseline statusBehavior without it
Logical propertieswidelyNothing to degrade — Baseline widely available since 2021.

base.css adds text-wrap: balance to headings and text-wrap: pretty to paragraphs where supported, which is what keeps a heading from breaking with one word on the last line. Without it the text sets normally.