Unlock seamless workflows and faster delivery with our latest releases - Join the deep dive
Last updated January 18, 2024

Style Sanity Portable Text blocks with the Tailwind .prose class

A Blocks component that will allow you to split rendering of normal text blocks (`_type==='block'`) like `h1`, `h2`, `p`, etc, and modules.

This was inspired by the ProseableText guide, which I found while looking for a good solution.

Tailwind's typography plugin is a great way of quickly getting a base level of styling applied to a piece of text, with easy ways to customize individual elements as well. Naively, you could use it by wrapping your <PortableText> component in a <div className="prose">.

However, there are several issues with that:

  1. It will apply styling to everything, e.g. also paragraphs, headings etc that are inside custom modules
  2. The first-child styles that remove top margin don't work on blocks of text after a custom module

The aforementioned guide states that the first issue now could be solved by slapping not-prose on your custom modules. However, that will disable you from using it again in that module if you wanted. For example, I have a custom module called "image with text", where I want the text to be formatted with .prose again — but this is impossible once there is a not-prose somewhere above in the DOM tree.

The solution I have built is based on the approach from <ProseableText>, but I simplified it a bit and made it recursive. That way blocks of text inside modules are automatically wrapped in a <div className="prose"> container again, giving both styling and working first-child styles.

Here you go, this is blocks/index.tsx:

import { PortableText } from '@portabletext/react'
import { PortableTextBlock } from '@portabletext/types'
import { EncodeDataAttributeCallback } from '@sanity/react-loader'

import SectionImage from './section-image'

type Props = {
  value: PortableTextBlock[]
  sanity?: EncodeDataAttributeCallback
}

export default function Blocks({ value, sanity }: Props) {
  let div: PortableTextBlock[] = []
  return value.map((block, i, blocks) => {
    // Normal text blocks (p, h1, h2, etc.) — these are grouped so we can wrap them in a prose div
    if (block._type === 'block') {
      div.push(block)

      // If the next block is also text, group it with this one
      if (blocks[i + 1]?._type === 'block') return null

      // Otherwise, render the group of text blocks we have
      const value = div
      div = []

      return (
        <div key={block._key} className="prose-lg prose-h2:max-w-[32ch] prose-h2:text-5xl">
          <PortableText
            value={value}
            components={{
              marks: {
                // ...
              },
            }}
          />
        </div>
      )
    } else {
      // Non-text blocks (modules, sections, etc.) — note that these can recursively render text
      // blocks again
      return (
        <PortableText
          key={block._key}
          value={block}
          components={{
            types: {
              'section.image': ({ value }) => <SectionImage {...value} sanity={sanity} />,
              // ...
            },
          }}
        />
      )
    }
  })
}

and as an example, here is the section module (blocks/section-image.tsx) — simplified a bit but to demonstrate the recursiveness:

import { PortableTextBlock } from '@portabletext/types'
import { SanityImageObject } from '@sanity/image-url/lib/types/types'
import { EncodeDataAttributeCallback } from '@sanity/react-loader'
import { image } from 'lib/sanity/client'

import Blocks from '.'

type Props = {
  image: {
    image: SanityImageObject
  }
  richtext: PortableTextBlock[]
  _key: string
  sanity?: EncodeDataAttributeCallback
}

export default function SectionImage({ image: source, richtext, _key, sanity }: Props) {
  const hotspot = source.image.hotspot
    ? { objectPosition: `${source.image.hotspot.x * 100}% ${source.image.hotspot.y * 100}%` }
    : undefined

  return (
    <section className="...">
      <div className="..." data-sanity={sanity?.([`body:${_key}`, 'image', 'image'])}>
        <img
          src={image(source.image)}
          width={400}
          height={1000}
          className="absolute h-full w-full object-cover"
          style={hotspot}
        />
      </div>
      <div className="...">
        <Blocks value={richtext} />
      </div>
    </section>
  )
}

Sanity – build remarkable experiences at scale

Sanity Composable Content Cloud is the headless CMS that gives you (and your team) a content backend to drive websites and applications with modern tooling. It offers a real-time editing environment for content creators that’s easy to configure but designed to be customized with JavaScript and React when needed. With the hosted document store, you query content freely and easily integrate with any framework or data source to distribute and enrich content.

Sanity scales from weekend projects to enterprise needs and is used by companies like Puma, AT&T, Burger King, Tata, and Figma.