DEV Community

India Owens
India Owens

Posted on

Adding Rich Text to Shiny for Python with Quill.js

If you've been building internal data tools with Shiny for Python and hit the wall where your users need to format text — bold, italics, headings, the works — you've probably noticed there's no native rich text component. There's a good reason for that: Shiny is designed for lightweight, reactive data applications, not document editors.

But production doesn't care about design philosophy. I ran into this exact situation building an internal specification management tool: the application had to support rich text because the analysts using it needed formatted notes alongside their data inputs. Shiny was the right framework for everything else, so I needed a way to extend it rather than abandon it.

The solution is Quill.js — an API-driven rich text editor that plays nicely with Shiny's JavaScript extension model. This article walks through exactly how to wire them together, including the production-grade version with a task button that handles async database submissions.

⚠️ Before you reach for this Only do this if you genuinely need rich text. Shiny doesn't support it natively for a reason — the added complexity is real. If plain text works for your use case, use ui.input_text_area() and move on.

Setup and prerequisites

One important decision before you start: use Shiny Core, not Shiny Express. I initially built this in Express and ran into deployment issues when the app was hosted on a Shiny Server I didn't control. Core is more explicit and more predictable across environments. If you're unsure which to use, the official comparison is worth reading — but for anything heading to production, Core is the safer choice.

You'll need two files:

  • app.py — your Shiny application
  • js/app.js — the JavaScript bridge between Quill and Shiny

No pip installs needed. Quill loads from a CDN directly in your app's <head>.

How the communication works

Before the code, it's worth understanding why the integration is structured the way it is. Shiny's reactive system only knows about inputs it defines — and Quill's editor lives entirely outside that system. To bridge them, we use Shiny's custom message API:

  1. When the user clicks Submit, Python sends a custom message to JavaScript via session.send_custom_message()
  2. JavaScript receives it, reads the Quill editor content, and pushes it back to Python using Shiny.setInputValue()
  3. Python's reactive system picks it up as a regular input and handles it from there

💡 Key detail When you have multiple Quill editors, the last setInputValue() call should use {priority: 'event'}. This forces Shiny to treat it as a new event even if the value hasn't changed, ensuring your reactive handler always fires. Trigger your reactive on this last input.

Part 1: Basic implementation with an action button

Step 1 — app.py

app.py

from shiny import App, reactive, render, ui
from pathlib import Path

js_file = Path(__file__).parent / "js" / "app.js"

app_ui = ui.page_fluid(
    ui.tags.head(
        # Load Quill stylesheet from CDN
        ui.tags.link(
            rel="stylesheet",
            href="https://cdn.jsdelivr.net/npm/[email protected]/dist/quill.snow.css"
        ),
        # Load Quill library from CDN
        ui.tags.script(
            src="https://cdn.jsdelivr.net/npm/[email protected]/dist/quill.js"
        ),
    ),
    ui.include_js(js_file),
    ui.card(
        ui.card_header("Quill Input Demo"),
        ui.input_text(
            id="shiny_input_text",
            label="Standard Shiny Text Input",
        ),
        # Option A: initialize Quill on an HTML element
        ui.HTML('<div id="quill-text-box-using-HTML"></div>'),
        # Option B: initialize Quill on a div tag
        ui.tags.div(id="quill-text-box-using-div"),
        ui.output_ui("handle_rich_text"),
        ui.input_action_button("submit", "Submit"),
    )
)

def server(input, output, session):

    @reactive.effect
    @reactive.event(input.submit)
    async def handle_submit():
        # Signal JavaScript to read and return Quill content
        await session.send_custom_message(
            "get_quill_content", {"message": "get_quill_content"}
        )

    @render.ui
    @reactive.event(input.div_editor_raw_rich)  # trigger on the LAST input set
    def handle_rich_text():
        return ui.TagList(
            ui.tags.p("Standard text input:"),
            ui.tags.p(input.shiny_input_text()),
            ui.tags.p("Quill plain text:"),
            ui.tags.p(input.html_editor_plain_text()),
            ui.tags.p("Quill raw HTML:"),
            ui.tags.p(input.div_editor_raw_rich()),
        )

app = App(app_ui, server)
Enter fullscreen mode Exit fullscreen mode

Step 2 — js/app.js

js/app.js

$(document).on('shiny:connected', function(event) {

    // Initialize both Quill editors once Shiny is ready
    var html_editor = new Quill('#quill-text-box-using-HTML', {
        theme: 'snow',
        bounds: '#quill-text-box-using-HTML'
    });

    var div_editor = new Quill('#quill-text-box-using-div', {
        theme: 'snow',
        bounds: '#quill-text-box-using-div'
    });

    // Listen for Python's signal to read editor content
    Shiny.addCustomMessageHandler('get_quill_content', function(message) {

        var html_editor_plain_text = html_editor.getText();
        var div_editor_raw_rich = div_editor.root.innerHTML;

        // Set the plain text value first
        Shiny.setInputValue('html_editor_plain_text', html_editor_plain_text);

        // Set the rich HTML value LAST, with priority: 'event'
        // This ensures Shiny fires the reactive even if content hasn't changed
        Shiny.setInputValue('div_editor_raw_rich', div_editor_raw_rich, {
            priority: 'event'
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

Part 2: Production version with a task button

The action button version works for simple demos. But in a real application where submitting the form triggers database writes or API calls, you need the UI to reflect that the app is busy. That's where input_task_button comes in — it has built-in busy and ready states. The catch is that async tasks change how you structure the Quill handoff.

The key addition is @reactive.extended_task, which lets the Quill message round-trip complete asynchronously before the rest of your submit logic runs. Without it, your reactive handler fires before JavaScript has had a chance to push the Quill content back to Python.

app.py — production version

from shiny import App, reactive, render, ui
from pathlib import Path

js_file = Path(__file__).parent / "js" / "app.js"

app_ui = ui.page_fluid(
    ui.tags.head(
        ui.tags.link(
            rel="stylesheet",
            href="https://cdn.jsdelivr.net/npm/[email protected]/dist/quill.snow.css"
        ),
        ui.tags.script(
            src="https://cdn.jsdelivr.net/npm/[email protected]/dist/quill.js"
        ),
    ),
    ui.include_js(js_file),
    ui.card(
        ui.card_header("Quill Input Demo"),
        ui.input_text(id="shiny_input_text", label="Standard Text Input"),
        ui.HTML('<div id="quill-text-box-using-HTML"></div>'),
        ui.tags.div(id="quill-text-box-using-div"),
        # auto_reset=False keeps the button in "busy" state
        # until we explicitly reset it after the async work completes
        ui.input_task_button("submit", "Submit", auto_reset=False),
    )
)

def server(input, output, session):

    @reactive.extended_task
    async def get_rich_text():
        # Signal JS to read Quill and push values back to Python
        await session.send_custom_message(
            "get_quill_content", {"message": "get_quill_content"}
        )

    @reactive.effect
    @reactive.event(input.submit)
    def handle_submit():
        # Put the button into busy state immediately on click
        ui.update_task_button("submit", state="busy")
        # Kick off the async Quill read
        get_rich_text()

    @reactive.effect
    @reactive.event(input.div_editor_raw_rich)  # fires after JS sets the last value
    def handle_rich_text():
        # At this point all Quill values are available in input.*
        # Do your database write or API call here

        # Reset button to ready state when work is complete
        ui.update_task_button("submit", state="ready")

app = App(app_ui, server)
Enter fullscreen mode Exit fullscreen mode

✅ Why extended_task matters here @reactive.extended_task allows the async message round-trip (Python → JS → Python) to complete without blocking Shiny's reactive graph. Without it, handle_rich_text could fire before JavaScript has pushed the Quill content back, and you'd get empty or stale values.

Gotchas I hit in production

A few things that will cost you time if you don't know them going in:

  • Shiny Express broke on my server. I started with Express, hit deployment issues on a Shiny Server I didn't control, and switched to Core. If you're self-hosting or deploying to a managed server, Core is safer.
  • Order of setInputValue calls matters. Always set your "trigger" input — the one your reactive listens to — last, and always with {priority: 'event'}. If you set it first, Shiny might not register the earlier values in time.
  • Don't initialize Quill before shiny:connected. If Quill initializes before Shiny's session is ready, the custom message handler won't exist yet and the bridge silently breaks.
  • Quill returns HTML, not plain text. div_editor.root.innerHTML gives you raw HTML with tags. If you're storing to a database, decide upfront whether you want the HTML or the plain text (editor.getText()), and sanitize accordingly before writing.

When to use this — and when not to

This pattern works well for internal tools where you control the environment and your users need formatted input alongside data. It's less appropriate if Shiny isn't already your framework of choice, or if rich text is the primary feature rather than a supporting one — in that case, a dedicated web framework will serve you better.

The full working code for both versions is available on GitHub. The next part of this series covers multi-page routing in Shiny for Python with Starlette — building the foundation that lets the form we built here live alongside a database view page under a single application and URL structure.


References

  1. Quill.js Quickstart — Quill
  2. Shiny Express vs Core — Posit

Top comments (0)