opsinjs
FoundationsTheming & tokens

CSS variables

Consuming opsinjs tokens as raw custom properties, with no Tailwind, no build step and no React.

Overview

Everything opsinjs styles is driven by CSS custom properties. Tailwind is how this repository consumes them; it is not how they are defined. That means a project using vanilla CSS, CSS Modules, Sass, Lightning CSS or a CSS-in-JS runtime can use the token system without adopting Tailwind at all, and without giving up dark mode, the Display-P3 escalation or the reduced-motion degradation. All three are expressed as plain CSS.

What you give up is the utility classes. That is the whole difference.

Get the variables

Three ways, in increasing order of how much of opsinjs you are taking on.

Copy the generated file. app/tokens.generated.css is a single stylesheet of custom-property declarations under :root, .dark, and the three preference media queries. It has no imports and no Tailwind syntax in it. Vendor it, import it first, and you have the whole ramp system.

Fetch a theme registry item. /r/themes/opsinjs-default.json returns the role tokens as JSON under cssVars.light and cssVars.dark, which is the convenient form if you are generating CSS from a build script or feeding a CSS-in-JS theme object. See registry-item.json.

Install with the CLI. npx shadcn@latest add @opsinjs/opsinjs-default writes the block into whichever stylesheet your components.json names. This works even in a project that has no Tailwind, because the item contains only cssVars.

The naming convention

Two conventions coexist, on purpose, and knowing which is which tells you whether you are allowed to set a value.

--<role>                          tier 2 roles you set
--<role>-foreground               tier 2 text that sits on that role

--opsin-<axis>-<name>-<slot>      tier 1 values you read
--opsin-<system>-<step>-<slot>    tier 1 values you read

Concretely:

PatternExampleMeaning
--background / --foregroundNo single exampleThe page surface and its default text
--card / --card-foregroundNo single exampleAn elevated surface and its text
--muted / --muted-foregroundNo single exampleA quiet surface and lower-emphasis text
--border, --input, --ringNo single exampleSeparators, control edges, focus rings
--radius, --spacingNo single exampleThe one value each derived scale is built from
--opsin-status-<level>-<slot>--opsin-status-watch-inkStatus ramp; slots are surface, line, ink
--opsin-category-<name>-<slot>--opsin-category-sleep-accentCategory ramp; slots are accent, surface, ink
--opsin-material-<rung>-<slot>--opsin-material-3-blurMaterial ladder; slots are bg, blur, border, shadow
--opsin-duration-<speed>--opsin-duration-baseMotion durations
--opsin-ease-<curve>--opsin-ease-springEasings, including two linear() springs
--opsin-radius-<step>--opsin-radius-lgThe product shape scale

The complete generated list, with what each controls and what reads it, is Reference → CSS variables. The theme-author's subset is Token reference.

Use them

Nothing special is required. They are custom properties.

card.css
.card {
  background: var(--card);
  color: var(--card-foreground);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  transition: transform var(--opsin-duration-fast) var(--opsin-ease-standard);
}

.card[data-status="watch"] {
  background: var(--opsin-status-watch-surface);
  border-color: var(--opsin-status-watch-line);
  color: var(--opsin-status-watch-ink);
}

Note what the second rule does not do: it does not also apply a category colour. One axis per element. The data-status attribute is the same contract the components publish, documented per component in a <DataAttributesTable>, so styling against it keeps working alongside them.

Keep the degradations

If you vendor the variables, vendor the media queries with them. Three behaviours live entirely in CSS and are lost if you copy only the :root block:

@media (prefers-reduced-transparency: reduce) {
  :root {
    --opsin-material-3-bg: var(--card);
    --opsin-material-3-blur: 0px;
  }
}

@media (prefers-reduced-motion: reduce) {
  :root {
    --opsin-duration-base: 1ms;
    --opsin-ease-spring: linear(0, 1);
  }
}

@supports (color-gamut: p3) {
  :root { /* higher chroma, identical lightness */ }
  .dark { /* repeat every class selector. See below */ }
}

The repetition in the @supports block is not redundancy. :root and .dark have the same specificity, so a bare :root escalation placed after .dark wins in dark mode as well and leaks light-theme chroma into it. Every escalated selector is restated in the same cascade order as the base declarations.

Using them from JavaScript

Read them, do not duplicate them.

const styles = getComputedStyle(document.documentElement)
const urgent = styles.getPropertyValue("--opsin-status-urgent-line").trim()

For a CSS-in-JS theme object, reference the variable rather than resolving it: { background: "var(--card)" } keeps dark mode, P3 and the preference queries working. { background: urgent } snapshots one value at one moment in one theme and is the most common way a themed app stops responding to a theme.

Verify it worked

The variables are defined

getComputedStyle(document.documentElement).getPropertyValue("--card") returns a value. An empty string means the stylesheet is not loaded or is loaded after the element you are inspecting.

Dark mode still swaps

Toggle the dark class on <html> and re-read the same property. If it does not change, you copied :root without .dark.

The generated layer is the real one

--opsin-tokens-generated should hold a source hash. placeholder means you have vendored the committed fallback rather than generated output.

Troubleshooting

A variable resolves to nothing and the rule disappears. An undefined custom property makes the whole declaration invalid at computed-value time, which is silent. Give critical properties a fallback: var(--card, #fff).

Values are right but transitions do not run. You are on a machine with reduced motion, and every duration is 1ms by design. That is the degradation working.

Colours are more saturated on one machine. Display-P3. Lightness is identical by construction, so contrast has not changed.

A status colour looks wrong on a translucent surface. Status surfaces are tuned against the page background. See The contrast floor.

Next

On this page