How I build a Sanity Studio custom input component with a colour picker

Jun 11, 2026 · 6 min read

Sanity Studio v3 lets you replace any field's default input with your own React component. The sanity studio custom input component API is clean once you understand three moving parts: definePlugin for registration, useFormValue for reading sibling field values, and PatchEvent for writing changes back to the document. Below I'll walk through a real example — a hex colour picker with a live swatch preview — that I use on branding documents for agency clients.

Why write a custom input at all

Sanity's built-in string field renders a plain text box. For a hex colour value that works fine until an editor types red instead of #e53e3e and breaks a downstream CSS variable. A custom input lets you swap the text box for a native <input type="color">, show the resolved colour as a swatch next to the field label, and validate the hex format before the patch ever fires. You own the UX completely.

Setting up the plugin with definePlugin

Custom inputs ship as Studio plugins. The plugin wrapper keeps your component isolated from the schema definition and makes it reusable across projects without copy-pasting.

Create plugins/colour-input/index.ts:

// plugins/colour-input/index.ts
import { definePlugin } from 'sanity'
import { ColourInput } from './ColourInput'
 
export const colourInputPlugin = definePlugin({
  name: 'colour-input',
  form: {
    components: {
      // nothing global here — we'll wire per-field in the schema
    },
  },
})
 
export { ColourInput }

Then register it in sanity.config.ts:

// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from '@sanity/structure'
import { colourInputPlugin } from './plugins/colour-input'
 
export default defineConfig({
  projectId: process.env.SANITY_STUDIO_PROJECT_ID!,
  dataset: 'production',
  plugins: [structureTool(), colourInputPlugin()],
  schema: { types: [] }, // your types here
})

The plugin currently does nothing global — we wire the component directly on the field using components.input. That approach is more explicit and avoids accidentally replacing every string field.

Building the ColourInput component

Sanity passes a standardised StringInputProps to any custom string input. The two properties you'll use most are value (the current string) and onChange (a function that accepts a PatchEvent). useFormValue lets you reach into sibling or parent fields — useful here if you want to read a colourLabel field and show it beside the swatch.

// plugins/colour-input/ColourInput.tsx
import React, { useCallback } from 'react'
import { set, unset, PatchEvent } from 'sanity'
import { StringInputProps, useFormValue } from 'sanity'
import { Stack, Flex, Text, TextInput, Box } from '@sanity/ui'
 
export function ColourInput(props: StringInputProps) {
  const { value = '', onChange, readOnly, elementProps } = props
 
  // Read a sibling field called "colourLabel" for the preview line.
  // Path is relative to the document root — adjust for nested objects.
  const label = useFormValue(['colourLabel']) as string | undefined
 
  const handleNativePicker = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const hex = e.currentTarget.value
      onChange(PatchEvent.from(hex ? set(hex) : unset()))
    },
    [onChange],
  )
 
  const handleTextChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const raw = e.currentTarget.value.trim()
      // Only patch when the value looks like a full hex colour.
      if (raw === '' ) {
        onChange(PatchEvent.from(unset()))
      } else if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(raw)) {
        onChange(PatchEvent.from(set(raw)))
      }
      // While typing partial values we don't patch — no destructive intermediate state.
    },
    [onChange],
  )
 
  const isValidHex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value)
 
  return (
    <Stack space={3}>
      <Flex align="center" gap={3}>
        {/* Native colour wheel — syncs to the text input */}
        <input
          type="color"
          value={isValidHex ? value : '#000000'}
          onChange={handleNativePicker}
          disabled={readOnly}
          style={{ width: 40, height: 40, border: 'none', background: 'none', cursor: readOnly ? 'not-allowed' : 'pointer' }}
        />
        {/* Hex text box — the real stored value */}
        <TextInput
          {...elementProps}
          value={value}
          onChange={handleTextChange}
          placeholder="#3b82f6"
          readOnly={readOnly}
          style={{ fontFamily: 'monospace' }}
        />
      </Flex>
 
      {/* Live swatch preview */}
      {isValidHex && (
        <Flex align="center" gap={2}>
          <Box
            style={{
              width: 24,
              height: 24,
              borderRadius: 4,
              background: value,
              border: '1px solid var(--card-border-color)',
            }}
          />
          <Text size={1} muted>
            {label ? `${label} — ` : ''}{value}
          </Text>
        </Flex>
      )}
    </Stack>
  )
}

A few decisions worth noting:

  • elementProps spread: Sanity injects focus, blur, and id attributes through elementProps. Spread them onto the primary interactive element so Studio's focus-tracking works correctly. If you omit this, the field outline doesn't highlight when the document has validation errors.
  • Partial typing guard: I only fire set when the hex is complete. Patching on every keystroke would create a flood of mutations and leave invalid 3-character hex strings in the document.
  • unset vs set(''): Always use unset() to clear a field. An empty string is a valid value in Sanity's type system and will pass through validation differently.

Wiring the component to a schema field

Back in your schema, add components: { input: ColourInput } to any string field:

// schemas/brandKit.ts
import { defineType, defineField } from 'sanity'
import { ColourInput } from '../plugins/colour-input'
 
export const brandKitType = defineType({
  name: 'brandKit',
  title: 'Brand kit',
  type: 'document',
  fields: [
    defineField({
      name: 'colourLabel',
      title: 'Colour label',
      type: 'string',
    }),
    defineField({
      name: 'primaryColour',
      title: 'Primary colour',
      type: 'string',
      components: { input: ColourInput },
      validation: (Rule) =>
        Rule.regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/).warning(
          'Must be a 3 or 6-digit hex colour',
        ),
    }),
  ],
})

The validation rule is a belt-and-braces fallback. The component already filters bad input, but if a document is imported via the API with a bad value the Studio will show the warning inline.

Using useFormValue for context-aware inputs

useFormValue takes a path array relative to the document root. In a flat document that's just ['fieldName']. Inside an array item you'd pass ['colours', { _key: currentKey }, 'hex'] — but for simple sibling reads the flat path is all you need. The hook is read-only; you can't use it to write to other fields. For cross-field writes you'd need a custom onChange at the object level, which is a separate pattern.

One gotcha: useFormValue only works inside Studio form components. Don't try to call it outside the Studio render tree (e.g., in a structure builder callback).

Testing in development

Run npx sanity dev and open the brandKit document. You should see the colour wheel and text input side by side. Pick a colour from the wheel and watch the text input update. Type a partial hex like #3b8 — no patch fires. Complete it to #3b82f6 and the swatch appears. Clear the field and useFormValue keeps the label text ready for the next value.

If the swatch doesn't appear, open the Studio's network tab and confirm the patch event shape — you're looking for {type: 'mutation', patches: [{set: {primaryColour: '#3b82f6'}}]}. If you see empty string patches, the unset guard isn't firing correctly.

What to build next

This same pattern extends to any field type. An ObjectInputProps custom input can render a full map picker and store {lat, lng} in an object field. A NumberInputProps input can render a slider for opacity values from 0–100. The PatchEvent API is the same in all cases — set writes a value, unset clears it, and setIfMissing initialises a nested path without overwriting existing data. Once you have definePlugin wired up, adding a second custom input is just a new component file.

Related posts

All posts →