Understanding the Ghosting Issue in Streamlit

Streamlit's React-based frontend uses a virtual DOM diffing algorithm to update the user interface efficiently. When you use custom routing (like st.sidebar.radio) instead of Streamlit's native multipage setup, Streamlit executes the entire script from top to bottom. If you render raw HTML via st.markdown(..., unsafe_allow_html=True) on one page and then switch to a page with only native components, Streamlit can get confused. Because raw HTML elements sit outside Streamlit's standard React component lifecycle, they sometimes fail to unmount, resulting in "ghost" elements that persist and get pushed down the page.

Solution 1: Force DOM Cleanup with st.empty()

The most reliable way to fix this in custom routing is to wrap your page rendering logic inside an st.empty() container. This forces Streamlit to clear the entire container's DOM tree before rendering the next page's content.

Modify your app.py as follows:

import streamlit as st

st.set_page_config(page_title="Demo", layout="wide")

page = st.sidebar.radio("Navigation", ["Heavy Page", "Light Page"])

# Create a single empty container that acts as a viewport
viewport = st.empty()

with viewport.container():
    if page == "Heavy Page":
        from pages import page_heavy
        page_heavy.render()
    elif page == "Light Page":
        from pages import page_light
        page_light.render()

Solution 2: Upgrade to Native Navigation (Recommended)

If you are using Streamlit version 1.35.0 or later, you should migrate from your custom radio-button router to Streamlit's native st.navigation and st.Page API. Native routing completely destroys and rebuilds the DOM context between page changes, preventing any HTML or CSS bleeding.

Here is how you can rewrite your app.py using the modern API:

import streamlit as st

# Define pages
heavy_page = st.Page("pages/page_heavy.py", title="Heavy Page")
light_page = st.Page("pages/page_light.py", title="Light Page")

# Initialize navigation
pg = st.navigation([heavy_page, light_page])

st.set_page_config(page_title="Demo", layout="wide")
pg.run()

Why This Works

  • DOM Isolation: st.empty() acts as a single React node. When its contents change, React unmounts the old children completely, taking the rogue HTML with it.
  • Native Routing: st.navigation handles page state and cleanup at the framework level, ensuring a clean slate on every transition.