---
title: "Stylelint plugin"
description: "The proposed CSS rule set catches hardcoded colours, tier violations, and a status token applied to a category surface."
url: "https://opsinjs.pensievelabs.org/handbook/tooling/stylelint-plugin"
source: "https://opsinjs.pensievelabs.org/handbook/tooling/stylelint-plugin.md"
section: "Handbook"
kind: "handbook"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["stylelint-plugin-opsinjs", "css lint rules", "token tier enforcement", "no hardcoded colour css"]
---

> 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 />

`stylelint-plugin-opsinjs` is specified but not built. CSS needs its own plugin
rather than an ESLint rule because most of the violations that matter happen in
a stylesheet, not in JSX. They surface in an override file, a theme, a copied
component's CSS.

```js
// stylelint.config.mjs is proposed and not yet publishable
export default {
  plugins: ["stylelint-plugin-opsinjs"],
  extends: ["stylelint-plugin-opsinjs/recommended"],
}
```

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

### Proposed rules [#proposed-rules]

**`opsinjs/no-raw-color`** is an error-level rule.
Any colour literal in a declaration whose property affects colour: hex, `rgb()`,
`hsl()`, `oklch()` and named colours. The exception is the token definition
files themselves, where the literals are the point.
&#x2A;Why:* the same reason as in JSX, with a wider blast radius. A stylesheet rule
applies to every instance.

**`opsinjs/token-tier`** is an error-level rule.
Flags a component-level custom property assigned a primitive value directly,
skipping the semantic tier. Tiers are defined in
[Token architecture](../../foundations/token-architecture.mdx).
&#x2A;Why:* the three-tier structure is what makes a theme swap possible; a component
reading a primitive is not themeable.

**`opsinjs/no-axis-crossing`** is an error-level rule.
Flags a `--opsin-status-*` value assigned to a `--opsin-category-*` property, or
the reverse, and flags a status token used in a declaration on a selector that
also carries `[data-category]` in a way that changes the same visual channel.
&#x2A;Why:* [the two colour axes](../../health/two-colour-axes.mdx), enforced where
the mixing actually happens.

**`opsinjs/no-contrast-floor-violation`** is a warn-level rule.
Where both sides of a foreground/background pair are opsinjs tokens, checks the
pair against the published floor using the same APCA implementation as
`check-contrast.mts`.
&#x2A;Why:* it moves a CI failure to the editor. It cannot check a pair involving a
value it does not know, which is why it warns rather than errors.

**`opsinjs/no-transition-on-layout`** is a warn-level rule.
Flags `transition` and `animation` on `width`, `height`, `top`, `left`,
`margin`, `padding`.
&#x2A;Why:* they do not composite. See
[Motion in practice](../motion-in-practice.mdx).

**`opsinjs/require-logical-properties`** is a warn-level rule.
Flags `margin-left`, `padding-right`, `left`, `right`, `text-align: left|right`
in favour of their logical equivalents.
&#x2A;Why:* right-to-left support is cheap to keep and expensive to retrofit. See
[Internationalisation](../internationalisation.mdx).

**`opsinjs/no-global-component-variable`** is a warn-level rule.
Flags a `--opsin-<component>-*` property set on `:root`, `html` or `body`.
&#x2A;Why:* it changes every instance in the application, including the ones the
author has never seen. See [Styling](../styling.mdx).

**`opsinjs/no-opacity-for-emphasis`** is a warn-level rule.
Flags an `opacity` below a threshold on an element carrying text.
&#x2A;Why:* reducing opacity to make something look secondary reduces contrast
against the background, which is the most common accidental contrast failure and
the one that hits stale values hardest. See
[The contrast floor](../../foundations/materials/the-contrast-floor.mdx).

## Do this [#do-this]

* **Run it on your overrides and your theme files**, which is where the
  violations concentrate.
* **Keep the token definition files in the documented exception list** rather
  than disabling the rule for them ad hoc.
* **Fix `no-opacity-for-emphasis` with a token**, not with a slightly higher
  opacity.

## Not this [#not-this]

* **Do not exclude your theme file to make `no-raw-color` pass.** The theme is
  where the literals belong, and the exception should say so explicitly.
* **Do not disable `no-axis-crossing`.** If it fires, either the token is wrong
  or the design is. Both are worth stopping for.
* **Do not run it on `globals.css` wholesale** in this repository. That file is
  in `.prettierignore` for related reasons, and its lyra block is intentionally
  preserved byte-for-byte from the shadcn CLI so it can be diffed against a
  fresh init.

## Gotchas [#gotchas]

* **CSS custom properties are untyped.** The plugin can only reason about names
  it recognises; a project-specific property assigned a status value is
  invisible.
* **Contrast checking needs both sides.** A token against `inherit`, against an
  image, or against a translucent material cannot be evaluated statically. That
  is what [the contrast
  floor](../../foundations/materials/the-contrast-floor.mdx) and its scrim rules
  exist for.
* **Tailwind generates CSS the linter never sees.** Utility classes are compiled
  from your markup, so a hardcoded colour in an arbitrary value is an ESLint
  problem, not a Stylelint one.
* **`oklch()` literals are legitimate in exactly one place.** That place is the
  generated token layer, and they are illegitimate everywhere else, which makes
  the exception list load-bearing.

## Related [#related]

* [Tooling](./index.mdx) covers the three-layer enforcement model.
* [ESLint plugin](./eslint-plugin.mdx) covers the same invariants in JSX.
* [Token architecture](../../foundations/token-architecture.mdx) has the tiers
  `token-tier` enforces.
* [Validating your theme](../../theming/validating-your-theme.mdx) has the CI
  check that catches what a linter cannot.
