Skip to main content

Beyond the Spin: Building a Creative Loading Bar in Streamlit for Better UX

8 min read

When your analytics app waits on Databricks, an orange text strip says ‘hang on’ — but a lawn mower cutting grass says ‘we meant for you to wait.’ This seemingly small difference in messaging and presentation can dramatically alter a user’s perception of your application’s performance. As data engineers and Streamlit practitioners, we often focus on optimizing the actual load times. But what about the perceived load times? That’s where a creative loading bar in Streamlit can make all the difference, transforming a moment of frustration into a moment of delight.

Try it: Open the interactive before/after demo — simulate both loaders side by side.

I’ve spent countless hours building data applications, and one recurring challenge is the dreaded waiting game. Whether it’s querying large datasets from Databricks or processing complex requests with an AI agent like Genie, there are inevitable delays. My goal is always to deliver a smooth, intuitive user experience (UX), even when the backend is churning away.

The Problem: Long Waits and the Generic ‘Hang On’

Imagine a user interacting with a Streamlit dashboard. They click a button or select a filter, and the application needs to fetch data that might take anywhere from 5 to 20 seconds. This isn’t an eternity, but in today’s instant-gratification world, it feels like it.

For a long time, my applications, like many others, displayed a simple, static loading message. While functional, these generic messages often fall short in communicating progress effectively. They can feel more like an error message than an indication that the system is actively working on your request. This was particularly true for our internal analytics apps, which frequently query Databricks for fresh insights. The user sees a static text strip, and their mind immediately thinks, “Is it broken? Is it stuck? How long will this be?”

Before: The Generic Orange Banner

My initial approach to handling these waits was straightforward: a context manager that displayed a simple orange banner. It served its purpose by indicating something was happening, but it lacked personality and dynamism.

Here’s a simplified look at what the “before” state might have looked like in utils/layout.py:

# utils/layout.py (simplified 'before' version)
import streamlit as st
import contextlib
import time

@contextlib.contextmanager
def data_load(message: str = "Loading data..."):
    """
    A simple context manager for displaying a static loading message.
    """
    placeholder = st.empty()
    try:
        # Display a static orange banner
        placeholder.markdown(
            f"""
            <div style="
                background-color: #F35321; /* Husqvarna orange */
                color: white;
                padding: 10px;
                border-radius: 5px;
                text-align: center;
                font-weight: bold;
            ">
                {message}
            </div>
            """,
            unsafe_allow_html=True
        )
        yield
    finally:
        placeholder.empty() # Clear the message when done

# Example usage in a Streamlit app
# def fetch_sales_data():
#     time.sleep(5) # Simulate a long query
#     return {"sales": 1000, "region": "North"}

# st.title("Sales Dashboard")

# if st.button("Load Sales Data"):
#     with data_load("Loading sales data from Databricks..."):
#         sales_df = fetch_sales_data()
#         st.write("Sales data loaded successfully!")
#         st.json(sales_df)

This data_load context manager was applied across dozens of dashboard pages. It worked, but it felt… clinical. There was no animation, just static text like “Loading sales data from Databricks…”. The same look on 60+ dashboard pages, while consistent, became monotonous. It didn’t convey progress; it just stated a fact. The user’s wait felt longer, and the experience was entirely neutral.

After: The Automower Animation – A Creative Loading Bar in Streamlit

I realized we could do better. Instead of a sterile orange strip, what if we could infuse some brand identity and humor into these waiting moments? That’s where the idea of the “Automower” loading animation came from. Our company is Husqvarna, known for its iconic orange and, yes, robotic lawnmowers. Why not embrace that?

The goal was to create a loading experience that was:

  1. Dynamic: Motion helps convey progress.
  2. Branded: Instantly recognizable and aligned with our identity.
  3. Humorous: Lighten the mood during a wait.
  4. Informative: Still convey what’s happening.

The result was an Automower animation, implemented in a new module utils/mower_load.py (aliased as data_load in layout.py for seamless integration).

Here’s what the Automower provides:

  • st.html() with CSS animation: A continuously rolling grass strip, a small Husqvarna Automower driving back and forth (a 5-second loop), with spinning wheels and even a blinking sensor.
  • Headline: “Still loading — cutting the grass in the meantime…”
  • Detail line: A custom message provided by the developer, under the headline, explaining what is loading.
  • Accessibility: Respects prefers-reduced-motion to disable animations for users who prefer it.

You can imagine how much more engaging this is than a static orange banner. The motion tricks the brain into perceiving the wait as shorter, and the humor creates a more positive user experience.

Here’s a conceptual look at the mower_load context manager, leveraging st.html():

# utils/mower_load.py (conceptual 'after' version)
import streamlit as st
import contextlib
import time

@contextlib.contextmanager
def mower_load(detail_message: str = "Preparing analysis..."):
    """
    A creative context manager for displaying an animated Automower loading screen.
    Uses st.html() for rich HTML and CSS animations.
    """
    placeholder = st.empty()
    try:
        # The actual CSS and HTML for the Automower animation is extensive
        # and would typically reside in a separate custom.css file and a complex HTML string.
        # For this example, we'll provide a simplified representation.
        # Imagine 'automower_loading.html' contains the full animation logic.

        # In a real scenario, we'd load this from a file or a pre-defined string.
        # This HTML would include CSS for the grass, mower, animation keyframes, etc.
        # For a full example, refer to demo/automower_loading.html
        automower_html = f"""
        <style>
            .mower-container {{
                display: flex;
                flex-direction: column;
                align-items: center;
                justify-content: center;
                padding: 20px;
                background-color: #f0f2f6; /* Light background */
                border-radius: 10px;
                box-shadow: 0 4px 8px rgba(0,0,0,0.1);
                margin-bottom: 20px;
            }}
            .mower-animation-area {{
                width: 100%;
                max-width: 400px;
                height: 80px;
                overflow: hidden;
                position: relative;
                background-color: #e0ffe0; /* Light green grass */
                border-radius: 5px;
                margin-top: 15px;
                margin-bottom: 15px;
            }}
            .grass {{
                position: absolute;
                width: 200%; /* Wider than container for rolling effect */
                height: 100%;
                background: repeating-linear-gradient(90deg, #a0d468 0 10px, #9ac961 10px 20px);
                animation: rollGrass 5s linear infinite;
            }}
            .mower {{
                position: absolute;
                bottom: 10px;
                left: 0;
                width: 60px;
                height: 40px;
                background-color: #F35321; /* Husqvarna orange */
                border-radius: 50% 50% 10% 10% / 100% 100% 10% 10%;
                display: flex;
                align-items: center;
                justify-content: center;
                animation: driveMower 5s linear infinite alternate; /* Back and forth */
            }}
            .mower-wheel {{
                position: absolute;
                width: 15px;
                height: 15px;
                background-color: #333;
                border-radius: 50%;
                animation: spinWheels 1s linear infinite;
            }}
            .mower-wheel.front {{ left: 5px; }}
            .mower-wheel.back {{ right: 5px; }}
            .mower-sensor {{
                position: absolute;
                top: 5px;
                width: 5px;
                height: 5px;
                background-color: #fff;
                border-radius: 50%;
                animation: blinkSensor 1s step-end infinite alternate;
            }}

            @keyframes rollGrass {{
                from {{ transform: translateX(0); }}
                to {{ transform: translateX(-50%); }}
            }}
            @keyframes driveMower {{
                from {{ left: 0%; }}
                to {{ left: calc(100% - 60px); }} /* Mower width */
            }}
            @keyframes spinWheels {{
                from {{ transform: rotate(0deg); }}
                to {{ transform: rotate(360deg); }}
            }}
            @keyframes blinkSensor {{
                0%, 100% {{ background-color: #fff; }}
                50% {{ background-color: #ff0; }} /* Yellow blink */
            }}

            /* Respect prefers-reduced-motion */
            @media (prefers-reduced-motion: reduce) {{
                .grass, .mower, .mower-wheel, .mower-sensor {{
                    animation: none !important;
                }}
            }}
        </style>
        <div class="mower-container">
            <h3>Still loading — cutting the grass in the meantime...</h3>
            <p style="font-style: italic; color: #555;">{detail_message}</p>
            <div class="mower-animation-area">
                <div class="grass"></div>
                <div class="mower">
                    <div class="mower-wheel front"></div>
                    <div class="mower-wheel back"></div>
                    <div class="mower-sensor"></div>
                </div>
            </div>
        </div>
        """
        st.html(automower_html) # Use st.html for embedding rich content
        yield
    finally:
        placeholder.empty()

# Example usage in a Streamlit app
# def fetch_complex_report():
#     time.sleep(7) # Simulate a longer query
#     return {"report": "Detailed analysis"}

# st.title("Advanced Analytics")

# if st.button("Generate Report"):
#     with mower_load("Fetching market trends from Databricks and processing with AI..."):
#         report_data = fetch_complex_report()
#         st.write("Report generated!")
#         st.json(report_data)

For a full, live demonstration of the Automower loading bar, I recommend checking out demo/automower_loading.html in the project repository. It truly brings the concept to life!

Technical Deep Dive: Same API, Swapped Implementation

One of the most satisfying aspects of this transformation was that it required zero changes at the call-site in our 60+ dashboards. This was crucial for a smooth rollout and minimal refactoring effort.

The magic happened by maintaining the same context manager API:

with data_load("Loading sales scorecard from Databricks"):
    df = fetch_sales_data_from_databricks()

Initially, data_load pointed to the old orange banner implementation. To switch to the Automower, I simply aliased mower_load as data_load in our central utils/layout.py file:

# utils/layout.py (after the change)
from .mower_load import mower_load as data_load # Alias the new implementation
# ... (rest of layout.py)

This simple alias allowed us to deploy the new experience across the entire application instantly, without touching a single with data_load(...) statement. It’s a testament to good API design – isolating the what (displaying a loading message) from the how (which specific loading message to display).

The core technical shift was from using st.markdown(unsafe_allow_html=True) to st.html(). While st.markdown can render HTML, st.html() is Streamlit’s dedicated and more robust component for embedding complex HTML and CSS, offering better isolation and control. This was essential for the intricate CSS animations required for the Automower. All the CSS for the rolling grass, mower movement, spinning wheels, and blinking sensor is encapsulated within the mower_load module and custom.css.

Usage in AI Chat (Genie)

The Automower really shines in our AI chat interface, Genie. When a user asks a complex question, the AI might take 5-30 seconds to query various data sources (like Databricks) and formulate an answer. During this time, a generic “Thinking…” message feels inadequate.

With mower_load, we wrap the AI processing:

# genie_chat.py (simplified)
from utils.mower_load import mower_load # Direct import for specific use case

def ask_analytics(query: str):
    # Simulate AI processing
    time.sleep(15)
    return f"AI response for: {query}"

user_query = st.chat_input("Ask Genie...")

if user_query:
    with st.chat_message("user"):
        st.write(user_query)

    with st.chat_message("assistant"):
        with mower_load("Querying Genie and preparing analysis..."):
            ai_response = ask_analytics(user_query)
            st.write(ai_response)

This context provides a much more pleasant wait for the user, aligning with the playful yet powerful persona of our AI assistant.

Rollout Strategy

We rolled this out incrementally. The first step (aea695d) was to implement mower_load specifically for the chat interface, where the waits were often longer and the need for a better UX was most acute. Once it proved successful and well-received, we made the global switch (8ad5af3) by aliasing mower_load to data_load for the entire application. This cautious approach allowed us to test and gather feedback before a full deployment.

Comparison: Old vs. Automower

Let’s put the two approaches side-by-side to highlight the impact:

DimensionOld orange bannerAutomower
Wait feelsLonger, staticShorter, motion = progress
BrandGeneric loadingHusqvarna / lawn / humor
ToneNeutral enterpriseLight humor, engaging
TechOne inline CSS line (st.markdown)mower_load module + custom CSS (st.html())

The Lesson: Wait Time is UX; Small Branded Details Help

The most significant takeaway from this project is that wait time is a critical component of user experience. It’s not just about how fast your backend is; it’s about how that speed (or lack thereof) is communicated to the user.

  • Motion conveys progress: Even if the underlying process takes the same amount of time, an animated loading bar makes the wait feel shorter. Our brains are wired to interpret movement as activity and progress.
  • Branding and humor matter: Injecting personality into these often-overlooked moments can significantly improve user perception. A branded, creative loading bar transforms a mundane wait into a small, memorable interaction. It reinforces brand identity and can even make users smile.
  • Crucial for AI chat: In AI applications, where response times can vary wildly depending on query complexity, a robust and engaging loading indicator is invaluable. It manages user expectations and keeps them engaged during the 5-30 second processing window. This is where a creative loading bar Streamlit UX really shines.
  • Streamlit’s flexibility: Streamlit’s st.html() component provides an excellent escape hatch for when you need to go beyond its standard widgets and create highly customized, interactive UI elements. It empowers developers to build rich, branded experiences directly within their Streamlit apps.

By focusing on these “small” details, we elevate the entire application experience. It shows users that we care about every interaction, even the ones that involve waiting.

Where It’s Used Today

Today, the Automower loading bar is an integral part of our Streamlit ecosystem:

  • Dashboards with @st.cache_data queries: While @st.cache_data significantly reduces subsequent load times, the initial fetch still benefits from the engaging loading animation.
  • genie_chat.py: As highlighted, it’s essential for providing a smooth experience during AI query processing.

This project is a perfect example of how a relatively small investment in UX design, powered by Streamlit’s flexibility, can yield significant improvements in user satisfaction and perceived performance. Don’t just tell your users to “hang on” – give them something delightful to watch while they wait.

Tools Used in This Article

This article mentions several tools from my tech stack.

Get insights and updates first

Subscribe to get updates on agentic engineering, data pipelines, MCP infrastructure, and new projects straight to your inbox.