If you upgrade directly from v2 to v4, complete the v2-to-v3 steps first, and then complete the v3-to-v4 steps.
v4 modernizes the runtime without redesigning components. If your app is already on current tooling, the migration is one stylesheet import plus some import path updates — TypeScript walks you through the rest: every removed or moved API is kept in the types as a @deprecated tombstone whose message names its replacement.
Not ready to upgrade? v3 is maintained on the v3 branch: 3.x fixes land there and are published under the release-v3 npm dist-tag.
Work through the checklist top to bottom:
- Upgrade the runtime — Node.js 22.12+, React 19.2+, styled-components 6.1+, ESM-only
- Import the stylesheet —
import '@sanity/ui/styles.css' - Update imports for moved components
- Replace removed deprecated APIs
- Less common removals
- Behavior changes to review
- Update test assertions on closed tooltip and popover content — check visibility, not existence
- ESM-only. The CommonJS build (
.cjs/.d.cts) and therequireexport condition are removed — the package now ships ESM only. This is not a code change for consumers: modern Node.js supportsrequire(esm), sorequire('@sanity/ui')keeps working on the supported Node.js versions. - Node.js 22.12 or later (
enginesis>=22.12, matchingsanity). - React 19.2 or later — peer dependencies are
react: ^19.2andreact-dom: ^19.2. The 19.2 floor is set by the new<Activity>component, which shipped in React 19.2.0 and which v4 uses to keep closedTooltipandPopovercontent mounted. Components are compiled with the React Compiler targeting React 19, using React's built-in runtime. - styled-components 6.1 or later — it stays a peer dependency, now declared as
^6.1. v4.0.0 already required v6 — it imports the namedstyledexport and v6-only types such asExecutionContext, neither of which exist in v5 — but the peer range was left at^5.2 || ^6and the 4.0.0 release notes forgot to mention the dropped v5 support. The corrected range also excludes 6.0.x, which ships the Babel macro (and with it the entire@babel/*toolchain) as runtime dependencies until 6.1.0 removed it, and whose last release, 6.0.9, was published without its type declarations. - TypeScript: use an
exports-awaremoduleResolution(node16,nodenext, orbundler). v4 resolves everything through packageexportsand no longer shipstypesVersions, so the legacymoduleResolution: "node"(node10) mode cannot resolve the types for the subpath entry points.
npm install @sanity/ui@^4 react@^19.2 react-dom@^19.2 styled-components@^6.1Static styles (the ones that don't depend on theme or props) are extracted into a stylesheet at build time instead of being injected at runtime, and the stylesheet is not loaded automatically.
Add this import once, next to where the app renders <ThemeProvider>:
import '@sanity/ui/styles.css'Without it, SrOnly, Spinner and internal text-overflow styling render unstyled. These components have moved off styled-components — more components follow in future 4.x minors, and UI5 ships without styled-components or any other runtime CSS-in-JS.
The stylesheet must load before styled-components' <style data-styled> tag. It has no cascade layers, so when one of your styled-components overrides, such as styled(Button), has the same specificity as the static style it overrides, the rule that loads last wins. Importing the stylesheet before anything renders gives the right order; see Load order.
Components with heavy dependencies moved to dedicated subpath entry points, so importing @sanity/ui no longer pulls in @floating-ui/react-dom, motion or react-refractor — even without bundler treeshaking.
| Entry point | Exports |
|---|---|
@sanity/ui/toast |
Toast, ToastProvider, useToast |
@sanity/ui/popover |
Popover |
@sanity/ui/tooltip |
Tooltip, TooltipDelayGroupProvider, useTooltipDelayGroup |
@sanity/ui/menu |
Menu, MenuButton, MenuDivider, MenuGroup, MenuItem |
@sanity/ui/autocomplete |
Autocomplete |
@sanity/ui/breadcrumbs |
Breadcrumbs |
@sanity/ui/code |
Code |
Prop, context and message types moved along with their components (PopoverProps, ToastParams, MenuItemProps, AutocompleteState, TooltipDelayGroupContextValue, …).
-import {MenuButton, ToastProvider, useToast} from '@sanity/ui'
+import {MenuButton} from '@sanity/ui/menu'
+import {ToastProvider, useToast} from '@sanity/ui/toast'You don't need to hunt these down by hand: every moved symbol is still declared on the root entry as a @deprecated never tombstone, so TypeScript reports the new location at each usage instead of a bare "does not exist" error.
Everything that was deprecated during v3 is removed in v4. Removed props stay on the public types as @deprecated never (you get the migration message, not a mystery error), and removed hooks are still exported but throw when called.
| Removed | Use instead |
|---|---|
space prop |
gap |
Grid columns / rows / column* / row* props |
gridTemplateColumns / gridTemplateRows / gridColumn* / gridRow* |
Menu focusFirst / focusLast props |
shouldFocus |
| MenuButton top-level popover props | popover={{…}} |
Popover boundaryElement prop |
floatingBoundary / referenceBoundary (plus BoundaryElementProvider for max-width / constrainSize) |
Tooltip allowedAutoPlacements prop |
fallbackPlacements |
useClickOutside |
useClickOutsideEvent |
useElementRect |
useElementSize |
useForwardedRef |
useRef + useImperativeHandle |
useArrayProp(value) |
Array.isArray(value) ? value : [value] |
ConditionalWrapper |
inline the conditional wrapping logic |
@sanity/ui/_visual-editingis removed. Import from@sanity/uiinstead — except the moved components (Menu,MenuDivider,MenuGroup,MenuItem,Popover), which now live on@sanity/ui/menuand@sanity/ui/popover(see step 3). The slim private subset is no longer needed:displayNameside effects are gone, so unused components tree-shake out of consuming bundles.- The internal
_ResizeObserverexport is removed along with the@juggle/resize-observerpolyfill. Use the globalResizeObserver.
TooltipandPopoverkeep closed content mounted with React's<Activity>, preserving internal state and pre-rendering hidden content. Withanimateenabled, hiding is deferred until exit animations finish. Popovers with recursivecontentmust gate the recursion onopen— otherwise they render an infinitely deep hidden tree. Tests that assert on closed content need updating, see step 7.- All components are plain function components that take
refas a regular prop (React 19 semantics). Refs keep working exactly as before, but components no longer passreact-ischecks such asisForwardRef.
<Activity mode="hidden"> keeps closed content in the DOM and hides it with display: none. "Closed" therefore means "not visible", not "not in the document": assertions that check whether content exists have to check its visibility instead. This applies to unit tests and end-to-end tests alike.
End-to-end tests (Playwright):
-await expect(page.getByText('Tooltip content')).toHaveCount(0)
+await expect(page.getByText('Tooltip content')).toBeHidden()Unit tests (Testing Library and jest-dom):
-expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
+expect(screen.getByText('Tooltip content')).not.toBeVisible()Assertions on open content are unchanged, and so are queries that skip inaccessible elements: display: none keeps closed content out of the accessibility tree, so Testing Library's *ByRole queries and Playwright's getByRole() still don't match it (unless you opt in with hidden: true / includeHidden: true).
React renders hidden activities at a lower priority than visible ones, so closed content isn't guaranteed to be in the DOM yet — the most robust assertions accept both "absent" and "present but hidden". Playwright's toBeHidden() does that by definition. jest-dom's toBeVisible() requires an element, so query first and only assert on what you find:
function expectHidden(text: string) {
const element = screen.queryByText(text)
if (element) {
expect(element).not.toBeVisible()
}
}v3 updates the Node.js requirement and the syntax-highlighting dependencies. Most applications only need to update Node.js and @sanity/ui. Applications that use react-refractor or refractor directly must also update those imports.
Work through the checklist top to bottom:
v2 supports Node.js 14 or later. v3 supports these Node.js versions:
>=20.19 <22>=22.12
Node.js 22.0 through 22.11 is not supported.
Check the installed version:
node --versionUpgrade Node.js before you install v3 if the installed version is outside the supported ranges.
Install the latest v3 release:
npm install @sanity/ui@^3You do not need to change code that only uses the Code component from @sanity/ui. Its internal implementation already supports the updated syntax-highlighting dependencies.
v3 updates these dependencies:
refractor: 4.9.0 → 5.0.0react-refractor: 2.2.0 → 4.0.0
This update addresses CVE-2024-53382.
If your application imports react-refractor or refractor directly, make these changes:
- Use the named
Refractorexport instead of the default export. - Import languages from
refractor/<language>instead ofrefractor/lang/<language>. - Use the separate
registerLanguageandhasLanguagefunctions instead of methods onRefractor.
-import Refractor from 'react-refractor'
-import javascript from 'refractor/lang/javascript'
+import {Refractor, hasLanguage, registerLanguage} from 'react-refractor'
+import javascript from 'refractor/javascript'
-Refractor.registerLanguage(javascript)
-const registered = Refractor.hasLanguage('javascript')
+registerLanguage(javascript)
+const registered = hasLanguage('javascript')Complete example:
// v2
import Refractor from 'react-refractor'
import javascript from 'refractor/lang/javascript'
import json from 'refractor/lang/json'
Refractor.registerLanguage(javascript)
Refractor.registerLanguage(json)
function MyComponent() {
return <Refractor language="javascript" value="const x = 1" />
}// v3
import {Refractor, registerLanguage} from 'react-refractor'
import javascript from 'refractor/javascript'
import json from 'refractor/json'
registerLanguage(javascript)
registerLanguage(json)
function MyComponent() {
return <Refractor language="javascript" value="const x = 1" />
}Test each code path that uses syntax highlighting. Confirm that each registered language still works.
For more information: