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.
- Insert a clause in the middle and every number below it is stale until something renumbers them.
- That renumbering pass has to run on every edit, and it rewrites document content — which means undo entries, dirty state, and in a collaborative editor, a conflict on text nobody typed.
- Copy a clause elsewhere and its number comes along, now meaningless.
- The number is indistinguishable from the clause text, so search, diffing and export all treat it as prose.
CSS counters: numbering as presentation
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
- Numbers cannot drift. They are computed at render time from document structure. Insert, delete or reorder a clause and the numbering is correct on the next paint, with no renumbering pass.
- The markup stays semantic. Plain
<ol>and<li>. In our implementation the only thing stored is a single class on the root list — which is a real formatting choice, so it belongs in the saved HTML. - Copy and paste behave. Numbers are not text, so they cannot be copied out of context.
- Printing works.Counters are ordinary CSS, so the browser print pipeline renders them — PDF export and print preview reproduce the numbering without a separate code path.
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.