Skip to content

Component

section

Demo

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

Source

Copy both files, or run npx nojsui add section.

Source for section
<!-- Two elements: the outer band paints edge to edge and owns the rhythm, the
     inner one is the centred column. A background that stopped at the measure
     would not be a band, which is why this is two elements and not one.

     Both bands below use the same markup and differ only by attribute. -->
<section class="sk-section" aria-labelledby="sk-section-demo-1-title">
  <div class="sk-section__inner">
    <h2 id="sk-section-demo-1-title">A wide band</h2>
    <p class="sk-section__label">
      The default. The column is capped at <code>--sk-size-measure-wide</code>
      (72rem) and centred; the background runs the full width.
    </p>
  </div>
</section>

<section class="sk-section" data-surface="raised" aria-labelledby="sk-section-demo-2-title">
  <div class="sk-section__inner" data-measure="reading">
    <h2 id="sk-section-demo-2-title">A raised band, at a reading measure</h2>
    <p class="sk-section__label">
      <code>data-surface="raised"</code> alternates the background so bands
      read as separate sections without a border between them.
      <code>data-measure="reading"</code> narrows this column to
      <code>--sk-size-measure-reading</code> (65ch) — a comfortable line length
      is a count of characters, so it is expressed in characters and follows
      whatever typeface is set.
    </p>
  </div>
</section>

<section class="sk-section" aria-labelledby="sk-section-demo-3-title">
  <div class="sk-section__inner">
    <h2 id="sk-section-demo-3-title">And back to base</h2>
    <p class="sk-section__label">
      Every value here is a theme property. Set
      <code>--sk-section-padding-block</code> on <code>:root</code> and the
      whole page's rhythm changes at once.
    </p>
  </div>
</section>
/* .sk-section — the page band: vertical rhythm, surface, and a centred column.

   TWO ELEMENTS, AND THE SPLIT IS THE DESIGN. The outer element paints edge to
   edge and owns the rhythm; the inner one is the centred column. One element
   cannot do both — a background that stops at the measure is not a band — and
   that is why hand-written sections always end up with a wrapper nobody can
   name. Naming it is most of the value here.

   --_measure is declared on BOTH selectors: __inner is usable on its own
   (inside a <main> that is not a .sk-section, say), and a var() with no
   declaration anywhere above it would make max-inline-size invalid at computed
   time and silently drop the measure. The other three aliases are only ever
   read by .sk-section, so they are declared there alone.

   Specs:
   - logical properties ..... https://drafts.csswg.org/css-logical-1/
   - max-inline-size ........ https://drafts.csswg.org/css-sizing-3/#preferred-size-properties */

/* Public theme knobs (ADR 0011). Read, never declared. */
.sk-section,
.sk-section__inner {
  --_measure: var(--sk-section-measure, var(--sk-size-measure-wide));
}

.sk-section {
  --_padding-block: var(--sk-section-padding-block, var(--sk-space-3xl));
  --_padding-inline: var(--sk-section-padding-inline, var(--sk-space-lg));
  --_bg: var(--sk-section-bg, var(--sk-color-bg));

  padding-block: var(--_padding-block);
  padding-inline: var(--_padding-inline);
  background: var(--_bg);
}

/* Alternating bands down a page. Background only — the text colour is
   inherited, and check-contrast.mjs verifies it against both surfaces. */
.sk-section[data-surface="raised"] {
  --_bg: var(--sk-section-bg, var(--sk-color-surface));
}

.sk-section__inner {
  margin-inline: auto;
  max-inline-size: var(--_measure);
}

/* rem for the container, ch for the reading column: a page width should not
   stretch when the reader enlarges the typeface, and a comfortable line length
   is a count of characters and should. See the README. */
.sk-section__inner[data-measure="reading"] {
  --_measure: var(--sk-section-measure, var(--sk-size-measure-reading));
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour

   Demo copy only. Delete when copying .sk-section into your own page.
--------------------------------------------------------------------------- */

.sk-section__label {
  margin: 0;
  color: var(--sk-color-text-muted);
  font-size: var(--sk-text-sm);
}

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

<section class="sk-section" aria-labelledby="pricing-title">
  <div class="sk-section__inner">
    <h2 id="pricing-title">Pricing</h2>

  </div>
</section>

<section class="sk-section" data-surface="raised" aria-labelledby="faq-title">
  <div class="sk-section__inner" data-measure="reading">
    <h2 id="faq-title">FAQ</h2>

  </div>
</section>

Two elements, and the split is the whole design: the outer element paints edge to edge and owns the vertical rhythm; the inner one is the centred column. Trying to do both on one element is why hand-written sections end up with a wrapper nobody can name.

AttributeWhereEffect
data-surface="base" (or omitted)root--sk-color-bg
data-surface="raised"root--sk-color-surface, for alternating bands down a page
data-measure="reading" (omitted → wide)inner--sk-size-measure-reading (65ch) instead of --sk-size-measure-wide (72rem)

Two measures, and why they use different units

--sk-size-measure-wide is 72rem and --sk-size-measure-reading is 65ch.

That is deliberate. A page container is a layout dimension: it should not stretch when the reader enlarges the typeface, so it is in rem. A reading measure is a typographic one — a comfortable line is roughly 45 to 75 characters — so it is in ch and tracks whatever typeface is set. Using one unit for both makes one of them wrong.

Both live in tokens.css, not here, because page width is a page-level decision — the same reasoning as --sk-density (ADR 0019). Two components each carrying their own idea of how wide a page is would drift.

Neither of these is --sk-size-measure (90ch), the existing global token tabs and accordion read for panel/item body copy. That one is a reading measure too — a deliberately wide one, per ADR 0024 — not a container width; it just happens to be wider than what section and prose needed. The point of adding --sk-size-measure-reading was not that 90ch is somehow not a reading measure, it is that the primitives needed a narrower one and a separate container width, which is a different problem than the one ADR 0024 solved. See ADR 0027’s amendment to 0024 for the full reasoning.

Theming

PropertyDefaultControls
--sk-section-padding-block--sk-space-3xlVertical rhythm between bands
--sk-section-padding-inline--sk-space-lgGutter, so the inner column never touches the edge
--sk-section-bg--sk-color-bgBand background (--sk-color-surface when raised)
--sk-section-measure--sk-size-measure-wideInner column width (--sk-size-measure-reading when data-measure="reading")

Keyboard contract

None — it is layout. Whatever you put inside keeps its own behaviour.

Accessibility notes

  • <section> is only exposed as a region landmark when it carries an accessible name — aria-labelledby pointing at the band’s heading, or aria-label. A heading nested inside a <section> does not name it; a <section> with no aria-labelledby/aria-label is not a landmark at all, it is just an element, and screen-reader users get no benefit from it over a <div>. So the real choice is between wiring up aria-labelledby (as the demo does) and using a plain <div class="sk-section"> for a band that is only visual.
  • No motion, no state, nothing else to announce.
  • The gutter is padding-inline, so it flips correctly in RTL without a second rule.
  • data-surface="raised" changes the background only. Text colour comes from the inherited --sk-color-text, which is contrast-checked against both surfaces by tools/tokens/check-contrast.mjs.

Degradation

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

Feature usedBaseline statusBehavior without it
Logical propertieswidelyNothing to degrade. padding-block, padding-inline and max-inline-size have been Baseline widely available since 2021.

There is no @supports block here and nothing that any supported engine can miss. A primitive should be the piece everything else can assume.