opsinjs
HandbookWorking with components

Styling

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.

The short version

NOT IMPLEMENTED. This component does not exist in any released version of opsinjs. There is no package to install, no module to import and no props interface to generate code against. Everything on this page is a specification of intended behaviour and may change without notice. Do not write code against it.

This component is not built yet

There is nothing to render because there is nothing to install. What you can read on this page is the specification the implementation will have to satisfy.

PlannedRoadmapWhat “planned” means

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

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:

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:

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

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:

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

The full vocabulary is Data attributes, and it is generated into Reference rather than listed by hand.

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:

.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

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

  • 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

  • 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 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.
  • Do not reach for style because it was quicker. It is quicker once and slower every time after.

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.
  • 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.
  • Data attributes is the state vocabulary you style against, and the one to read next.
  • Token architecture explains why the first hook is a token, and which tier you are allowed to touch.
  • Adding your own tokens covers extending the system in a way that survives an upgrade.
  • ESLint plugin has the rules that catch a raw colour and a mixed axis before review does.

On this page