Sanity logosanity.ioAll Systems Operational© Sanity 2026
Change Site Theme
Sanity logo

Documentation

    • Overview
    • Platform introduction
    • Next.js quickstart
    • Nuxt.js quickstart
    • Astro quickstart
    • React Router quickstart
    • Studio quickstart
    • Build with AI
    • Content Lake
    • Functions
    • APIs and SDKs
    • Agent Actions
    • Visual Editing
    • Blueprints
    • Platform management
    • Dashboard
    • Studio
    • Canvas
    • Media Library
    • App SDK
    • Content Agent
    • HTTP API
    • CLI
    • Libraries
    • Specifications
    • Changelog
    • User guides
    • Developer guides
    • Courses and certifications
    • Join the community
    • Templates
Developer guides
Overview

  • Develop with AI

    Best practices

  • Query optimization

    Paginating with GROQ
    High performance GROQ

  • Roles and authentication

    Setting up Single Sign-On with SAML
    Third-Party Login (Deprecated)
    OAuth2
    Set up SSO authentication with SAML and Azure/Entra ID
    Set up SSO authentication with SAML and PingIdentity
    Set up SSO authentication with SAML and JumpCloud
    Reconcile users against internal systems
    Restrict Access to Specific Documents
    Setting up a Default Relay State for IdP Initiated - SAML Logins

  • Structured content

    Scalable navigation patterns
    An opinionated guide to Sanity Studio
    Browsing Content How You Want with Structure Builder
    Deciding on fields and relationships
    Create richer array item previews
    Dynamic folder structure using the currentUser and workflow states
    Create a time duration object field
    Level up Your Edit Modal with Next/Previous Navigation Buttons for Array Items
    Create a “coupon generator” string field input
    Managing redirects with Sanity
    Create a document form progress component
    Create an array input field with selectable templates
    Creating a Parent/Child Taxonomy
    Create interactive array items for featured elements
    Create a visual string selector field input
    Create a survey rating number field input
    How to use structured content for page building
    Create a recycling bin for deleted documents via Sanity Functions

  • Frontend integration

    Add live content to your application
    Forms with Sanity
    Vercel Integration
    Build your blog with Astro and Sanity
    How to implement front-end search with Sanity

  • Ecommerce

    Displaying Sanity content in Shopify
    Sanity Connect for Shopify
    Custom sync handlers for Sanity Connect

  • Integrating with other services

    A/B testing with Sanity and Growthbook
    Cookie consent integrations with Sanity
    Integrating external data sources with Sanity
    Klaviyo (email campaigns)
    Developing with Next.js on GitHub Codespaces

  • Adopting Sanity

    How to pitch Sanity.io to your team
    Convincing your clients to go with Sanity.io, rather than a traditional CMS
    Not-profit plan
    Agencies: Navigating the Spring 2025 Organization Changes
    How to generate massive amounts of demo content for Sanity
    How to implement Multi-tenancy with Sanity

  • GROQ

    GROQ-Powered Webhooks – Intro to Filters
    GROQ-Powered Webhooks – Intro to Projections

  • Portable Text

    Presenting Portable Text
    Add Inline blocks for the Portable Text Editor
    Beginners guide to Portable Text
    How to add custom YouTube blocks to Portable Text
    Converting Inline Styles to Sanity Block Decorators
    Add things to Portable Text
    Change the height of the PTE

  • Community and ecosystem

    Create your own Sanity template
    Community guides
    Community Code of Conduct
    Contribute to the ecosystem

  • Plugin development

    Migrating plugins to support Content Releases

On this page

Previous

Create interactive array items for featured elements

Next

Create a survey rating number field input

Was this page helpful?

On this page

  • What you need to know:
  • Custom form components by example
  • What you’ll create
  • Getting started
  • Create an input component
  • Handling changes and patching data
  • Next steps
Developer guidesLast updated September 24, 2025

Create a visual string selector field input

Go beyond a plain radio list of inputs by giving authors more contextually useful buttons to select values from.

This developer guide was contributed by Simeon Griggs (Principal Educator).

Go beyond a plain radio list of inputs by giving authors more contextually useful buttons to select values from.

What you need to know:

This guide assumes that you know how to set up and configure a Sanity Studio and have basic knowledge about defining a schema with document and field types. Basic knowledge of React and TypeScript is also useful, although you should be able to copy-paste the example code to get a runnable result.

Custom form components by example

One of Sanity Studio’s most powerful features is custom drop-in replacements for form fields. This guide is one in a series of code examples.

You can get more familiar with the Form Components API in the documentation.

  • Create a “coupon generator” string field input
  • Create a visual string selector field input
  • Create a survey rating number field input
  • Create a time duration object field
  • Create an array input field with selectable templates
  • Create interactive array items for featured elements
  • Create richer array item previews
  • Create a document form progress component

What you’ll create

A string field with larger buttons to select a value packed with more relevant information.

Getting started

In this guide, you’ll be creating a document type for a product feature. Each feature is only available on a certain plan and above. Plans are selected from a list of predefined strings.

Create the following schema type files and ensure they’re loaded into the schema property in sanity.config.ts

First, create a custom string type called plan. Creating a new schema type for this string type allows more flexible reuse throughout your Studio. For example, if multiple document types use this plan type with its custom input; but with unique options. By importing this schema type to the Studio schema, you can refer to this type with it’s value for name , in other words type: 'plan'.

// ./schema/plan/planType.ts

import {defineType} from 'sanity'

// We need will extend and import these in the custom input component later
export const PLANS = [
  {title: 'Free', value: 'free'},
  {title: 'Premium', value: 'premium'},
  {title: 'Enterprise', value: 'enterprise'},
]

export const planType = defineType({
  name: 'plan',
  title: 'Plan',
  type: 'string',
  options: {
    list: PLANS.map(({title, value}) => ({title, value})),
    layout: 'radio',
  },
})

Now, add a feature document type:

// ./schemas/featureType.ts

import {defineField, defineType} from 'sanity'

export const featureType = defineType({
  name: 'feature',
  title: 'Feature',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
    defineField({
      name: 'plan',
      type: 'plan',
      description: 'Minimum plan required to access this feature',
    }),
  ],
})

Create a new feature type document, and you should see both string fields like below:

Loading...
Two string fields, one with predefined options

Create an input component

Let’s say you want to add more information to these plans with an icon and a description. The neat solution would to have a document type for plans and bring the selection from them, but for the sake of simplicity, you’ll be hard coding this information by extending the PLANS array with properties and values for icon and description. The out-of-box radio list doesn’t support these properties, but we are going to build an UI for them in the custom input component:

// ./schema/plan/planType.ts

import {defineType} from 'sanity'
import {PlanInput} from './PlanInput'
import {UserIcon, UsersIcon, EarthGlobeIcon} from '@sanity/icons'

export const PLANS = [
  {title: 'Free', value: 'free', description: 'For personal use', icon: UserIcon},
  {title: 'Premium', value: 'premium', description: 'For small teams', icon: UsersIcon},
  {title: 'Enterprise', value: 'enterprise', description: 'For large teams', icon: EarthGlobeIcon},
]

export const planType = defineType({
  name: 'plan',
  title: 'Plan',
  type: 'string',
  options: {
    list: PLANS.map(({title, value}) => ({title, value})),
    layout: 'radio',
  },
  components: {input: PlanInput},
})

Create a new input component using the code below:

// ./schema/plan/PlanInput.tsx

import {StringInputProps, set} from 'sanity'
import {Stack, Button, Grid, Label, Text} from '@sanity/ui'
import {createElement} from 'react'
import {PLANS} from './planType'

export function PlanInput(props: StringInputProps) {
  const {value, onChange} = props

  return (
    <Grid columns={PLANS.length} gap={3}>
      {PLANS.map((plan) => (
        <Button
          key={plan.value}
          value={plan.value}
          mode={value === plan.value ? `default` : `ghost`}
          tone={value === plan.value ? `primary` : `default`}
        >
          <Stack space={3} padding={2}>
            <Text size={4} align="right">
              {createElement(plan.icon)}
            </Text>
            <Label>{plan.title}</Label>
            <Text>{plan.description}</Text>
          </Stack>
        </Button>
      ))}
    </Grid>
  )
}

Now you’ll take whatever value is saved to the field and match it against a plan in the component’s PLANS array.

To see this component in the Studio, you’ll need to add it to the plan schema type:

// ./schemas/plan/planType.ts

import {PlanInput} from './PlanInput'

export const planType = defineType({
  name: 'plan',
  // ...all other settings
  components: {input: PlanInput},
})

Now when editing the document you’re shown a beautiful set of buttons with much more details and context for your authors.

You can click these buttons but they won’t write anything to the document, yet!

Loading...
The string field input now shows beautiful buttons!

Handling changes and patching data

Custom inputs contain helpful functions and details in their props – for this input, you’ll only need one: onChange.

This function wraps any patch – such as setting or unsetting the value of a field – and ensures the rest of the Studio stays up to date with changes.

When working with forms in React, you’re often recommended to store values in a component’s state. This is an anti-pattern working with Sanity Studio input components. Writing content to state is only reflected in the browser of the person using the input. By using Sanity’s real-time APIs you allow content creators to collaborate and avoid overwriting each other’s changes by always syncing directly to the Content Lake.

Update your PlanInput component to use the code below:

// ./schema/plan/PlanInput.tsx

import {StringInputProps, set} from 'sanity'
import {Stack, Button, Grid, Label, Text} from '@sanity/ui'
import {UserIcon, UsersIcon, EarthGlobeIcon} from '@sanity/icons'
import {useCallback, createElement} from 'react'
import {PLANS} from './planType'

export function PlanInput(props: StringInputProps) {
  const {value, onChange} = props

  const handleClick = useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      const nextValue = event.currentTarget.value
      onChange(set(nextValue))
    },
    [onChange]
  )

  return (
    <Grid columns={PLANS.length} gap={3}>
      {PLANS.map((plan) => (
        <Button
          key={plan.value}
          value={plan.value}
          mode={value === plan.id ? `default` : `ghost`}
          tone={value === plan.id ? `primary` : `default`}
          onClick={handleClick}
        >
          <Stack space={3} padding={3}>
            <Text size={4} align="right">
              {createElement(plan.icon)}
            </Text>
            <Label>{plan.title}</Label>
            <Text>{plan.description}</Text>
          </Stack>
        </Button>
      ))}
    </Grid>
  )
}
  • Notice how onChange is destructured from the component’s props.
  • It is then called inside the handleClick function with the set() function, to update the field’s value in the Content Lake. This means the new value will be instantly validated in the document and updated in the browser of any other authors currently viewing the same document.
  • Both the mode and tone of the button are updated to highlight which value is currently selected.

Now you have a fully functional, automated, and editable coupon generator field with a handy visual preview!

Loading...
The buttons now write to and display the value of the field

Next steps

Some ideas to extend this custom input include:

  • A similar component with richly detailed buttons but for selecting a reference instead of a string.
  • Import unset from sanity and add an extra Button to remove the value from the document.
// ./schema/plan/planType.ts

import {defineType} from 'sanity'

// We need will extend and import these in the custom input component later
export const PLANS = [
  {title: 'Free', value: 'free'},
  {title: 'Premium', value: 'premium'},
  {title: 'Enterprise', value: 'enterprise'},
]

export const planType = defineType({
  name: 'plan',
  title: 'Plan',
  type: 'string',
  options: {
    list: PLANS.map(({title, value}) => ({title, value})),
    layout: 'radio',
  },
})
// ./schemas/featureType.ts

import {defineField, defineType} from 'sanity'

export const featureType = defineType({
  name: 'feature',
  title: 'Feature',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
    }),
    defineField({
      name: 'plan',
      type: 'plan',
      description: 'Minimum plan required to access this feature',
    }),
  ],
})
Two string fields, one with predefined options
// ./schema/plan/planType.ts

import {defineType} from 'sanity'
import {PlanInput} from './PlanInput'
import {UserIcon, UsersIcon, EarthGlobeIcon} from '@sanity/icons'

export const PLANS = [
  {title: 'Free', value: 'free', description: 'For personal use', icon: UserIcon},
  {title: 'Premium', value: 'premium', description: 'For small teams', icon: UsersIcon},
  {title: 'Enterprise', value: 'enterprise', description: 'For large teams', icon: EarthGlobeIcon},
]

export const planType = defineType({
  name: 'plan',
  title: 'Plan',
  type: 'string',
  options: {
    list: PLANS.map(({title, value}) => ({title, value})),
    layout: 'radio',
  },
  components: {input: PlanInput},
})
// ./schema/plan/PlanInput.tsx

import {StringInputProps, set} from 'sanity'
import {Stack, Button, Grid, Label, Text} from '@sanity/ui'
import {createElement} from 'react'
import {PLANS} from './planType'

export function PlanInput(props: StringInputProps) {
  const {value, onChange} = props

  return (
    <Grid columns={PLANS.length} gap={3}>
      {PLANS.map((plan) => (
        <Button
          key={plan.value}
          value={plan.value}
          mode={value === plan.value ? `default` : `ghost`}
          tone={value === plan.value ? `primary` : `default`}
        >
          <Stack space={3} padding={2}>
            <Text size={4} align="right">
              {createElement(plan.icon)}
            </Text>
            <Label>{plan.title}</Label>
            <Text>{plan.description}</Text>
          </Stack>
        </Button>
      ))}
    </Grid>
  )
}
// ./schemas/plan/planType.ts

import {PlanInput} from './PlanInput'

export const planType = defineType({
  name: 'plan',
  // ...all other settings
  components: {input: PlanInput},
})
The string field input now shows beautiful buttons!
// ./schema/plan/PlanInput.tsx

import {StringInputProps, set} from 'sanity'
import {Stack, Button, Grid, Label, Text} from '@sanity/ui'
import {UserIcon, UsersIcon, EarthGlobeIcon} from '@sanity/icons'
import {useCallback, createElement} from 'react'
import {PLANS} from './planType'

export function PlanInput(props: StringInputProps) {
  const {value, onChange} = props

  const handleClick = useCallback(
    (event: React.MouseEvent<HTMLButtonElement>) => {
      const nextValue = event.currentTarget.value
      onChange(set(nextValue))
    },
    [onChange]
  )

  return (
    <Grid columns={PLANS.length} gap={3}>
      {PLANS.map((plan) => (
        <Button
          key={plan.value}
          value={plan.value}
          mode={value === plan.id ? `default` : `ghost`}
          tone={value === plan.id ? `primary` : `default`}
          onClick={handleClick}
        >
          <Stack space={3} padding={3}>
            <Text size={4} align="right">
              {createElement(plan.icon)}
            </Text>
            <Label>{plan.title}</Label>
            <Text>{plan.description}</Text>
          </Stack>
        </Button>
      ))}
    </Grid>
  )
}
The buttons now write to and display the value of the field