---
title: "Data attributes"
description: "The shared state-attribute vocabulary. Where it comes from, which attributes are Base UI's and which are opsinjs's, and why it is a versioned contract."
url: "https://opsinjs.pensievelabs.org/handbook/data-attributes"
source: "https://opsinjs.pensievelabs.org/handbook/data-attributes.md"
section: "Handbook"
kind: "handbook"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["data-open", "data-status", "data-starting-style", "state attributes", "styling state"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="handbook" />

## The short version [#the-short-version]

<NotBuiltYet />

Every stateful part will publish its state to the DOM as a `data-*` attribute,
so styling a state needs no JavaScript:

```css
[data-open] { /* the popup is open */ }
[data-status="urgent"] { /* the value's clinical status */ }
[data-category="sleep"] { /* which metric family this belongs to */ }
```

Two families of attributes exist and they come from different places. **Base UI
publishes the interaction state**: open, closed, disabled, and the animation
lifecycle. **opsinjs publishes the semantic state**: clinical status and
category. The [Reference → Data attributes](../reference/generated/data-attributes.mdx)
table has no extracted rows yet, so the vocabulary you can rely on is the one on
this page.

These attributes are **covered by semver**. Renaming one is a breaking change,
exactly like renaming a prop. That is a deliberate commitment; see
[Versioning policy](../project/versioning-policy.mdx).

## How it works [#how-it-works]

### Base UI's attributes [#base-uis-attributes]

opsinjs is built on Base UI, so its state contract passes straight through.
The ones you will use constantly:

| Attribute             | Present when                                  |
| --------------------- | --------------------------------------------- |
| `data-open`           | The part is open                              |
| `data-closed`         | The part is closed                            |
| `data-disabled`       | The part is disabled                          |
| `data-starting-style` | For one frame as the part enters              |
| `data-ending-style`   | While the part is leaving, before it unmounts |

`data-starting-style` and `data-ending-style` are what make enter and exit
transitions work without an animation library. You style the from-state and the
to-state, and the element stays mounted long enough for the exit to run. See
[Motion in practice](./motion-in-practice.mdx).

### opsinjs's attributes [#opsinjss-attributes]

These carry meaning rather than interaction, and they are the reason this page
matters more here than it would in a general-purpose library.

| Attribute            | Values                                      | What it means                                                                                                                                                                                                        |
| -------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data-slot`          | present, naming the part                    | On every part of every component. This is the stable styling and testing handle, the one attribute you can rely on being everywhere.                                                                                 |
| `data-status`        | `steady` · `watch` · `attention` · `urgent` | The clinical status of the value this part represents                                                                                                                                                                |
| `data-category`      | the metric family, e.g. `heart` · `sleep`   | Which family the surface belongs to                                                                                                                                                                                  |
| `data-opsinjs-value` | the unrounded magnitude                     | The machine-readable number behind a `Value`. A magnitude is not a reading. Nothing beside it names the unit, so read the unit from wherever the caller set it, and never export or print this attribute on its own. |

The component contract is closed at four: `data-slot`, `data-status`,
`data-opsinjs-value` and `data-category`. That is the same four the component
pages name, and no component adds a fifth. In particular there is no
`data-opsin-shape`. Card, Callout and Dialog draw the squircle with the CSS
`corner-shape` property, `[corner-shape:var(--opsin-corner-shape)]`, and the
product theme does not stamp an attribute for it either, so the shape is a
token-driven switch you cannot select on.

`data-opsinjs-not-implemented` is not part of that contract. This documentation
site stamps it on its own not-built-yet markers, `NotBuiltYet` and `StubNotice`
in `components/docs/stub.tsx`, and on `ComponentInstall` in
`components/docs/source.tsx` when the component it installs is unbuilt, so
tooling can tell a specification from a shipped component without parsing prose.
No component emits it, and a consumer's installed DOM will never carry it.

The five data states that [Data states](../foundations/data-states.mdx) defines
have no attribute today. There is no `data-state` in the DOM, so a surface's state
is legible only from the words it shows. Do not write CSS or a test against a
`data-state` selector: it would match nothing and fail silently.

`data-status` and `data-category` never appear on the same element with
overlapping visual effect. That is [the two-axis
rule](../health/two-colour-axes.mdx) expressed in the DOM, and it is what the
[stylelint plugin](./tooling/stylelint-plugin.mdx) checks.

## Do this [#do-this]

* **Style states with attribute selectors** rather than toggling classes. The
  component's DOM is already the source of truth.
* **Use `data-status` to drive presentation, never to compute one.** The status
  is assigned upstream by whoever owns the thresholds; the attribute reports it.
* **Assert on data attributes in tests.** `[data-open]` is a stable, documented
  contract; a generated class name is not. See [Testing](./testing.mdx).
* **Read `data-opsinjs-not-implemented` if you are writing tooling.** It is the
  machine-readable way to ask "does this exist yet" without parsing prose.

## Not this [#not-this]

* **Do not set `data-status` yourself to force a colour.** If you want a surface
  to look urgent without a value being urgent, you want a different component.
  Forcing the attribute puts a clinical claim in the DOM that nothing backs.
* **Do not invent attributes in the `data-opsinjs-*` namespace.** It is
  versioned; your additions will collide. Use your own prefix.
* **Do not rely on attribute *order* or on the absence of one you have not
  read about.** `data-closed` being absent is not a promise that `data-open` is
  present.
* **Do not use `data-category` for theming an unrelated surface.** A settings
  page tinted with the sleep category colour teaches the reader that the colour
  means nothing.

## Gotchas [#gotchas]

* **Boolean-style attributes are present or absent, not `"true"` or
  `"false"`.** `[data-open]` matches; `[data-open="true"]` will not. Base UI
  renders these as empty attributes.
* **`data-starting-style` lasts one frame.** You cannot inspect it comfortably
  in devtools, which is why enter transitions look "broken" when they are
  actually working. See [Motion in practice](./motion-in-practice.mdx).
* **`data-ending-style` requires the element to still be mounted.** If you
  unmount on close yourself, the exit transition never runs and no error is
  reported.
* **Attribute selectors and Tailwind arbitrary variants disagree about
  escaping.** `data-[status=urgent]:bg-…` works; quoting inside the arbitrary
  variant frequently does not.
* **Server-rendered markup carries these attributes too**, which is what makes
  them safe to style with pure CSS. It also means, though, that an incorrect
  status is visible in view-source. Do not put anything in an attribute you
  would not put on screen.

## Related [#related]

* [Styling](./styling.mdx) has the four hooks, of which this is the second.
* [Reference → Data attributes](../reference/generated/data-attributes.mdx) will
  hold the generated list once the extractor reads the component sources. Its
  table is empty for now, so this page is the vocabulary.
* [Clinical status semantics](../health/clinical-status-semantics.mdx) says what
  each `data-status` value is allowed to mean.
* [Data states](../foundations/data-states.mdx) has the five states the design
  contract names and what each obliges the surface to show. They are a design
  contract, not a DOM attribute today.
