
Building Custom Input Components in Sanity
A custom input component in Sanity is a React component that replaces or wraps the form control for a field. You attach it with components: { input: MyInput } on a field or type definition. The component receives the field's value, an onChange function that accepts patches such as set(value) and unset() from sanity, the field's schemaType, and renderDefault, which renders the built-in input. You can wrap the default to add hints, counters or previews, or render your own control from scratch, and read other fields in the document with the useFormValue hook.
The standard inputs cover most fields, but some content is easier to edit with a purpose-built control: a swatch picker limited to brand colours, a meta description field that shows a search snippet, or a product picker that searches an external API. This guide covers the props an input receives, wrapping the default input, building a fully custom input, writing values with patches, reading other fields, loading data, object inputs, where to register inputs, and the mistakes to avoid.
The Props an Input Receives
Every input gets the same core props. The exact type depends on the field: StringInputProps, NumberInputProps, ObjectInputProps, ArrayOfObjectsInputProps and so on, all exported from sanity.
| Prop | What it is | Typical use |
|---|---|---|
value | The field's current value, or undefined | Render the current state |
onChange | Accepts a patch, an array of patches or a PatchEvent | Write changes |
schemaType | The field's schema definition, including options | Read config such as a list of values |
elementProps | id, ref, onFocus, onBlur, readOnly and more | Spread onto your main control |
readOnly | Whether the field is locked | Disable your control |
validation | Current validation markers for the field | Show errors inside the input |
path | The field's location in the document | Patch nested values, debugging |
renderDefault | Renders the built-in input for these props | Wrap instead of replace |
The input is only the control. The title, description, validation icons and presence avatars around it belong to the field component, which Studio renders for you. That's why a custom input usually needs no label of its own.
Wrap the Default Input
The simplest and most robust custom input adds something around the built-in one. Here's a character counter for a meta description:
// components/inputs/CharacterCountInput.tsx
import { Card, Stack, Text } from '@sanity/ui'
import type { StringInputProps } from 'sanity'
const MAX = 160
export function CharacterCountInput(props: StringInputProps) {
const length = props.value?.length ?? 0
const over = length > MAX
return (
<Stack space={2}>
{props.renderDefault(props)}
<Card padding={2} radius={2} tone={over ? 'critical' : 'transparent'}>
<Text size={1} muted={!over}>
{length} / {MAX} characters
</Text>
</Card>
</Stack>
)
}
Attach it to a field:
// schemaTypes/seo.ts
import { defineField } from 'sanity'
import { CharacterCountInput } from '../components/inputs/CharacterCountInput'
export const metaDescription = defineField({
name: 'metaDescription',
title: 'Meta description',
type: 'text',
rows: 3,
components: { input: CharacterCountInput },
validation: (rule) => rule.max(160).warning('Search engines usually cut off longer descriptions'),
})
Because renderDefault draws the real textarea, you keep everything Sanity provides: focus handling, presence, real-time collaboration, review changes and inline diffs. The validation rule still does the enforcing, while the counter gives feedback as the editor types. Rules themselves are covered in validation rules in Sanity.
The counter uses a Card tone rather than a hard-coded red, so the Studio theme decides how the warning looks in light and dark mode.
You Can Change the Props
renderDefault takes props, so you can pass modified ones. For example, to add a placeholder that depends on the document:
return props.renderDefault({
...props,
elementProps: { ...props.elementProps, placeholder: 'Summarise the page in one sentence' },
})
Build a Fully Custom Input
When the default control is the wrong shape, render your own. This swatch picker stores a brand colour name as a string, using the list from options:
// components/inputs/BrandColourInput.tsx
import { useCallback } from 'react'
import { Button, Flex, Text } from '@sanity/ui'
import { set, unset, type StringInputProps } from 'sanity'
type Swatch = { title: string; value: string; hex: string }
export function BrandColourInput(props: StringInputProps) {
const { value, onChange, readOnly, elementProps, schemaType } = props
const swatches = ((schemaType.options as { swatches?: Swatch[] })?.swatches ?? []) as Swatch[]
const select = useCallback(
(next: string) => onChange(next === value ? unset() : set(next)),
[onChange, value],
)
return (
<Flex gap={2} wrap="wrap" ref={elementProps.ref} id={elementProps.id}>
{swatches.map((swatch) => (
<Button
key={swatch.value}
mode={swatch.value === value ? 'default' : 'ghost'}
disabled={readOnly}
onClick={() => select(swatch.value)}
onFocus={elementProps.onFocus}
onBlur={elementProps.onBlur}
padding={2}
aria-pressed={swatch.value === value}
>
<Flex align="center" gap={2}>
<span style={{ width: 16, height: 16, borderRadius: 4, background: swatch.hex }} />
<Text size={1}>{swatch.title}</Text>
</Flex>
</Button>
))}
</Flex>
)
}
defineField({
name: 'accentColour',
type: 'string',
options: {
swatches: [
{ title: 'Ember', value: 'ember', hex: '#e4572e' },
{ title: 'Ocean', value: 'ocean', hex: '#2e7de4' },
{ title: 'Moss', value: 'moss', hex: '#4c9a2a' },
],
} as Record<string, unknown>,
components: { input: BrandColourInput },
})
Storing ember rather than #e4572e keeps the content meaningful: the front end decides what "ember" looks like, and you can change the hex later without a migration. For free colour picking, the colour input plugin in the best Sanity Studio plugins is a better fit.
Three details make a custom input behave like a native one:
- Pass
ref,id,onFocusandonBlurfromelementProps, so focus, presence and validation links work. - Respect
readOnly, which is true when the document is locked, the field is conditionally read-only, or the user lacks permission. - Use
@sanity/uifor controls, so the input matches the Studio and follows dark mode.
Write Values With Patches
onChange doesn't take a new value. It takes patches describing the change:
| Function | What it does | Example |
|---|---|---|
set(value, path?) | Sets the value | set('ember') |
unset(path?) | Removes the value | unset() |
setIfMissing(value, path?) | Sets only if empty | setIfMissing({}) on an object |
insert(items, position, path) | Adds array items | insert([item], 'after', [-1]) |
inc(amount, path?) / dec(amount, path?) | Changes a number | inc(1) |
All of them are imported from sanity. Without a path, a patch targets the field the input is attached to. With one, it targets a location inside it.
Use unset() for empty values rather than set(''). An empty string is still a value, so rule.required() passes and GROQ's defined() returns true, which is rarely what you want.
PatchEvent
Older inputs wrap patches in a PatchEvent:
import { PatchEvent, set, setIfMissing } from 'sanity'
onChange(PatchEvent.from([setIfMissing({ _type: 'dimensions' }), set(1200, ['width'])]))
This still works, and the prefixAll() method on a PatchEvent is useful when a parent input passes changes from a child. In new code, passing a patch or an array of patches directly is simpler: onChange([setIfMissing({}), set(1200, ['width'])]).
Read Other Fields With useFormValue
useFormValue(path) reads any value in the current document, including unsaved edits. Pass an empty array for the whole document. This search snippet preview reads the title and slug:
// components/inputs/SnippetPreviewInput.tsx
import { Card, Stack, Text } from '@sanity/ui'
import { useFormValue, type StringInputProps } from 'sanity'
export function SnippetPreviewInput(props: StringInputProps) {
const title = useFormValue(['title']) as string | undefined
const slug = useFormValue(['slug', 'current']) as string | undefined
return (
<Stack space={3}>
{props.renderDefault(props)}
<Card padding={3} radius={2} border>
<Stack space={2}>
<Text size={1} muted>
example.com/blog/{slug ?? 'your-slug'}
</Text>
<Text weight="semibold">{title || 'Untitled'}</Text>
<Text size={1} muted>
{props.value || 'Add a description to see how it might look in search results.'}
</Text>
</Stack>
</Card>
</Stack>
)
}
The input re-renders whenever those fields change, so the preview updates as the editor types in another field. useFormValue is read-only, and an input's patches apply to its own field. If two values must change together, model them as one object field with a single input.
If you only need to show or hide fields based on others, hidden and readOnly callbacks are simpler than a custom input. See conditional fields in Sanity.
Load Data in an Input
Inputs are React components, so they can fetch. useClient gives you a configured client for the current workspace. This input, for a string field such as a primary tag, suggests tags already used by posts in the same category:
// components/inputs/TagSuggestionInput.tsx
import { useEffect, useState } from 'react'
import { Button, Flex, Stack, Text } from '@sanity/ui'
import { set, useClient, useFormValue, type StringInputProps } from 'sanity'
export function TagSuggestionInput(props: StringInputProps) {
const client = useClient({ apiVersion: '2025-02-19' })
const categoryId = useFormValue(['category', '_ref']) as string | undefined
const [tags, setTags] = useState<string[]>([])
useEffect(() => {
if (!categoryId) return
const controller = new AbortController()
client
.fetch<string[]>(
`array::unique(*[_type == "post" && category._ref == $categoryId].tags[])[0...8]`,
{ categoryId },
{ signal: controller.signal },
)
.then(setTags)
.catch(() => {})
return () => controller.abort()
}, [client, categoryId])
return (
<Stack space={3}>
{props.renderDefault(props)}
{tags.length > 0 && (
<Flex gap={2} wrap="wrap" align="center">
<Text size={1} muted>
Used in this category:
</Text>
{tags.map((tag) => (
<Button key={tag} text={tag} mode="ghost" fontSize={1} padding={2} onClick={() => props.onChange(set(tag))} />
))}
</Flex>
)}
</Stack>
)
}
Always clean up requests when dependencies change, and keep queries small: the input mounts every time the document opens. For an external API, call your own endpoint rather than putting secret keys in the Studio bundle, since everything in the Studio runs in the browser.
Object Inputs
For object fields, renderDefault draws every member field, so you can add a summary above them without rebuilding the form. When you do build the controls yourself, patch individual keys by path, such as set(1200, ['width']), rather than replacing the whole object. That keeps simultaneous edits from overwriting each other, which is explained in real-time collaboration in Sanity.
Begin with setIfMissing when the object might not exist yet:
onChange([setIfMissing({ _type: 'dimensions' }), set(Number(next), ['width'])])
Where to Register an Input
Components can be attached at three levels:
// 1. On a single field
defineField({ name: 'metaDescription', type: 'text', components: { input: CharacterCountInput } })
// 2. On a type, so every field of that type uses it
export const brandColour = defineType({
name: 'brandColour',
type: 'string',
components: { input: BrandColourInput },
})
// 3. Globally, in sanity.config.ts or a plugin
export default defineConfig({
// ...
form: {
components: {
input: (props) =>
props.schemaType.name === 'url'
? <UrlCheckInput {...(props as StringInputProps)} />
: props.renderDefault(props),
},
},
})
A reusable type is usually the best home: give it a name such as brandColour and use type: 'brandColour' wherever you need it, as described in reusable object types. Global components are powerful but run for every input in the Studio, so always fall through to props.renderDefault(props) for anything you don't handle. Global inputs are a good fit for a plugin, which creating a Sanity plugin walks through.
The same components key also accepts field, item and preview. Use field to change the label and description area, and item to change how a value looks inside an array.
Common Mistakes
- Storing UI state in the document: anything only the input needs, such as an open panel, belongs in React state.
- Ignoring
readOnly: an input that still accepts clicks on a locked document confuses editors. - Replacing when you could wrap: a full rewrite loses inline diffs, paste handling and future improvements. Start with
renderDefault. - Writing empty strings: use
unset()so the field is truly empty. - Fetching on every keystroke: depend on the specific values you need, not the whole document, and debounce search requests.
FAQ: Custom Input Components in Sanity
It's a function passed to every custom input that renders the built-in input for the given props. Calling props.renderDefault(props) lets you wrap the default control with extra UI instead of rebuilding it.
Call props.onChange with a patch from sanity, such as set(value) to write it or unset() to remove it. You can pass a single patch, an array of patches or a PatchEvent.
Use the useFormValue hook with the field's path, for example useFormValue(['title']). It returns the current value, including unsaved edits, and re-renders when it changes.
Not directly. An input's patches apply to its own field. Model related values as one object field with a single input, or use a document action to update several fields together.
Yes, as long as you write changes as patches through onChange. Patching the smallest path, such as one key of an object, keeps concurrent edits from overwriting each other.
If a well-maintained plugin solves the problem, use it. Write your own input when the control is specific to your content, such as brand swatches or a picker for your own API.
Conclusion
Custom input components let you shape the Studio around your content. Start by wrapping the default input with renderDefault to add counters, previews and suggestions without losing anything Sanity provides. When the control itself needs to change, render your own with Sanity UI, spread elementProps, respect readOnly and write values with set and unset.
Use useFormValue to react to other fields, useClient to load data, and path-based patches for objects. Register inputs on reusable types where you can, and pair them with a custom desk structure so editors find the right documents and get the right controls when they open them.


