Engineering

Legal numbering (1, 1.1, 1.1.1) with CSS counters

Contracts and specifications need hierarchical clause numbering. Writing the numbers into the text guarantees they drift. CSS counters keep the markup clean and the numbering always correct.

6 min read

Contracts, specifications, policies and statutes are numbered hierarchically: clause 1, sub-clause 1.1, sub-sub-clause 1.1.1. People cite those numbers in email, in court, and in other documents, so they have to be correct — and they have to stay correct when clause 3 is deleted and everything below it shifts.

There are two ways to produce that numbering. One of them guarantees the numbers eventually drift.

The approach that drifts

The intuitive implementation is to write the numbers into the text: prefix each item with "1.2.1 " as literal characters. It looks right immediately, and it is wrong the moment anyone edits the document.

CSS has a purpose-built mechanism. counter-reset starts a counter in a scope, counter-increment advances it per item, and the counters()function — plural, note the s — renders every counter of that name in the ancestor chain, joined by a separator you choose.

That last part is what makes the whole thing about six lines of CSS. You do not track depth; the cascade does it for you.

ol.legal, ol.legal ol {
  counter-reset: clause;      /* every list starts a fresh scope */
  list-style: none;           /* suppress the native marker      */
}

ol.legal > li, ol.legal ol > li {
  counter-increment: clause;  /* each item advances its scope    */
  position: relative;
}

ol.legal > li::before, ol.legal ol > li::before {
  content: counters(clause, ".") ".";   /* 1  /  1.1  /  1.1.1   */
  position: absolute;
  left: -3.2em;
  width: 3em;
  text-align: right;
  font-variant-numeric: tabular-nums;   /* digits stay aligned    */
}

Applied to ordinary nested lists, that produces the numbering with no other machinery:

<ol class="legal">
  <li>Definitions              <!-- 1.     -->
    <ol>
      <li>Agreement            <!-- 1.1.   -->
      <li>Parties              <!-- 1.2.   -->
        <ol>
          <li>Discloser        <!-- 1.2.1. -->
          <li>Recipient        <!-- 1.2.2. -->
        </ol>
    </ol>
  <li>Obligations              <!-- 2.     -->
</ol>

What you get for free

Because numbering is presentational, plain-text copy of the list will not contain the numbers. If your users need to paste numbered clauses into email as text, generate the numbers on that export path explicitly — do not solve it by writing them back into the document.

Details worth getting right

Suppress the native marker properly

list-style: none handles most cases, but some user-agent and reset stylesheets still draw a ::marker. Setting content: "" on ::markeras well is cheap insurance against a doubled “1. 1.”.

Leave room for the number

Absolutely positioning the ::before into the left padding keeps the text block flush. Give the root list enough padding-leftfor your deepest expected level — “1.2.3.4.” is considerably wider than “1.” — and increase the indent on nested lists.

Use tabular figures

font-variant-numeric: tabular-nums keeps digits monospaced, so numbers stay right-aligned as they move from 9. to 10.

Make the separator configurable

Both arguments are yours: counters(clause, "-")gives 1-1-1, and the trailing string after the function controls the suffix — "." for 1.1. or ")" for 1.1). House styles vary more than you would expect.

Apply the class to the outermost list

counters()collects counters up the ancestor chain, so the scope has to start at the root list. If your UI acts on “the list containing the caret”, walk up to the outermost <ol> before applying the class, or a caret inside a sub-list will number from the wrong level.

Availability

Multi-level legal numbering is a paid add-on in several commercial editors and absent entirely from most others. In RichTextEditor it ships in the standard licence — see the documentation or try it in the live demo, which pairs it with paginated page view on a sample contract.


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.