Create a survey rating number field input
Give content creators quick access to valid values by replacing the default number field input with a list of options.
This developer guide was contributed by Simeon Griggs (Principal Educator).
Give content creators quick access to valid values by replacing the default number field input with a list of options.
What you need to know
This guide assumes that you know how to set up and configure 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 can copy and 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 build
A number field input with predefined values that are easy to select:
Create the schema types
In this guide, you’ll build an input for a number field with a button for each valid option. Uses might include survey responses to rank questions from 1–10, or a movie review for 1–5 stars.
First, create the minimum schema types required to create content.
Create a new rating schema type in your studio:
import {defineType} from 'sanity'
export const ratingType = defineType({
name: 'rating',
title: 'Rating',
type: 'number',
validation: (rule) => rule.min(1).max(10),
})Creating a new schema type for this number field lets you reuse it throughout your studio. For example, multiple document types can use this rating type with its custom input, each with its own validation rules. After you add this schema type to the studio schema, you can refer to it by the value of its name: type: 'rating'.
Next, edit a document schema type and use this new rating field type. In the following example, it’s used twice for different fields:
import {defineField, defineType} from 'sanity'
export const surveyType = defineType({
name: 'survey',
title: 'Survey',
type: 'document',
fields: [
defineField({
name: 'title',
type: 'string',
}),
defineField({
name: 'wouldRecommend',
description: 'How likely are you to recommend this product to a friend?',
type: 'rating',
validation: (rule) => rule.min(1).max(10),
}),
defineField({
name: 'wouldBuyAgain',
description: 'How likely are you to buy this product again?',
type: 'rating',
validation: (rule) => rule.min(1).max(5),
}),
],
})Add both schema types to the schema.types array in sanity.config.ts:
import {defineConfig} from 'sanity'
import {ratingType} from './schema/rating/ratingType'
import {surveyType} from './schema/survey'
export default defineConfig({
// ...all other settings
schema: {
types: [ratingType, surveyType],
},
})Create a new survey type document, and look at the two number fields.
They’re functional but not practical. It can be much better!
Create a component
Create a new component in your studio using the following code. Notice that you are accessing the validation rules in the schemaType to find the range of numbers to print out. This means that wherever you use type: 'rating', the field needs validation rules for min() and max(), either set on the field or inherited from the rating schema type:
import {Grid, Button} from '@sanity/ui'
import {NumberInputProps} from 'sanity'
import {useMemo} from 'react'
export function RatingInput(props: NumberInputProps) {
const {schemaType, value} = props
const {validation = []} = schemaType
const range = useMemo(() => generateRange(validation as any[]), [validation])
return (
<Grid gridTemplateColumns={range.length} gap={1}>
{range.map((index) => (
<Button
key={index}
mode={value === index ? 'default' : 'ghost'}
tone={value === index ? 'primary' : 'default'}
text={index.toString()}
value={index}
/>
))}
</Grid>
)
}
/**
* Function that finds the `min` and `max` rules from validations,
* and generates the range of numbers between them
**/
function generateRange(validation: any[]) {
const [min, max] = validation
.reduce((acc, {_rules}) => {
return [...acc, ..._rules]
}, [])
.filter((rule: any) => ['max', 'min'].includes(rule.flag))
.map((rule: any) => rule.constraint)
let range = []
for (let i = min; i <= max; i++) {
range.push(i)
}
return range
}Notice the following:
- On line 1, the imports from Sanity UI help you create custom inputs that use the same components and design language as the rest of the studio. See getting started with Sanity UI for more information.
- The code assumes that both
min()andmax()are set, and that the minimum rule comes first in the rule chain. If you’re making a plugin or plan to ship to production, add guardrails that check both are set. The code also uses some TypeScript shortcuts withanyto keep the code a bit more readable. - This input doesn’t render the default input (usually rendered with
props.renderDefault(props)), though you might choose to render it for debugging purposes.
To use this component, you’ll need to load it into the correct slot back on the rating schema. You’ll use input here because you don’t want to replace the field’s title and description.
import {defineType} from 'sanity'
import {RatingInput} from './RatingInput'
export const ratingType = defineType({
name: 'rating',
// ...all other settings
components: {input: RatingInput},
})Your survey document now contains clickable buttons. You can change the numbers in (rule) => rule.min(1).max(10) to see the number of buttons change.
Next, you’ll need to make them do something when selected!
Update the field value
You’ll need to access the onChange function from the component’s props to write patches to the document.
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, it’s common to store values in a component’s state. This is an anti-pattern when 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 RatingInput component with the following code:
import {Grid, Button} from '@sanity/ui'
import {NumberInputProps, set} from 'sanity'
import {useCallback, useMemo, type MouseEvent} from 'react'
export function RatingInput(props: NumberInputProps) {
const {onChange, schemaType, value} = props
const {validation = []} = schemaType
const range = useMemo(() => generateRange(validation as any[]), [validation])
const handleScore = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
const value = Number(event.currentTarget.value)
onChange(set(value))
},
[onChange]
)
return (
<Grid gridTemplateColumns={range.length} gap={1}>
{range.map((index) => (
<Button
key={index}
mode={value === index ? 'default' : 'ghost'}
tone={value === index ? 'primary' : 'default'}
text={index.toString()}
value={index}
onClick={handleScore}
/>
))}
</Grid>
)
}
/**
* Function that finds the `min` and `max` rules from validations,
* and generates the range of numbers between them
**/
function generateRange(validation: any[]) {
const [min, max] = validation
.reduce((acc, {_rules}) => {
return [...acc, ..._rules]
}, [])
.filter((rule: any) => ['max', 'min'].includes(rule.flag))
.map((rule: any) => rule.constraint)
let range = []
for (let i = min; i <= max; i++) {
range.push(i)
}
return range
}- Notice how
onChangeandvalueare destructured from the component’s props. onChangeis called inside thehandleScore()function with theset()function to update the field’s value. The new value is validated immediately and updated in the browser of anyone else viewing the same document.- The
handleScorefunction is registered with auseCallbackhook to cache it between re-renders.
The rating field is now much more author-friendly, with selectable values and a clear indication of the current value.
Next steps
Some ideas to extend this custom input include:
- Add additional guardrails, or defaults, to support only a
max()rule being set to increase the developer experience for your team. - Increase accessibility by supporting the number keys on a keyboard when the field has the focus.
- Improve the UI for scales with more than 10 values by spreading them across more rows with the
gridTemplateRowsprop. - Import
unsetfromsanityand add an extra button to remove the value from the field. - Add text below the rating buttons to explain what each end of the scale represents, for example: “more likely” and “less likely”.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?