
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
proseclasses target descendant elements, so a single class on a parent styles everything inside it. - On each rendered element: The
componentsprop 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:
| Class | What it does |
|---|---|
prose-sm, prose-base, prose-lg, prose-xl, prose-2xl | Scales the whole type system up or down |
prose-gray, prose-slate, prose-zinc, prose-neutral, prose-stone | Changes the grey palette used for text and borders |
prose-invert | Swaps to light text for dark backgrounds |
max-w-none | Removes 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/reactREADME recommends keeping the object's identity stable between renders, so declare it at module level or wrap it inuseMemo. - Keys match your schema:
blockkeys match thestylevalues in your block schema,listandlistItemkeys match thelistsvalues, andmarkskeys match decorator values or annotation_typenames.
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
| Consideration | prose wrapper | Custom components map |
|---|---|---|
| Setup time | Minutes | Longer, one entry per element |
| Visual consistency | Strong defaults from the plugin | Whatever you design |
| Fine control | Element modifiers and CSS variables | Complete |
| Dark mode | dark:prose-invert | dark: variants on every element |
| Custom blocks | Needs not-prose for isolated designs | Natural fit |
| Works with a design system | Can conflict with existing styles | Uses 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 misspeltprose. - Content is narrow:
prosesets a maximum width. Addmax-w-noneand 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 examplemt-0!in v4. - Empty paragraphs create gaps: Editors sometimes leave empty blocks. Filter them in GROQ or check
childrenlength in yournormalcomponent and returnnull. - Lists look unstyled in a custom map: Preflight removes list markers, so add
list-discorlist-decimaland 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.


