How to split Block Content text into spans per letter while preserving marks and styles?

6 replies
Last updated: Nov 29, 2025
Any ideas on how to go about implementing split letter animations on block content text that includes marks and styles? Essentially I want to split the text strings into individual spans, one per letter, to achieve this sort of animation . I’ve accomplished that with a custom serializer but I lose the marks and styles
AI Update

This is a tricky challenge! When you split text into individual letter spans, you're essentially breaking apart the text nodes that marks and styles are applied to. Here's an approach that should preserve both marks and styles:

The key is to create a custom mark serializer that handles the letter splitting within the mark component itself, rather than splitting the text before serialization. This way, marks stay applied to their respective letters.

Here's a pattern that should work with @portabletext/react:

import { PortableText } from '@portabletext/react'

// Helper function to split text into letter spans
const splitIntoLetters = (text) => {
  return text.split('').map((char, i) => (
    <span key={i} className="letter" style={{ display: 'inline-block' }}>
      {char === ' ' ? '\u00A0' : char}
    </span>
  ))
}

// Recursively process children to split text nodes
const wrapChildrenWithSpans = (children) => {
  return React.Children.map(children, child => {
    if (typeof child === 'string') {
      return splitIntoLetters(child)
    }
    return child
  })
}

// Custom block serializer that preserves styles
const customBlock = ({ children, value }) => {
  const style = value.style || 'normal'
  
  const styleMap = {
    h1: 'h1',
    h2: 'h2',
    normal: 'p',
    // ... other styles
  }
  
  const Tag = styleMap[style] || 'p'
  return <Tag className="split-text">{children}</Tag>
}

// Custom mark serializers that preserve formatting
const customMarks = {
  strong: ({ children }) => (
    <strong>{wrapChildrenWithSpans(children)}</strong>
  ),
  em: ({ children }) => (
    <em>{wrapChildrenWithSpans(children)}</em>
  ),
  link: ({ children, value }) => (
    <a href={value.href}>{wrapChildrenWithSpans(children)}</a>
  ),
  // Add other custom marks as needed
}

// Use with PortableText
<PortableText
  value={content}
  components={{
    block: customBlock,
    marks: customMarks,
  }}
/>

The crucial part is the wrapChildrenWithSpans function that recursively processes children. When a mark serializer receives its children, it wraps any string content with letter spans while preserving the mark structure.

This means a bold, italic word will render as:

<strong><em><span>H</span><span>e</span><span>l</span><span>l</span><span>o</span></em></strong>

For your GSAP/Framer Motion animations, you can now target .letter spans while maintaining the complete mark hierarchy. The animations will work on individual letters, but the styling from marks (bold, italic, links, etc.) stays intact.

Important tips:

  • Use \u00A0 (non-breaking space) for spaces to prevent them from collapsing
  • Set display: inline-block on letter spans so transforms work properly in animations
  • Consider adding white-space: pre to the parent if you need to preserve multiple spaces

This approach works because you're splitting at the serialization level rather than preprocessing the Portable Text data. Each mark component handles its own text splitting, so the mark and style hierarchy defined in your Portable Text schema is fully preserved.

Show original thread
6 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?