Type something to search...
Styling Portable Text Output With Tailwind CSS

Styling Portable Text Output With Tailwind CSS

To style Portable Text with Tailwind CSS, you have two options. The quickest is to wrap the <PortableText /> component from @portabletext/react in an element with the prose class from the @tailwindcss/typography plugin, which styles headings, paragraphs, lists, links and quotes automatically. The more controlled option is to pass a components object that maps each block style, mark, list and custom type to your own elements with Tailwind utility classes. Most real projects combine the two: prose for sensible defaults, plus custom components for images, callouts and anything else the plugin doesn't know about.

Portable Text renders as plain, unstyled HTML. That's deliberate, because Sanity stores your rich text as structured data and leaves presentation to you, but it means a fresh render looks like a 1995 web page. Tailwind's utility-first approach doesn't style raw elements either, since its Preflight reset removes default margins and heading sizes. This guide shows how to set up the typography plugin with Tailwind CSS v4, customise it with element modifiers, write a components map, handle custom blocks with not-prose, support dark mode and keep everything in one reusable component.

Why Portable Text Needs Styling

When you render Portable Text, @portabletext/react outputs semantic HTML: <p>, <h2>, <ul>, <li>, <blockquote>, <strong>, <em> and <a>. If you haven't read it yet, what is Portable Text explains how blocks, spans and marks are structured.

Tailwind's Preflight strips the browser's default styles from those elements. Headings become the same size as body text, lists lose their bullets and paragraphs lose their spacing. You'll see something like a single wall of text.

You can't add classes inside rich text the way you would in JSX, because editors write the content in Sanity Studio. Instead you style the output in one of two places:

  • On a wrapper element: The typography plugin's prose classes target descendant elements, so a single class on a parent styles everything inside it.
  • On each rendered element: The components prop on <PortableText /> lets you decide exactly which element and classes each block style, list and mark renders.

Set Up the Project

This guide assumes a Next.js App Router project with Tailwind CSS v4 and a Sanity client already configured. Install the renderer and the typography plugin:

npm install @portabletext/react @tailwindcss/typography

In Tailwind CSS v4, plugins are registered in your CSS file with @plugin, not in a JavaScript config:

/* src/app/globals.css */
@import "tailwindcss";
@plugin "@tailwindcss/typography";

If you're on Tailwind CSS v3, add require('@tailwindcss/typography') to the plugins array in tailwind.config.js instead. The class names used in the rest of this guide are the same in both versions. For a deeper look at fonts and type scales, see how to set up typography with Tailwind CSS.

Fetch the body field with GROQ so you have something to render:

// src/sanity/queries.ts
import { defineQuery } from "next-sanity";

export const POST_QUERY = defineQuery(`
  *[_type == "post" && slug.current == $slug][0]{
    title,
    body
  }
`);

Option 1: Use the Prose Classes

The fastest way to get readable output is to wrap <PortableText /> in an element with prose:

// src/app/blog/[slug]/page.tsx
import { PortableText } from "@portabletext/react";
import { client } from "@/sanity/lib/client";
import { POST_QUERY } from "@/sanity/queries";

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await client.fetch(POST_QUERY, { slug });

  return (
    <main className="mx-auto max-w-3xl px-4 py-12">
      <h1 className="mb-8 text-4xl font-bold">{post.title}</h1>
      <article className="prose lg:prose-lg">
        <PortableText value={post.body} />
      </article>
    </main>
  );
}

That's usually enough to make the content look finished. The plugin gives you balanced line heights, vertical rhythm between elements, styled lists and quotes, and a readable measure of about 65 characters.

Size and Colour Modifiers

The plugin ships with modifiers you add alongside prose:

ClassWhat it does
prose-sm, prose-base, prose-lg, prose-xl, prose-2xlScales the whole type system up or down
prose-gray, prose-slate, prose-zinc, prose-neutral, prose-stoneChanges the grey palette used for text and borders
prose-invertSwaps to light text for dark backgrounds
max-w-noneRemoves the default maximum width so the content fills its column

Size modifiers work with responsive variants, so prose md:prose-lg gives you larger text on wider screens.

Element Modifiers

When you only want to adjust one element, use the plugin's element modifiers instead of writing custom CSS. They follow the pattern prose-{element}:{utility}:

<article
  className="prose prose-slate max-w-none
    prose-headings:font-semibold prose-headings:tracking-tight
    prose-h2:mt-12 prose-h2:text-3xl
    prose-a:text-indigo-600 prose-a:no-underline hover:prose-a:underline
    prose-blockquote:border-l-indigo-500 prose-blockquote:not-italic
    prose-img:rounded-xl prose-code:before:content-none prose-code:after:content-none"
>
  <PortableText value={post.body} />
</article>

Available modifiers include prose-headings, prose-h1 to prose-h6, prose-p, prose-a, prose-blockquote, prose-strong, prose-em, prose-code, prose-pre, prose-ol, prose-ul, prose-li, prose-img, prose-figure, prose-figcaption, prose-hr and prose-table. The prose-code:before:content-none pair removes the backticks the plugin adds around inline code by default, which most people don't want.

Long class strings like this are easier to maintain in one shared component, which we'll build later.

Option 2: Map Blocks to Tailwind Classes

If you want full control, or you're building a design system where prose would fight your existing styles, pass a components object. Each key overrides the default element for that part of the content:

// src/components/portable-text-components.tsx
import type { PortableTextComponents } from "@portabletext/react";

export const components: PortableTextComponents = {
  block: {
    normal: ({ children }) => (
      <p className="mb-5 leading-7 text-slate-700">{children}</p>
    ),
    h2: ({ children }) => (
      <h2 className="mt-12 mb-4 text-3xl font-semibold tracking-tight text-slate-900">
        {children}
      </h2>
    ),
    h3: ({ children }) => (
      <h3 className="mt-8 mb-3 text-2xl font-semibold text-slate-900">
        {children}
      </h3>
    ),
    blockquote: ({ children }) => (
      <blockquote className="my-6 border-l-4 border-indigo-500 pl-4 text-lg text-slate-600">
        {children}
      </blockquote>
    ),
  },
  list: {
    bullet: ({ children }) => (
      <ul className="mb-5 ml-6 list-disc space-y-2 marker:text-indigo-500">
        {children}
      </ul>
    ),
    number: ({ children }) => (
      <ol className="mb-5 ml-6 list-decimal space-y-2">{children}</ol>
    ),
  },
  listItem: {
    bullet: ({ children }) => <li className="pl-1">{children}</li>,
    number: ({ children }) => <li className="pl-1">{children}</li>,
  },
  marks: {
    strong: ({ children }) => (
      <strong className="font-semibold text-slate-900">{children}</strong>
    ),
    em: ({ children }) => <em className="italic">{children}</em>,
    code: ({ children }) => (
      <code className="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-sm">
        {children}
      </code>
    ),
  },
};

Then pass it in:

<PortableText value={post.body} components={components} />

A few things worth knowing:

  • Components merge with the defaults: You only need to define what you want to change. Anything you leave out, such as h4, still renders with the default element.
  • Define the object outside your component: The @portabletext/react README recommends keeping the object's identity stable between renders, so declare it at module level or wrap it in useMemo.
  • Keys match your schema: block keys match the style values in your block schema, list and listItem keys match the lists values, and marks keys match decorator values or annotation _type names.

The render Portable Text in React post covers the full list of component keys, including hardBreak and the unknown* fallbacks.

Style Links and Annotations

Annotations such as links arrive in marks with a value prop that holds the annotation's data. This is a good place to add Tailwind classes and handle external links:

// inside the components object
marks: {
  link: ({ value, children }) => {
    const href: string = value?.href ?? '#'
    const isExternal = href.startsWith('http')

    return (
      <a
        href={href}
        className="font-medium text-indigo-600 underline decoration-indigo-300 underline-offset-4 transition hover:decoration-indigo-600"
        {...(isExternal && { target: '_blank', rel: 'noopener noreferrer' })}
      >
        {children}
      </a>
    )
  },
},

Tailwind's decoration-* and underline-offset-* utilities give you a subtle, modern link style without custom CSS. For internal references and other annotation types, see inline annotations and links in Portable Text.

Style Custom Blocks Without Fighting Prose

Custom block types, such as images, callouts or code, are rendered through components.types. When they sit inside a prose wrapper, the plugin's descendant styles leak into them, which can add unwanted margins to a card or recolour a button.

The fix is the plugin's not-prose class. Any element with it, and everything inside it, is excluded from typography styles:

// src/components/portable-text-components.tsx
import Image from "next/image";
import type { PortableTextComponents } from "@portabletext/react";
import { urlFor } from "@/sanity/lib/image";

export const components: PortableTextComponents = {
  types: {
    image: ({ value }) => {
      if (!value?.asset) return null;
      return (
        <figure className="my-10">
          <Image
            src={urlFor(value).width(1200).fit("max").auto("format").url()}
            alt={value.alt ?? ""}
            width={1200}
            height={675}
            className="w-full rounded-xl"
          />
          {value.caption && (
            <figcaption className="mt-2 text-center text-sm text-slate-500">
              {value.caption}
            </figcaption>
          )}
        </figure>
      );
    },
    callout: ({ value }) => (
      <aside className="not-prose my-8 rounded-lg border border-amber-200 bg-amber-50 p-5 text-amber-900">
        <p className="font-semibold">{value.title}</p>
        <p className="mt-1 text-sm">{value.text}</p>
      </aside>
    ),
  },
};

The image keeps prose styling for the caption and spacing, which works well. The callout opts out entirely, so you style it from scratch with utilities. Using next/image here requires cdn.sanity.io in images.remotePatterns, or the Image component from next-sanity/image; Sanity's image URL builder covers both. If you haven't modelled custom types yet, start with adding custom blocks to Portable Text.

Add Dark Mode

With prose, dark mode is one class. Add dark:prose-invert to the wrapper:

<article className="prose prose-slate dark:prose-invert">
  <PortableText value={post.body} components={components} />
</article>

If you use custom components with hard-coded colours, add dark: variants to each of them, for example text-slate-700 dark:text-slate-300 on paragraphs. That's one of the strongest arguments for letting prose handle text colours and only using custom components for structure.

Theme the Plugin With CSS Variables

The typography plugin reads its colours from CSS custom properties such as --tw-prose-body, --tw-prose-headings and --tw-prose-links. You can override them to match your brand without touching each element:

/* src/app/globals.css */
@import "tailwindcss";
@plugin "@tailwindcss/typography";

@utility prose-brand {
  --tw-prose-body: var(--color-slate-700);
  --tw-prose-headings: var(--color-slate-950);
  --tw-prose-links: var(--color-indigo-600);
  --tw-prose-quote-borders: var(--color-indigo-500);
  --tw-prose-invert-body: var(--color-slate-300);
  --tw-prose-invert-headings: var(--color-white);
  --tw-prose-invert-links: var(--color-indigo-400);
}

Now className="prose prose-brand dark:prose-invert" applies your palette in both modes. The --tw-prose-invert-* variables are the ones prose-invert switches to.

Build a Reusable Rich Text Component

Rather than repeating class strings across pages, wrap everything in one component:

// src/components/rich-text.tsx
import { PortableText, type PortableTextBlock } from "@portabletext/react";
import { components } from "./portable-text-components";

type RichTextProps = {
  value: PortableTextBlock[] | null | undefined;
  className?: string;
};

export function RichText({ value, className = "" }: RichTextProps) {
  if (!value?.length) return null;

  return (
    <div
      className={`prose prose-slate prose-brand dark:prose-invert max-w-none
        prose-headings:tracking-tight prose-a:underline-offset-4
        prose-code:before:content-none prose-code:after:content-none ${className}`}
    >
      <PortableText value={value} components={components} />
    </div>
  );
}

Use it anywhere you render rich text:

<RichText value={post.body} className="lg:prose-lg" />
<RichText value={author.bio} className="prose-sm" />

<PortableText /> works in React Server Components without configuration, so RichText can stay a server component and ship no JavaScript unless a custom block needs interactivity. If you join class names often, a helper such as clsx or tailwind-merge keeps the string tidy and resolves conflicting utilities.

Prose vs Custom Components

Considerationprose wrapperCustom components map
Setup timeMinutesLonger, one entry per element
Visual consistencyStrong defaults from the pluginWhatever you design
Fine controlElement modifiers and CSS variablesComplete
Dark modedark:prose-invertdark: variants on every element
Custom blocksNeeds not-prose for isolated designsNatural fit
Works with a design systemCan conflict with existing stylesUses your tokens directly

For blogs, documentation and marketing pages, start with prose and add custom components only for custom types and links. For product UIs or strict design systems, a custom map is usually cleaner.

Common Problems

  • Styles don't appear at all: Check that @plugin "@tailwindcss/typography"; is in the CSS file your layout imports, and that you haven't misspelt prose.
  • Content is narrow: prose sets a maximum width. Add max-w-none and control width on the parent instead.
  • Custom classes are ignored inside prose: The plugin's selectors can be more specific than a single utility. Use an element modifier, not-prose, or Tailwind's important modifier, for example mt-0! in v4.
  • Empty paragraphs create gaps: Editors sometimes leave empty blocks. Filter them in GROQ or check children length in your normal component and return null.
  • Lists look unstyled in a custom map: Preflight removes list markers, so add list-disc or list-decimal and a left margin yourself.

FAQ: Styling Portable Text With Tailwind

No. You can style every element through the components prop on PortableText with your own Tailwind classes. The plugin is simply the fastest way to get good defaults, and many projects use it alongside a few custom components.

Install @tailwindcss/typography with npm, then add @plugin "@tailwindcss/typography"; below @import "tailwindcss"; in your main CSS file. There's no tailwind.config.js entry in v4 unless you load a legacy config with @config.

The prose class styles every descendant element, including those inside custom blocks. Add the not-prose class to the custom block's outer element to exclude it, then style it with utilities as normal.

Add dark:prose-invert to the element with the prose class. If you've written custom components with fixed colours, add dark: variants to those classes too, or theme the plugin with its --tw-prose-invert CSS variables.

Yes. Build one reusable component that accepts a className prop, then pass size or colour modifiers per use, such as prose-sm for an author bio and lg:prose-lg for an article body. You can also pass a different components object for each context.

Yes. @portabletext/react renders in server components without extra configuration, so your rich text component can stay on the server. Only custom blocks that need state or event handlers need to be client components.


Conclusion

Styling Portable Text with Tailwind comes down to choosing where your styles live. The typography plugin's prose class gives you polished defaults with one class, element modifiers and CSS variables let you tune it, and dark:prose-invert covers dark mode. A components map gives you complete control when you need it, and not-prose keeps custom blocks from inheriting article styles.

For most sites, the best setup is a single RichText component that combines prose with a small components map for links, images and custom types. You define the look once, every rich text field on the site stays consistent, and editors can write freely in Sanity Studio without ever thinking about classes.

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