opsinjs

Migrating from shadcn/ui

The token map, the component equivalences and the API translations. It ends with an honest list of what opsinjs has no equivalent for.

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

If you already use shadcn/ui, most of what you know transfers: the same registry mechanism, the same "you own the code" model, the same cn() helper, the same Tailwind. Three things change.

  1. Radix becomes Base UI, so asChild becomes render. See Composition and render.
  2. variant becomes two orthogonal props. shadcn's single variant frequently carries both identity and severity; opsinjs splits them into category and status and will not let you mix them. This is the change that requires thought rather than a codemod.
  3. A health vocabulary appears. destructive has no opsinjs equivalent, because nothing about a person's reading is destructive.

They can coexist. opsinjs is a shadcn-spec registry, so both can be installed in the same project and migrated component by component.

How it works

Component equivalences

shadcn/uiopsinjsNote
CardCardClose equivalent; opsinjs adds material ladder rungs
AlertAlertBannerTakes a clinical status, not a variant
BadgeStatusPillStatus only; a neutral label is Badge
DialogDialogBase UI underneath instead of Radix
SheetSheetSame role
SkeletonSkeletonSame role
ButtonButtonThe reference page; sets the anatomy
Form + FormFieldFieldLibrary-agnostic; see Forms
ProgressProgressDirect equivalent; a RangeBar is a different component
ToastToastSee Alert escalation for when a status warrants one

The complete list of all sixty components is in Components.

API translations

shadcn/uiopsinjs
asChild + single childrender={<element />}
variant="destructive"status="urgent". Read Clinical status semantics first
variant="secondary" for a category tintcategory="sleep"
data-state="open"data-open
data-state="closed"data-closed
CSS entry/exit via data-statedata-starting-style / data-ending-style

Token map

shadcn's semantic roles map onto opsinjs's neutral roles; the axis tokens have no shadcn counterpart because shadcn has no axes.

shadcnopsinjs
--background / --foregroundThe neutral surface and ink roles
--muted / --muted-foregroundThe lower-emphasis neutral roles
--borderThe neutral line role
--destructiveNo equivalent. Use --opsin-status-urgent-* only where a clinical status genuinely applies
No shadcn equivalent--opsin-status-<level>-*
No shadcn equivalent--opsin-category-<name>-*

The generated, authoritative list is in Reference → Tokens.

Do this

  • Migrate a screen at a time, not a component at a time. The value is in the axes being consistent across a surface; half-migrated screens are worse than either end state.
  • Start with the pages that show a health value, because that is where the difference is real. A settings page gains nothing from migrating.
  • Rewrite variant deliberately. For each use, decide whether it was identity or severity. If it was both, it was a bug and this is where you find out.
  • Keep both registries installed during the transition; namespaces prevent the collision.
  • Re-check contrast after the token swap. Your custom shadcn values will not have been measured against the opsinjs floor. See Validating your theme.

Not this

  • Do not map destructive to urgent mechanically. destructive is used for delete buttons, and a delete button is not a clinical status. It is one of the fastest ways to contaminate the status axis.
  • Do not keep both variant and status on your own wrappers. Two props meaning nearly the same thing is how the axes get mixed by the next person.
  • Do not run both theme providers. One only; see Dark mode.
  • Do not port your globals.css wholesale. The import order is load-bearing and different; see Tailwind v4.
  • Do not assume data-state still exists. Base UI splits it into separate presence attributes, and a selector for [data-state="open"] will silently match nothing.

Gotchas

  • asChild and render differ in the merge. Both merge props and chain handlers, but render takes an element rather than wrapping a child, so a mechanical find-and-replace produces markup that type-checks and renders one element too many.
  • Radix and Base UI can coexist, but two focus-management implementations on the same screen can fight. Migrate overlays as a group.
  • shadcn's Badge has no status semantics, so an existing Badge used as a label is not a StatusPill. Mapping it will attach a clinical meaning to a category label.
  • Nothing in a copied file says which system it came from. After migration you will have files from two, and only your own history tells them apart: commit the files shadcn add writes, in their own commit, so a later --diff has something to compare against.
  • Your custom --radius will not produce the opsinjs shape. opsinjs derives a radius ladder and uses corner-shape where supported; see Squircle.
  • The docs chrome you are reading is lyra, which is square and dense. The opsinjs product theme is not. Do not judge the product theme by this site; see Lyra and the docs chrome.

On this page