How to model adjacent `<kbd>` tags in Sanity without auto-merging?

16 replies
Last updated: Nov 29, 2025
Relatively new to Sanity land, and moving our changelog over but came across potentially exotic issue; We write quite a lot of combination keyboard shortcuts (eg
<kdb>Cmd</kdb><kdb>i</kdb
). I managed to write scripts to import data from markdown content, but as I modeled
kbd
tags as mark decorations, any two tags that are next to each others will automatically get combined as they are marks unless I add a whitespace in between... So question is, what would be the correct way to model them? Annotations?
AI Update

You're experiencing a classic Portable Text behavior! The issue is that decorators (marks) automatically merge when adjacent, which is exactly what you're seeing with your <kbd> tags. For keyboard shortcuts where you need <kbd>Cmd</kbd><kbd>i</kbd> to remain separate, you should indeed use annotations or inline objects instead.

Here are your two best options:

Option 1: Use Annotations (simpler)

Annotations are marks that can carry additional data and don't auto-merge like decorators. Define a kbd annotation in your schema:

defineType({
  name: 'blockContent',
  type: 'array',
  of: [
    {
      type: 'block',
      marks: {
        annotations: [
          {
            name: 'kbd',
            type: 'object',
            title: 'Keyboard Key',
            fields: [
              {
                name: 'key',
                type: 'string',
                title: 'Key'
              }
            ]
          }
        ]
      }
    }
  ]
})

Annotations won't merge when adjacent because each one is a distinct object with its own data. However, they still wrap text spans, so you'd select text and apply the annotation.

For your use case, inline objects are probably the better choice since keyboard keys are really discrete objects, not text formatting. According to the Portable Text documentation, inline blocks let you embed custom content within text paragraphs by adding them to the of array within the block type:

defineType({
  name: 'kbd',
  type: 'object',
  title: 'Keyboard Key',
  fields: [
    {
      name: 'key',
      type: 'string',
      title: 'Key'
    }
  ]
})

// Then in your block content:
defineType({
  name: 'blockContent',
  type: 'array',
  of: [
    {
      type: 'block',
      of: [
        { type: 'kbd' }  // Add as inline object
      ]
    }
  ]
})

With inline objects, each <kbd> becomes a distinct object in the content array, so they'll never merge. This also makes them much easier to insert programmatically during your markdown import.

For Your Import Script

When importing, inline objects would be easier to work with since you can insert them as distinct objects in the children array:

{
  _type: 'block',
  children: [
    { _type: 'span', text: 'Press ' },
    { _type: 'kbd', _key: 'unique-key-1', key: 'Cmd' },
    { _type: 'kbd', _key: 'unique-key-2', key: 'i' },
    { _type: 'span', text: ' to italicize' }
  ]
}

The inline objects approach is probably your best bet here since keyboard shortcuts are semantic units rather than text styling. This aligns with how Portable Text separates content structure from presentation, and it will make both authoring and rendering much cleaner. Each keyboard key will be a first-class object that you can style and render independently on your frontend.

Show original thread
16 replies

Sanity – Build the way you think, not the way your CMS thinks

Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.

Was this answer helpful?