opsinjs
RoadmapDecisions

ADR 0011. Anatomy parts are structure, not a public API

The part tree in every specification is the DOM contract expressed as data-slot, not exported compound components, and the attribute vocabulary is four names.

Status

Accepted. 2026-09-03.

Context

Every component specification has an ## Anatomy section, and every one of them draws a tree in dot notation: ResultCard.Header, RangeBar.Track, StatusPill.Icon. The line above the tree on the StatusPill page calls it "the specification the implementation must match", which says what the tree is for. It does not say what the tree is, and the two available readings lead to different systems.

Read as an export list, the trees across the sixty specifications come to well over a hundred exported compound parts, and the system has acquired a composition API that nobody designed and no page documents. Read as nothing at all, the anatomy section is decoration and the composition tree has no data behind it.

The same specifications also say, repeatedly, that composition is the hazard rather than the feature. The StatusPill page puts it plainly: the pill "is deliberately not composable. Icon, word and colour are one unit, and the API offers no way to remove the word, because every way of removing it that has ever been offered has been used." That is a general observation about health components dressed as a note about one of them. RangeBar has a text twin that must not be removable. Field has a label that must not be hideable. A composition API is a list of ways to remove something.

Meanwhile the styling contract has to exist somewhere. A consumer who has copied a component into their own repository will restyle a part of it, and the generated reference already commits to how: attributes "appear on parts, not on components as a whole". And the attribute name is already in this tree. components/ui/button.tsx stamps data-slot="button", because that is the convention every other shadcn component a consumer owns already carries.

Decision

The part tree is the DOM contract, expressed as data-slot.

Every part named in a specification's tree renders an element carrying data-slot="<component-id>-<part>", kebab-cased and derived mechanically from the tree: ResultCard.Header becomes data-slot="result-card-header", and the root part becomes data-slot="status-pill". The root part is StatusPill.Root, or the component itself where the tree has no explicit root. There is no mapping table and no judgement involved.

The single exception is a slot whose content the consumer supplies and no prop could carry. A real dot-notation compound export exists there, and only there. That is exactly three components: Card.Header, Card.Body and Card.Footer; Sheet.Content; Field.Control. Everywhere else the content arrives as a prop, as children, or as a ReactNode slot prop, and there is no dot-notation export. An exported part is still a named export with the assignment made after the declaration, never an Object.assign over the component.

The data attribute vocabulary is four names, and a fifth requires an amendment to this record.

AttributeAppears onValues
data-slotevery part, without exceptionthe part name, kebab-cased
data-statusevery element whose colour, icon or word carries a clinical statussteady, watch, attention, urgent, unknown
data-opsinjs-valueevery element rendering a measurement a reader reads as their ownthe machine-readable value, or empty when the value is absent
data-categoryevery element tinted from the category axisthe six health categories

Attributes Base UI puts on its own primitives pass through untouched and are delegated rather than re-documented, as ADR 0001 requires. Those attributes are data-open, data-closed, data-starting-style and data-ending-style, and they do not count against this list.

Consequences

  • Three generated widgets document one answer. <CompositionTree>, <DataAttributesTable> and <CssVariablesTable> all appear on a shipped page that documents them, and all describe the same tree, from the same source, rather than three hand-written descriptions that can disagree.
  • The parts are versioned surface. Removing a data-slot, renaming a part or changing the element a part renders is a breaking change under the versioning policy, because somebody's stylesheet selects on it. That is the real cost of choosing the DOM as the contract, and it is preferable to the alternative, which is a styling surface nobody can rely on and everybody uses anyway.
  • Styling a part is a CSS selector, not a prop. A classNames object carries one entry per part, and this decision is what removes it from sixty interfaces before any of them is written.
  • data-status and data-category never appear on the same element. Putting the two axes in one vocabulary is what makes that statement checkable from source rather than by eye; it is the same rule ADR 0003 states and the error OPSIN-0001 reports, at the level where a static check can see it.
  • A part with no data-slot does not exist as far as the documentation is concerned. The tables are generated from the source, so an unstamped part is silently absent from them, and the absence looks exactly like a component that does not have that part.
  • The exception list is closed at three components. Adding a fourth compound export is an amendment to this record, not a design decision taken inside one component's pull request.

Alternatives considered

Export every part as a compound component, which is what most headless libraries do. Rejected. It hands a consumer the ability to render a StatusPill without its word, a RangeBar track without its summary, and a Field control without its label. The corresponding specification forbids every one of those in prose, and prose can prevent none of them. The StatusPill page names the mechanism exactly: every escape hatch that has been offered has been used. In a system whose components carry somebody's test results, composition freedom is the hazard rather than the selling point.

No stable contract at all: style through props. Rejected. It moves every styling decision into the interface, which then grows one prop per part per property, and it makes the data-attribute and CSS-variable tables undocumentable, because there would be nothing to document. It also fails the audience test: a consumer who has copied the source can already edit it, and the prop only helps the consumer who has not.

data-part rather than data-slot. Rejected on the weakest sufficient ground. data-slot is the convention already in this tree and already on every other shadcn component a consumer owns, and a system that invents a synonym for a convention its own consumers already use has made their stylesheet worse in exchange for nothing.

A class name, or an ARIA attribute, as the hook. Rejected. A class name is exactly what a consumer's build tooling is entitled to rewrite or minify, and ARIA is an accessibility contract that assistive technology reads. Overloading it as a styling hook means a styling change becomes an accessibility change, in both directions.

Revisiting this

Revisit when a component genuinely cannot express its content as a prop, which is the trigger for adding to the compound exception list; Card, Sheet and Field are the three found so far and a fourth is plausible rather than surprising. Do not revisit in order to add a fifth data attribute without amending the table above: those four are what the static accessibility check and the generated tables both read, and a fifth added quietly appears in neither.

Edit this page

Last read through against the system on 2026-09-20. Due for review every 12 months; expiry is reported by pnpm run check:freshness.

On this page