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
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.
- Radix becomes Base UI, so
asChildbecomesrender. See Composition and render. variantbecomes two orthogonal props. shadcn's singlevariantfrequently carries both identity and severity; opsinjs splits them intocategoryandstatusand will not let you mix them. This is the change that requires thought rather than a codemod.- A health vocabulary appears.
destructivehas 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/ui | opsinjs | Note |
|---|---|---|
Card | Card | Close equivalent; opsinjs adds material ladder rungs |
Alert | AlertBanner | Takes a clinical status, not a variant |
Badge | StatusPill | Status only; a neutral label is Badge |
Dialog | Dialog | Base UI underneath instead of Radix |
Sheet | Sheet | Same role |
Skeleton | Skeleton | Same role |
Button | Button | The reference page; sets the anatomy |
Form + FormField | Field | Library-agnostic; see Forms |
Progress | Progress | Direct equivalent; a RangeBar is a different component |
Toast | Toast | See Alert escalation for when a status warrants one |
The complete list of all sixty components is in Components.
API translations
| shadcn/ui | opsinjs |
|---|---|
asChild + single child | render={<element />} |
variant="destructive" | status="urgent". Read Clinical status semantics first |
variant="secondary" for a category tint | category="sleep" |
data-state="open" | data-open |
data-state="closed" | data-closed |
CSS entry/exit via data-state | data-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.
| shadcn | opsinjs |
|---|---|
--background / --foreground | The neutral surface and ink roles |
--muted / --muted-foreground | The lower-emphasis neutral roles |
--border | The neutral line role |
--destructive | No 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
variantdeliberately. 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
destructivetourgentmechanically.destructiveis 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
variantandstatuson 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.csswholesale. The import order is load-bearing and different; see Tailwind v4. - Do not assume
data-statestill exists. Base UI splits it into separate presence attributes, and a selector for[data-state="open"]will silently match nothing.
Gotchas
asChildandrenderdiffer in the merge. Both merge props and chain handlers, butrendertakes 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
Badgehas no status semantics, so an existingBadgeused as a label is not aStatusPill. 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 addwrites, in their own commit, so a later--diffhas something to compare against. - Your custom
--radiuswill not produce the opsinjs shape. opsinjs derives a radius ladder and usescorner-shapewhere 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.
Related
- Composition and render has the
asChildtorendertranslation in detail. - The two colour axes explains why
variantsplits in two. - Components has the full roster of all sixty components.
- Registry covers installing both registries side by side.