Type something to search...
Conditional Fields in Sanity: Show and Hide Inputs Dynamically

Conditional Fields in Sanity: Show and Hide Inputs Dynamically

Conditional fields in Sanity are fields whose hidden or readOnly property is a function instead of a boolean. The Studio calls that function whenever the document changes, passing a context object with the current document, the parent object the field sits in, the field's own value, the currentUser and the field's path. Return true and the field is hidden (or locked); return false and it appears as normal. For example, hidden: ({ parent }) => parent?.linkType !== 'external' shows a URL field only when an editor picks "External link".

Conditional fields keep forms short and relevant. An editor choosing between an internal page and an external URL shouldn't see both inputs, and a contributor shouldn't be able to change a field only editors-in-chief should touch. This article covers how the callbacks work, the context they receive, common patterns, role-based access, how hidden fields interact with validation and stored data, and the limits you should know about.

How Conditional Properties Work

Two properties accept callbacks:

  • hidden: removes the field from the form when the callback returns true.
  • readOnly: shows the field but prevents editing when the callback returns true.

Both also work on fieldsets and field groups, which lets you hide or lock a whole cluster of fields at once. How to organise fields with groups and fieldsets explains those containers.

The simplest example toggles a field from a boolean:

// schemaTypes/post.ts
import { defineField, defineType } from 'sanity'

export const post = defineType({
  name: 'post',
  type: 'document',
  fields: [
    defineField({ name: 'title', type: 'string' }),
    defineField({
      name: 'hasVideo',
      title: 'Include a video?',
      type: 'boolean',
      initialValue: false,
    }),
    defineField({
      name: 'videoUrl',
      title: 'Video URL',
      type: 'url',
      hidden: ({ document }) => !document?.hasVideo,
    }),
  ],
})

Tick the box and the Video URL input appears instantly. Untick it and the input disappears.

The Callback Context

The callback receives one object with these properties:

PropertyWhat it contains
documentThe whole current document, or undefined before it loads
parentThe object or document that directly contains this field
valueThe current value of the field itself
currentUserThe logged-in user, including roles, email and id
pathThe field's path within the document

Use document for top-level conditions, and parent for fields inside objects and arrays, where document would require you to know which array item you're in. Always use optional chaining, because document and parent can be undefined briefly while the form loads.

Pattern: Link Type Switcher

The classic use case is a link that can point to an internal document or an external URL. Using parent makes it work anywhere the object is used, including inside arrays:

// schemaTypes/objects/link.ts
import { defineField, defineType } from 'sanity'

export const link = defineType({
  name: 'link',
  title: 'Link',
  type: 'object',
  fields: [
    defineField({
      name: 'linkType',
      type: 'string',
      options: {
        list: [
          { title: 'Internal page', value: 'internal' },
          { title: 'External URL', value: 'external' },
        ],
        layout: 'radio',
        direction: 'horizontal',
      },
      initialValue: 'internal',
    }),
    defineField({
      name: 'internal',
      type: 'reference',
      to: [{ type: 'page' }, { type: 'post' }],
      hidden: ({ parent }) => parent?.linkType !== 'internal',
    }),
    defineField({
      name: 'external',
      type: 'url',
      hidden: ({ parent }) => parent?.linkType !== 'external',
      validation: (rule) => rule.uri({ scheme: ['http', 'https', 'mailto', 'tel'] }),
    }),
    defineField({
      name: 'openInNewTab',
      type: 'boolean',
      initialValue: false,
      hidden: ({ parent }) => parent?.linkType !== 'external',
    }),
  ],
})

Because it's a reusable object type, every link in your schema gets the same behaviour.

Pattern: Hide Until Another Field Has a Value

Sometimes a field only makes sense once another is filled in. An image's caption, for example:

defineField({
  name: 'mainImage',
  type: 'image',
  options: { hotspot: true },
  fields: [
    defineField({ name: 'alt', type: 'string', title: 'Alt text' }),
    defineField({
      name: 'caption',
      type: 'string',
      hidden: ({ parent }) => !parent?.asset,
    }),
  ],
})

The caption only appears after an image is uploaded.

Pattern: Content Type Variants

For documents with several variants, a single type field can drive the rest of the form:

defineField({
  name: 'eventType',
  type: 'string',
  options: { list: ['in-person', 'online', 'hybrid'], layout: 'radio' },
}),
defineField({
  name: 'venue',
  type: 'string',
  hidden: ({ document }) => document?.eventType === 'online',
}),
defineField({
  name: 'streamUrl',
  type: 'url',
  hidden: ({ document }) => document?.eventType === 'in-person',
}),

If your conditions start growing into long chains of && and ||, that's a signal the variants might be better modelled as separate document types. The trade-offs are covered in content modeling best practices in Sanity.

Pattern: Hide a Field When It Has No Value

value lets a field hide itself. This is useful for legacy fields you're phasing out: existing data stays visible so editors can migrate it, but empty documents never show the field.

defineField({
  name: 'legacySubtitle',
  title: 'Subtitle (deprecated)',
  type: 'string',
  description: 'Move this text into the Summary field.',
  hidden: ({ value }) => !value,
}),

Sanity also has a dedicated deprecated property that marks a field as deprecated in the Studio with a reason, which pairs well with this pattern.

Role-Based Fields With currentUser

currentUser.roles is an array of role objects with a name such as administrator, editor or a custom role name. You can use it to lock or hide fields by role:

// schemaTypes/utils/roles.ts
import type { CurrentUser } from 'sanity'

export const isAdmin = (user: Omit<CurrentUser, 'role'> | null) =>
  Boolean(user?.roles.some((role) => role.name === 'administrator'))
import { isAdmin } from './utils/roles'

defineField({
  name: 'featured',
  title: 'Feature on homepage',
  type: 'boolean',
  readOnly: ({ currentUser }) => !isAdmin(currentUser),
}),
defineField({
  name: 'internalNotes',
  type: 'text',
  hidden: ({ currentUser }) => !isAdmin(currentUser),
}),

This is a convenience, not security. Hiding or locking a field in the Studio doesn't stop someone with write access from changing it through the API. Real access control comes from roles and permissions on your project, which depend on your plan; see how Sanity pricing works for what each tier includes.

Read-Only After Publishing

A common request is to lock a field, such as a slug, once content goes live. You can base it on a field that's only set at publish time:

defineField({
  name: 'slug',
  type: 'slug',
  options: { source: 'title' },
  readOnly: ({ document, currentUser }) =>
    Boolean(document?.publishedAt) && !isAdmin(currentUser),
}),

Editors can set the slug freely until publishedAt is filled in, after which only administrators can change it. More slug patterns are in how to create custom slugs in Sanity.

Hidden Fields, Validation and Data

Two behaviours surprise people, so it's worth being explicit.

Hiding a Field Doesn't Delete Its Value

If an editor fills in videoUrl and then unticks hasVideo, the URL stays in the document. It's just not visible. Your front end should check the controlling field rather than assume hidden fields are empty:

*[_type == "post" && slug.current == $slug][0]{
  title,
  "videoUrl": select(hasVideo == true => videoUrl, null)
}

select() returns the URL only when the toggle is on. Alternatively, handle it in your component.

Validation Still Runs on Hidden Fields

A required() rule on a hidden field still blocks publishing, and the editor has no way to see or fix it. Make the rule conditional instead, using the validation context:

defineField({
  name: 'videoUrl',
  type: 'url',
  hidden: ({ document }) => !document?.hasVideo,
  validation: (rule) =>
    rule.custom((value, context) => {
      if (context.document?.hasVideo && !value) {
        return 'Add a video URL or untick "Include a video?"'
      }
      return true
    }),
}),

Keep the hidden condition and the validation condition in sync. A small shared function, such as const needsVideo = (doc) => Boolean(doc?.hasVideo), used in both places, prevents them drifting apart. For more on custom rules, see how to add validation rules to Sanity fields.

Limits and Performance

  • Callbacks must be synchronous: they return a boolean, not a promise. You can't fetch data inside hidden. If a condition depends on a referenced document, copy the needed value into the current document or restructure the model.
  • They run often: callbacks run whenever the document changes, so keep them cheap. Avoid heavy loops over large arrays.
  • Hidden isn't secure: as above, it only affects the Studio interface.
  • Array members can't be hidden: you can't conditionally hide one member type in an array's of list. Hide fields inside the member, or control the insert options another way.
  • Required fields and initial values: a field hidden on a new document can still receive its initialValue. That's often what you want, but bear it in mind when debugging unexpected data. How to set initial values in Sanity documents explains when initial values apply.

Testing Conditional Logic

Because callbacks are plain functions, you can unit test them. Extract the logic and test it with any test runner:

// schemaTypes/utils/conditions.ts
export const hideExternal = ({ parent }: { parent?: { linkType?: string } }) =>
  parent?.linkType !== 'external'
// schemaTypes/utils/conditions.test.ts
import { describe, expect, it } from 'vitest'
import { hideExternal } from './conditions'

describe('hideExternal', () => {
  it('shows the URL field for external links', () => {
    expect(hideExternal({ parent: { linkType: 'external' } })).toBe(false)
  })
  it('hides it otherwise', () => {
    expect(hideExternal({ parent: { linkType: 'internal' } })).toBe(true)
    expect(hideExternal({})).toBe(true)
  })
})

This catches regressions when someone renames a field or changes a list value.


FAQ: Conditional Fields in Sanity

It receives one object with document, parent, value, currentUser and path. Use parent for fields inside objects or arrays, and document for conditions based on top-level fields.

No. The value stays in the document even when the field is hidden. Check the controlling field in your queries or front end, or clear the value with a migration if you need to.

Validation runs on hidden fields too. Replace required() with a custom rule that only requires the value when the field is visible, using the same condition as the hidden callback.

No. Conditional property callbacks must return a boolean synchronously. If the condition depends on another document, store the needed value on the current document or rethink the model.

Yes. currentUser.roles lists the user's roles, so you can check for a role name such as administrator. This only changes the Studio interface, though, and isn't a substitute for permissions.

Yes. Both field groups and fieldsets accept a hidden property, as a boolean or a callback. Fieldsets also accept readOnly.


Conclusion

Conditional fields make Sanity forms adapt to what editors are doing. A hidden or readOnly callback with access to the document, the parent object, the field's value and the current user covers link switchers, content variants, deprecated fields and role-based locks with a line or two of code each.

Keep the callbacks small and synchronous, use parent for reusable objects, and remember that hiding is purely visual: values stay in the document and validation still runs. Pair each condition with matching validation and front-end checks, and your forms will stay short without letting inconsistent data slip through.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper