Markdown
A lightweight, dependency-free way to render markdown in Reflex using Tailwind Typography.
Most component libraries, Radix included, ship a Markdown component that parses and renders markdown on the client, in the browser, on every render. That's a reasonable default, but it also means shipping a parser to the client and mapping every markdown token to a component.
This page shows a different pattern: parse markdown to HTML once, on the server, with Python's markdown library, then hand the raw HTML to rx.html with a Tailwind Typography class attached. There's no client-side parser and no component-per-token mapping, just HTML and CSS.
This isn't a component with an API to learn. It's a small utility function plus a CSS class, both of which you're expected to copy in and adapt.
How it works
Three pieces work together:
markdownis a Python library that converts a markdown string into an HTML string.- A prose class, built on
@tailwindcss/typography, styles raw HTML elements (h1,p,table,blockquote, etc.) to match your UI. rx.htmlrenders a raw HTML string as a component, with the prose class applied.
def render_markdown(text: str) -> rx.Component:
if not text.strip():
return rx.el.div()
html = markdown.markdown(text, extensions=["fenced_code", "tables"])
return rx.html(html, class_name="prose-content")
That's the whole pattern. Everything past this point, including which extensions to enable, how to style the class, and whether to post-process the HTML at all, is optional and up to your project.
Setup
Install markdown
pip install markdown
Extensions worth knowing about:
Full list in the Python-Markdown extension docs.
Add Tailwind Typography
Because @tailwindcss/typography gets processed as a JavaScript file, it should be added directly to rxconfig.py plugins.
import reflex as rx
from reflex.plugins.shared_tailwind import TailwindConfig
config = rx.Config(
...,
plugins=[
rx.plugins.TailwindV4Plugin(
config=TailwindConfig(plugins=["@tailwindcss/typography"])
),
],
)
Write a prose class
The prose class from Typography is unstyled but structured on its own; most projects add a class on top of it to bring in their own theme. Below is one example. Treat the class name, colors, and spacing as placeholders, not requirements.
Two rules are worth keeping regardless of theme, since they fix rendering quirks rather than express a style opinion:
- Resetting
::before/::aftercontent oncodeandblockquote. Typography adds decorative quote marks by default, which most non-editorial UIs don't want. - Resetting
pre > codepadding and background. Without it you get double backgrounds, since bothpreand thecodeinside it are styled.
@layer components {
/* name and theme this however fits your project */
.prose-content {
@apply prose dark:prose-invert max-w-none w-full;
/* fixes double-styling on code blocks */
@apply prose-pre:p-0
prose-pre:overflow-x-auto
prose-pre:before:content-none
prose-pre:after:content-none
prose-code:before:content-none
prose-code:after:content-none;
/* everything below is a styling choice, not a requirement */
@apply prose-code:rounded-md
prose-code:bg-muted
prose-code:px-1.5
prose-code:py-0.5
prose-code:text-sm
prose-pre:rounded-lg
prose-pre:bg-muted
prose-pre:border;
}
}
Put it together
from components.markdown import render_markdown
def page() -> rx.Component:
return render_markdown("""
# Welcome
Some **markdown** content, rendered straight to HTML.
""")
What markdown elements look like
Everything below is real markdown, run through render_markdown, styled with this site's own prose class. It's one possible look, not the only one.
Headings
Typography styles h1–h6 out of the box. Override with prose-h1:* / prose-h2:* modifiers as needed.
# Page title
## Section heading
Text formatting
Bold, italic, and inline code markers map straight to their HTML equivalents.
You can use bold text, italic text, or inline code inside a normal paragraph.
You can use **bold text**, _italic text_, or `inline code`.
Lists
- List markers can be restyled with
prose-li:marker:* - Ordered and unordered lists both inherit standard Typography spacing
- Nested lists work out of the box
- First item
- Second item
- Nested item
Links
Links inherit Typography's default underline-on-hover treatment. Restyle with prose-a:*.
Code blocks
Fenced code blocks come from the fenced_code extension. Typography wraps them in a pre > code pair, and the double-styling fix from the CSS above keeps that pairing clean.
def hello(name: str) -> str:
return f"Hello, {name}!"
Blockquotes
Typography's default blockquote is an italic left border. Some projects prefer a bordered callout instead. Both are reasonable; restyle with prose-blockquote:*.
A blockquote, styled however fits your content.
> A blockquote, styled however fits your content.
Optional: handling table overflow
Markdown tables render as plain <table> elements, which can overflow their container on narrow screens or long content. This isn't specific to this rendering approach; it's a general HTML table problem. But it's common enough with markdown-sourced tables that it's worth a note.
One way to handle it is to post-process the generated HTML and wrap each <table> in a scrollable container before it reaches rx.html. This is one option among several. You could just as easily reach for an overflow-x-auto utility on a parent element, a CSS container query, or leave tables unwrapped if your content never has wide ones.
from bs4 import BeautifulSoup
def wrap_tables(html: str) -> str:
soup = BeautifulSoup(html, "html.parser")
for table in soup.find_all("table"):
wrapper = soup.new_tag("div")
wrapper["class"] = "w-full overflow-x-auto"
table.wrap(wrapper)
return str(soup)
| Column A | Column B |
| -------- | -------- |
| Value 1 | Value 2 |
If you go this route, it requires beautifulsoup4:
pip install beautifulsoup4
def render_markdown(text: str) -> rx.Component:
if not text.strip():
return rx.el.div()
html = markdown.markdown(text, extensions=["fenced_code", "tables"])
html = wrap_tables(html) # optional
return rx.html(html, class_name="prose-content")