Custom overlay components and controls
Visual editing overlays can be extended with custom React components for direct in-app content editing and displaying content metadata.
Experimental feature
This article describes an experimental Sanity feature. The APIs described are subject to change and the documentation may not be fully complete.
Custom overlay components let you extend the functionality of visual editing overlays with custom React components. Such components can greatly enhance the editing experience by enabling direct, in-app, content editing and displaying rich metadata or controls to content editors.
With custom overlays, you can:
- Add interactive controls such as color pickers or sliders to configure complex objects, for example 3D models.
- Display additional context, such as related product data from external systems.
You can also customize the Presentation Tool's preview header, giving you the flexibility to toggle custom overlays, or add controls, status indicators, or other UI elements that enhance the editor experience.
Prerequisites
Before getting started, ensure the following:
- Visual Editing enabled with up-to-date dependencies in your front end
@sanity/visual-editing2.15.0 or later, which covers every API this article uses- Sanity Studio v3.65.0 or later (
npm install sanity@latest)
Gotcha
Custom overlay components currently only support React.
Custom overlay plugins
Overlay plugins will let you mount custom React components in the visual editing overlay, enabling complex views and controls for interacting with your content:
'use client'
import {defineOverlayPlugin} from '@sanity/visual-editing/unstable_overlay-components'
export const MyFirstPlugin = defineOverlayPlugin(() => ({
type: 'hud',
name: 'my-first-plugin',
title: 'My First Plugin!',
component: function MyFirstPluginComponent() {
return <div>Hello World</div>
},
}))For all available configuration properties, see OverlayPluginDefinition in the generated reference.
Gotcha
Custom overlay plugins, components, and resolvers should be rendered client-side, commonly done with the "use client" directive for React.
Use custom overlay plugins
Depending on your framework and implementation, pass an array of instantiated plugins through the plugins property of the object passed to the enableVisualEditing function, or the plugins prop of the <VisualEditing> component. For example:
import {VisualEditing} from 'next-sanity/visual-editing'
import {draftMode} from 'next/headers'
import {plugins} from './overlay-plugins'
// minimal Next.js-like example
export default async function RootLayout({children}: {children: React.ReactNode}) {
return (
<html>
<body>
<main>{children}</main>
{(await draftMode()).isEnabled && (
<VisualEditing plugins={plugins} />
)}
</body>
</html>
)
}'use client'
import type {OverlayPluginDefinition} from '@sanity/visual-editing/react'
import {MyFirstPlugin} from './my-first-plugin'
export const plugins: OverlayPluginDefinition[] = [
MyFirstPlugin()
]Overlay plugin types
There are two different types of overlay plugins which define how the plugin behaves in the visual editing overlay:
HUD
Plugins with the hud type can be used to mount a component under the overlay element when hovered.
Exclusive
Plugins with the exclusive type can be used to take exclusive control of the overlay experience. The type of plugin is listed in an overlay element menu. When selected by the user, the component is rendered inside the overlay element and all other overlay UI is hidden until closed.
User-configurable options
Plugins support user-configurable options enabling them to be used multiple times per project with different configurations or shared between multiple projects:
'use client'
import {defineOverlayPlugin} from '@sanity/visual-editing/unstable_overlay-components'
type MyConfigurablePluginOptions = {
displayText?: string
}
export const MyConfigurablePlugin = defineOverlayPlugin<MyConfigurablePluginOptions>(
({displayText = 'Default Text'}) => ({
type: 'hud',
name: 'my-configurable-plugin',
title: 'My Configurable Plugin!',
component: function MyConfigurablePluginComponent() {
return <div>{displayText}</div>
},
}),
)'use client'
import type {OverlayPluginDefinition} from '@sanity/visual-editing/react'
import {MyConfigurablePlugin} from './my-configurable-plugin'
export const plugins: OverlayPluginDefinition[] = [
MyConfigurablePlugin({
options: {
displayText: 'Hello World!'
}
})
]Guarded overlay plugins
The guard conditional function can be used to filter which overlay element the plugin should be displayed on.
When using defineOverlayPlugin, guards can be configured at both the plugin level and the user level. Both must return true for the plugin to be shown, and if no guard function is provided it will pass (as if true):
'use client'
import {defineOverlayPlugin} from '@sanity/visual-editing/unstable_overlay-components'
export const MyGuardedPlugin = defineOverlayPlugin(() => ({
type: 'hud',
name: 'my-guarded-plugin',
title: 'My Guarded Plugin!',
component: function MyGuardedPluginComponent() {
return <div>Hello World</div>
},
guard: ({type}) => {
// This plugin only supports string fields.
return type === 'string'
},
}))'use client'
import type {OverlayPluginDefinition} from '@sanity/visual-editing/react'
import {MyGuardedPlugin} from './my-guarded-plugin'
export const plugins: OverlayPluginDefinition[] = [
MyGuardedPlugin({
// This plugin instance should only show on title nodes
guard: ({node}) => node.path.endsWith('title'),
})
]Update document data
Custom overlay plugins enable powerful editing capabilities directly in your application, from basic string manipulation to advanced controls for complex content types.
Protip
Custom overlay plugins will automatically use the logged-in user's authentication to update content. This means that any permissions that the user has will still be respected.
Install the @sanity/mutate package in your front-end project to create the necessary patches for updating data. Refer to that package’s documentation for available methods. The example below also uses @sanity/util/paths to read the current value from a document snapshot, which comes from the @sanity/util package.
npm install @sanity/mutate @sanity/util
pnpm add @sanity/mutate @sanity/util
yarn add @sanity/mutate @sanity/util
bun add @sanity/mutate @sanity/util
This example mounts a button in an overlay that appends an exclamation mark to the end of a string value when clicked:
'use client'
import {at, set} from '@sanity/mutate'
import {get} from '@sanity/util/paths'
import {useDocuments} from '@sanity/visual-editing/react'
import {defineOverlayPlugin} from '@sanity/visual-editing/unstable_overlay-components'
export const ExcitingStringControlPlugin = defineOverlayPlugin(() => ({
type: 'hud',
name: 'exciting-string-control',
title: 'Exciting String Control',
component: function ExcitingStringControlComponent(props) {
const {node} = props
// Get the document ID and field path from the Sanity node.
const {id, path} = node
const {getDocument} = useDocuments()
// Get the optimistic document using the document ID.
const doc = getDocument(id)
const onChange = () => {
doc.patch(async ({getSnapshot}) => {
const snapshot = await getSnapshot()
// Get the current value using the document snapshot and the field path.
const currentValue = get<string>(snapshot, path)
// Append "!" to the string.
const newValue = `${currentValue}!`
// Use `@sanity/mutate` functions to create the document patches.
return [at(path, set(newValue))]
})
}
return (
<button
// Tailwind CSS classes
className="rounded bg-blue-500 px-2 py-1 text-sm text-white"
onClick={onChange}
>
Click Me 🎉
</button>
)
},
guard: (context) => {
// This plugin only supports string fields.
return context.type === 'string'
},
}))Custom preview header state
The visual editing package exports a named useSharedState hook. Given the unique key defined in a custom preview header, it returns the value shared by the corresponding useSharedState Presentation Tool hook.
This HUD plugin displays only if the showMyPlugin shared state value is set to true from a custom preview header:
'use client'
import {useSharedState} from '@sanity/visual-editing'
import {defineOverlayPlugin} from '@sanity/visual-editing/unstable_overlay-components'
export const MyTogglePlugin = defineOverlayPlugin(() => ({
type: 'hud',
name: 'my-toggle-plugin',
title: 'My Toggle Plugin!',
component: function MyTogglePluginComponent() {
const showMyPlugin = useSharedState<boolean>('showMyPlugin')
if (!showMyPlugin) {
return null
}
return <div>Hello World</div>
},
}))Close exclusive plugins
By default, exclusive plugins can be closed by clicking outside of the overlay element area. However, the closeExclusiveView function can be used to programmatically close the plugin:
'use client'
import {defineOverlayPlugin} from '@sanity/visual-editing/unstable_overlay-components'
export const CloseableExclusivePlugin = defineOverlayPlugin(() => ({
type: 'exclusive',
name: 'closeable-exclusive',
title: 'Closeable Exclusive',
component: function CloseableExclusiveComponent({closeExclusiveView}) {
return (
<button
// Tailwind CSS classes
className="absolute right-0 top-0 rounded bg-blue-500 px-2 py-1 text-sm text-white"
onClick={() => closeExclusiveView()}
>
Close Me
</button>
)
},
}))Custom component resolver
While the plugin system is helpful at managing the position and visibility of components, the custom component resolver can be used for cases where more control over rendering is required.
Resolvers determine which custom components to mount for specific overlays. Use the defineOverlayComponents helper to conditionally resolve components based on overlay context.
This function runs each time an overlay renders, and the context object it receives can be used to determine which components to return.
Resolver functions can return:
- JSX elements.
- React component(s), single or array.
- Object(s) with
componentandpropsvalues. Use thedefineOverlayComponentfor convenience and type safety, single or array. undefinedorvoidwhen no custom components should be mounted.
When using the custom component resolver, the PointerEvents component must be used if interaction is required, this is available through the overlay component props:
'use client'
import {type OverlayComponent} from '@sanity/visual-editing'
export const FieldNameOverlay: OverlayComponent = ({field, PointerEvents}) => (
<PointerEvents>
<div
// Tailwind CSS classes
className="absolute bottom-0 left-0 m-1 rounded bg-black bg-opacity-50 px-2 py-1 text-xs text-white"
>
{field?.name}
</div>
</PointerEvents>
)This example resolves different custom overlay components conditionally:
'use client'
import {
defineOverlayComponent,
defineOverlayComponents,
} from '@sanity/visual-editing/unstable_overlay-components'
import {
HighlightOverlay,
TitleControl,
UnionControl,
UnionTypeMarker,
} from './overlay-components'
export const components = defineOverlayComponents((context) => {
const {document, element, field, type, parent} = context
// Mount a component in overlays attached to string
// fields named 'title'
if (type === 'string' && field?.name === 'title') {
return TitleControl
}
// Return JSX directly
if (type === 'string' && field?.name === 'subtitle') {
return <div>Subtitle</div>
}
// Mount a component in overlays attached to any element
// corresponding to a 'product' document
if (document.name === 'product') {
const color = element.dataset.highlightColor || 'red'
return defineOverlayComponent(HighlightOverlay, {color})
}
// Mount multiple components in overlays attached to any
// member element of a union type
if (parent?.type === 'union') {
return [UnionTypeMarker, defineOverlayComponent(UnionControl, {direction: 'vertical'})]
}
return undefined
})Depending on your framework and implementation, pass the resolver function through the components property of the object passed to the enableVisualEditing function, or the components prop of the <VisualEditing> component. For example:
import {VisualEditing} from 'next-sanity/visual-editing'
import {draftMode} from 'next/headers'
import {components} from './component-resolver'
// minimal Next.js-like example
export default async function RootLayout({children}: {children: React.ReactNode}) {
return (
<html>
<body>
<main>{children}</main>
{(await draftMode()).isEnabled && (
<VisualEditing components={components} />
)}
</body>
</html>
)
}Reference
Full type signatures for @sanity/visual-editing live in the generated reference, including defineOverlayPlugin, defineOverlayComponents, OverlayPluginDefinition, OverlayComponentResolverContext, and useSharedState. This section covers only what the generated reference doesn't express.
useDocuments
The generated reference types getDocument and mutateDocument through internal aliases that it doesn't publish, so their parameters and the methods on the document getDocument returns are listed here.
getDocument(documentId): { id, get, getSnapshot, patch, commit }
Returns an optimistic document interface with the following methods:
id: string: The document ID.get: (path?: string): SanityDocument | PathValue: Returns the document snapshot or the specific value at the given path. Deprecated — usegetSnapshotinstead.getSnapshot: () => Promise<SanityDocument | null>: Resolves with the current document snapshot, ornullwhen no snapshot is available. Use it instead of the deprecatedget.patch: (patches: OptimisticDocumentPatches, options?: {commit?: boolean | {debounce: number}}) => void: Applies patches to the document. Commits patches by default.commit: () => void: Commits pending changes.
Parameters
documentId
string
The ID of the document to get.
mutateDocument(documentId, mutations, options): void
Parameters
documentId
string
- The ID of the document to mutate.
mutations
Mutation[]
The mutations to apply to the document.
options
{commit?: boolean | {debounce: number}}
Optional commit options.
Troubleshooting
The useDocuments hook throws rather than returning an error state, so a failure surfaces as an uncaught error in your component. The hook body never throws. The errors come from getDocument and mutateDocument, and from the methods getDocument returns.
Hook used outside a Presentation preview
getDocument and mutateDocument throw The `useDocuments` hook cannot be used in this context when the page isn't running inside a Presentation preview iframe or pop-up window. The same error fires before Presentation's optimistic actor is set. Rendering an overlay component in a plain browser tab always triggers it.
To avoid it, guard on useIsPresentationTool and render a fallback until it returns true. It returns null until VisualEditing renders, and can return false before eventually returning true, so treat any other value as not ready.
Document not tracked by the overlay
getDocument throws Document "DOCUMENT_ID" not found when the requested ID isn't among the documents the overlay currently tracks. The overlay tracks the IDs of overlay elements it detects on the page, so an ID with no rendered element is not found. A document existing in your dataset isn't enough.
Pass an ID that belongs to an element rendered on the current page. This error also fires before the overlay's tracking effect has run, so don't call getDocument during the first render.
Snapshot not available yet
Two paths throw Snapshot for document "DOCUMENT_ID" not found, and they mean different things. The deprecated snapshot property throws on access when the document data hasn't synced yet. patch rejects with the same message when the document doesn't exist at all.
Use getSnapshot instead of snapshot. It waits for the document to sync and resolves null rather than throwing, which turns a timing problem into a null check. Catch the rejection from patch for the case where the document is genuinely absent.