---
title: "Styling"
description: "className, data attributes, CSS variables and the style prop are the four override hooks, listed in their fixed order of preference, with the rule for choosing."
url: "https://opsinjs.pensievelabs.org/handbook/styling"
source: "https://opsinjs.pensievelabs.org/handbook/styling.md"
section: "Handbook"
kind: "handbook"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["className override", "how to style a component", "cn function", "css variable override"]
---

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

opsinjs components will expose exactly four styling hooks. Reach for them in
this order, and stop at the first one that works:

1. **A token.** Change the value, not the component. If several components look
   wrong the same way, a token is wrong.
2. **`className`.** Per instance, per part, merged with `cn()` so your utilities
   win over the defaults.
3. **A CSS variable on a selector you own.** For values a component reads at
   runtime, such as a track height or an accent, scoped to a container rather
   than dumped on `:root`.
4. **The `style` prop.** Last. It wins over everything, which is why it is last:
   it also wins over the theme, over dark mode, and over the reader's
   preferences.

You own the source, so editing the file is always a fifth option. That is a
feature of registry distribution, not a failure of the first four, but it does
mean you own the upgrade.

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

### `className` and `cn` [#classname-and-cn]

Every part accepts `className`. It is merged with the component's own classes
using `cn()` from `lib/utils`, which is `clsx` for conditional logic and
`tailwind-merge` for conflict resolution:

```ts
import { cn } from "@/lib/utils"

cn("px-4 py-2", isWide && "px-8")
// → "py-2 px-8". tailwind-merge resolves the px conflict in your favour
```

Without `tailwind-merge` the two `px-*` utilities would both be present and the
winner would be whichever CSS rule the stylesheet happened to order last, which
is not something you want to depend on.

`className` on a **compound** component applies to the part you put it on, not to
the subtree. This is the most common surprise:

```tsx
// Applies to the root element only.
<ResultCard className="max-w-sm">
  {/* Applies to the value part only. */}
  <ResultCard.Value className="tabular-nums" />
</ResultCard>
```

### Data attributes [#data-attributes]

Every stateful part exposes its state as a `data-*` attribute, so you can style
a state without a class toggle, a JavaScript branch or a render prop:

```css
.my-pill[data-status="urgent"] { /* … */ }
.my-sheet[data-open] { /* … */ }
```

The full vocabulary is [Data attributes](./data-attributes.mdx), and it is
generated into [Reference](../reference/index.mdx) rather than listed by hand.

### CSS variables [#css-variables]

Where a component needs a value at runtime, such as a computed track width or an
accent that has to interpolate, it reads a CSS custom property. Override it on a
selector you own:

```css
.dashboard {
  --opsin-range-bar-track-height: 0.5rem;
}
```

Scope it. Setting a component variable on `:root` changes every instance in the
application, including the ones you have not seen.

### The `style` prop [#the-style-prop]

Available, unrestricted, and a last resort. It produces an inline declaration
that beats your stylesheet, the theme and the dark-mode block, which means it
also survives changes you wanted it not to survive.

## Do this [#do-this]

* **Fix it at the token layer when more than one component is affected.** One
  token change is auditable; fourteen `className` overrides are not.
* **Style state through `data-*`, not through a class you toggle.** The
  component already knows its state and has told the DOM; a parallel class is a
  second source of truth that will eventually disagree.
* **Scope CSS variables to a container.**
* **Use `cn()` for every merge**, including in your own wrappers, so utility
  conflicts resolve in the caller's favour consistently.
* **Put `className` last in your prop spread** if you are forwarding props, or
  the caller's value will be overwritten by the default.

## Not this [#not-this]

* **Do not use `!important` to win a specificity fight.** Under Tailwind v4 the
  layer order usually means you did not need to; if you genuinely did, the
  component's class list is wrong and that is a bug worth reporting.
* **Do not hardcode a colour.** `bg-[#e11d48]` bypasses the theme, both modes,
  the contrast floor and the two-axis rule in one keystroke. This is the
  invariant [`opsinjs/no-raw-color`](./tooling/eslint-plugin.mdx) exists to
  catch.
* **Do not style by descendant selector into a component's internals.**
  `.card > div > span` will break on any structural change and you will not find
  out until the layout collapses. Use the part's own `className` or its data
  attributes.
* **Do not use a category token to express status, or a status token to express
  identity.** This is the single most important rule in the system, it is not a
  styling preference, and it is explained in
  [The two colour axes](../health/two-colour-axes.mdx).
* **Do not reach for `style` because it was quicker.** It is quicker once and
  slower every time after.

## Gotchas [#gotchas]

* **`tailwind-merge` needs to know about your custom utilities.** If you have
  extended the theme with new scales, conflicting classes in those scales may
  not resolve as you expect until it is configured for them.
* **Class sorting silently no-ops without `tailwindStylesheet` in
  `.prettierrc`.** Under Tailwind v4 the Prettier plugin needs to be told where
  the stylesheet is, and without it your classes stay in whatever order they
  were typed with no error.
* **Order in `globals.css` decides who wins.** The lyra block, the opsinjs token
  layer and `@layer base` are ordered deliberately; moving an `@import` changes
  cascade outcomes without any diagnostic. See
  [Tailwind v4](../theming/tailwind-v4.mdx).
* **A CSS variable set on `:root` in a docs page leaks into previews.** Scope it,
  or the component preview two sections down will change too.
* **Arbitrary values disable the theme, not just the token.** `text-[13px]`
  opts that instance out of the type scale and out of Dynamic Type; see
  [Dynamic type](../foundations/typography/dynamic-type.mdx).

## Related [#related]

* [Data attributes](./data-attributes.mdx) is the state vocabulary you style
  against, and the one to read next.
* [Token architecture](../foundations/token-architecture.mdx) explains why the
  first hook is a token, and which tier you are allowed to touch.
* [Adding your own tokens](../theming/adding-your-own-tokens.mdx) covers
  extending the system in a way that survives an upgrade.
* [ESLint plugin](./tooling/eslint-plugin.mdx) has the rules that catch a raw
  colour and a mixed axis before review does.
