Styling Rich Text with TailwindCSS

In this tutorial, we’ll cover using the JSON representation from the RTE to create custom elements for each text-based element of rich text.

Bryan Robinson, Head of Developer Relations at Hygraph
Bryan Robinson
Styling Rich Text with TailwindCSS

While using the HTML that comes from Hygraph’s Rich Text Editor (RTE) is an easy shortcut when prototyping. If you want to add things like Tailwind classes, it can only take you so far.

In this tutorial, we’ll cover using the JSON representation from the RTE to create custom elements for each text-based element of rich text. This will allow us to add granular Tailwind classes to each.

RequirementsAnchor

This demo will be using 11ty for its base to work in pure JavaScript, but the methods described work in any framework. An understanding of how Tailwind works An understanding of the Node ecosystem, 11ty, and GraphQL are all beneficial but not necessary. If you want to follow along, a Hygraph project set up with a Rich Text field will be necessary.

Getting data from HygraphAnchor

To start, we need the data for each post in Hygraph. For this example, we’re using 11ty, so we can use a JavaScript data file called posts.js to get all posts from our Hygraph project.

// Install with `npm install graphql-request`
const GraphQLClient = require('graphql-request').GraphQLClient
// Get Hygraph posts for 11ty data
const getHygraphPosts = async () => {
const client = new GraphQLClient('https://api-us-east-1.hygraph.com/v2/cl8vzs0jm7fb201ukbf4ahe92/master')
const response = await client.request(`
query MyQuery {
posts {
slug
title
content {
html
}
}
}
` )
return response.posts
}
module.exports = async () => {
const posts = await getHygraphPosts()
return posts
}

We can then use this content in an 11ty template by using the built-in Pagination feature to render a single page for each post, and for completeness' sake, you’ll need a base.html included to use as the overall template for the site.

---
pagination:
data: posts
size: 1
alias: post
permalink: "/post/{{ post.slug | slug }}/index.html"
layout: "base.html"
# You'll need a base.html with a simple HTML wrapper
---
<h1 class="mb-4 text-4xl font-extrabold tracking-tight leading-none text-gray-900 md:text-5xl lg:text-6xl dark:text-white">{{ post.title }}</h1>
{{ post.content.html }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div class="container mx-auto my-10 px-6">
<a href="/">Home</a>
{{ content }}
</div>
</body>
</html>

Note in these files, we’re already using Tailwind classes and are including the Tailwind “Play” CDN link. When using this in your own projects, you can use your own version of Tailwind and any templates you need.

While there’s content on the page, it’s not ideal. Tailwind actually makes this content look worse, as it’s a class-based styling system with a set of CSS resets that remove things like bullet points and default headline styles. To fix that, we need classes on the HTML body.

A screenshot of the page with no styling for the body copy

Converting the data to use JSON instead of HTMLAnchor

Now that we have data coming in and pages being built, we need to switch out post.content.html from our post body to be rendered HTML from JSON.

To do this, in the posts.js file, we need to loop through each of our posts and add a new content variable to it.

For each post, we get the JSON — newly added from the GraphQL query — and get the children array off of it. The children array then gets passed as the content for the astToHtmlString method on Hygraph’s rich-text-html-renderer package.

Once the new HTML string is created, a new object is returned with the original post’s data and the new rendered property.

const GraphQLClient = require('graphql-request').GraphQLClient
// Install using npm install @graphcms/rich-text-html-renderer
const { astToHtmlString } = require('@graphcms/rich-text-html-renderer')
// Get Hygraph posts for 11ty data
const getHygraphPosts = async () => {
const client = new GraphQLClient('https://api-us-east-1.hygraph.com/v2/cl8vzs0jm7fb201ukbf4ahe92/master')
const response = await client.request(`
query MyQuery {
posts {
slug
title
content {
html
json
}
}
}
` )
return response.posts
}
async function addContent(post) {
// Isolates the content array from the JSON
const content = post.content.json.children
// Passes the content to the astToHtmlString method
const rendered = await astToHtmlString({ content: content })
return {...post, rendered}
}
module.exports = async () => {
const posts = await getHygraphPosts()
// Loops through each posts and runs the addContent function
const postsWithContent = posts.map(addContent)
// Returns the new data to the 11ty template
return postsWithContent
}

After making this change, the rendered variable is now accessible by our post template.

---
pagination:
data: posts
size: 1
alias: post
permalink: "/post/{{ post.slug | slug }}/index.html"
layout: "base.html"
---
<h1 class="mb-4 text-4xl font-extrabold tracking-tight leading-none text-gray-900 md:text-5xl lg:text-6xl dark:text-white">{{ post.title }}</h1>
{{ post.rendered }}

Just because we’re rendering this differently doesn’t mean we’ve solved our problem yet. Our site should look exactly as it did before. That’s because we’re using the default renderers. We can change that by providing new ways to render each content type.

Creating custom HTML for each content typeAnchor

A screenshot with the page with paragraph, list, and code styles now in place

The astToHtmlString method accepts another parameter: renderers.

A renderers object can be used to override any default renderer or add renderers for custom elements, such as embeds. Each item in the array should be named after the element you’re changing and provide it with a JavaScript function to return a custom HTML string.

// Basic syntax
// <tag name>: (node) => `custom string`
const renderers = {
h1: ({ children }) => `<h1 class="mb-4 text-4xl text-gray-900 md:text-5xl lg:text-6xl ${sharedClasses}">${children}</h1>`,
}

The argument of each custom function is the data from each content type node. The main need we have here will be for the children data off that object. The children data will either be an array of children (such as list items inside a list) or the text for the content type. This makes it easier to handle things like ordered and unordered lists.

Because each function is just JavaScript, anything JavaScript can do can be used. Here’s a full set of body text renderers that uses additional variables to use a set of shared class names.

const sharedClasses = "dark:text-white"
const bodyClasses = "text-lg text-gray-700"
const renderers = {
h1: ({ children }) => `<h1 class="mb-4 text-4xl text-gray-900 md:text-5xl lg:text-6xl ${sharedClasses}">${children}</h1>`,
h2: ({ children }) => `<h1 class="mb-4 text-3xl text-gray-900 md:text-5xl lg:text-6xl ${sharedClasses}">${children}</h1>`,
h3: ({ children }) => `<h3 class="text-3xl ${sharedClasses}">${children}</h3>`,
h4: ({ children }) => `<h4 class="text-2xl ${sharedClasses}">${children}</h3>`,
h5: ({ children }) => `<h5 class="text-xl ${sharedClasses}">${children}</h3>`,
h6: ({ children }) => `<h6 class="text-large ${sharedClasses}">${children}</h3>`,
p: ({ children }) => `<p class="my-4 text-lg ${bodyClasses} ${sharedClasses}">${children}</p>`,
ul: ({ children }) => `<ul class="list-disc list-inside my-4 text-lg ${bodyClasses} ${sharedClasses}">${children}</ul>`,
ol: ({ children }) => `<ol class="list-decimal list-inside my-4 text-lg ${bodyClasses} ${sharedClasses}">${children}</ol>`,
li: ({ children }) => `<li class="my-2 text-lg ${bodyClasses} ${sharedClasses}">${children}</li>`,
code: ({ children }) => `<code class="bg-gray-100 dark:bg-gray-800 rounded-md p-2 text-sm ${sharedClasses}">${children}</code>`,
code_block: ({ children }) => `<pre class="bg-gray-100 dark:bg-gray-800 overflow-y-scroll rounded-md p-2 text-sm ${sharedClasses}">${children}</pre>`,
}

Once those renderers are defined, you can add that as an argument for the astToHtmlString in the addContent function.

async function addContent(post) {
const content = post.content.json.children
const rendered = await astToHtmlString({ content: content, renderers })
return {...post, rendered}
}

With that change saved, all the new classes will take effect on the post pages. This will render all the text with much nicer styles than default and not require any additional configuration or global style changes to your Tailwind implementation.

Taking this furtherAnchor

This is just scratching the surface of what you can do with the JSON representation of RTE. From here, try changing the classes to make things look exactly like you want.

If you’re up for a bigger challenge use something like Prism.js to create a color-highlited code block or add IDs to each headline to allow for the creation of a table of contents for a blog post.

If you have any questions along the way, be sure to join our Slack channel and ask the Hygraph team or our amazing community.