How to implement split letter animations on block content text in React.

6 replies
Last updated: May 6, 2021
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.

Here’s what I have so far in the serializer:
types: {
		block: (props: any) => {
			return (
				<span className="split-letters">
					{[...props.children].map((child) => {
						const text = child.props?.node?.children.join(" ");
						if (text) {
							return [...text].map((letter) => (
								<span className="letter">{letter}</span>
							));
						}
					})}
				</span>
			);
		},
	},
[Side note: That's a pretty sweet animation
user S
! 😄 ]
I got it working!
import React from "react";
// @ts-ignore
import SanityBlockContent from "@sanity/block-content-to-react";

export const SplitLetterSerializer = {
	types: {
		block: (props: any) => {
			const modifiedBlockContent = SanityBlockContent.defaultSerializers.types
				.block(props)
				.props.children.map((Block: React.ReactElement) => {
					let text;
					if (Block.props?.node?.children) {
						text = Block?.props?.node?.children.join(" ");
					}
					let modifiedText;
					if (text) {
						modifiedText = [...text].map((letter) => (
							<span className="letter">{letter}</span>
						));
					}
					if (Block.props?.node?.children) {
						Block.props.node.children = [modifiedText];
					}
					return Block;
				});

			return modifiedBlockContent;
		},
	},
};
Hey friends, GSAP also has an excellent plugin for this:

https://greensock.com/splittext/
Splitting.Js by Stephen Shaw is pretty popular too! https://splitting.js.org/
Thanks
user S
! I ended up getting it working without a library — I didn’t realize GSAP took HTML elements, same with splitting.js, but even so, I wanted to preserve the React Component

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?