Help needed: My Streamlit quiz app's layout "jumps" as questions change!

Hello, Streamlit Community! :waving_hand:

I’ve been building a quiz app, but I’m running into a UI issue that I’m hoping you can help with.

My Goal: I’m trying to create a stable user interface where the layout of the app remains consistent as the user navigates between different questions.

The Problem: The app’s layout “jumps” up and down. This happens because the height of the Option Box changes depending on the number of options in a given question (e.g., a True/False question has a shorter options list than a multiple-choice question). This dynamic height pushes the Button Box and Answer Box up or down, which is disruptive to the user experience.

I’ve tried using CSS min-height on the options container, but it doesn’t seem to be reliably enforced, and the container still resizes.

Could you please take a look at the minimal, reproducible code below? I’d appreciate any guidance on how to fix the height of the “Option Box” so that the “Button Box” and “Answer Box” remain at a fixed position relative to the page.

Thank you in advance!


import streamlit as st
import pandas as pd # Used for structure, though not for loading from disk in this minimal example

— Streamlit Page Configuration —

st.set_page_config(page_title=“Minimal Quiz App - Height Issue Demo”, page_icon=“:memo:”, layout=“centered”)

st.title(“Quiz App: Height Issue Demo”)

— Session State Initialization —

if “current_q_index” not in st.session_state:
st.session_state.current_q_index = 0
st.session_state.responses = {}
st.session_state.revealed = {} # To control when answers are shown

— Hardcoded Questions (for demonstration) —

questions_data = [
{
“qid”: “Q001”,
“question”: “Is 2 + 2 equal to 4?”,
“type”: “true/false”,
“option_A”: “True”,
“option_B”: “False”,
“correct_options”: “A”,
“explanation”: “Simple arithmetic: 2 + 2 indeed equals 4.”
},
{
“qid”: “Q002”,
“question”: “What is 2 + 2?”,
“type”: “single”,
“option_A”: “1”,
“option_B”: “2”,
“option_C”: “3”,
“option_D”: “4”,
“correct_options”: “D”,
“explanation”: “2 + 2 = 4. This is a basic addition problem.”
},
{
“qid”: “Q003”,
“question”: “Which of these statements are true about 2 + 2?”,
“type”: “multiple”,
“option_A”: “2 + 2 is less than 1”,
“option_B”: “2 + 2 is less than 2”,
“option_C”: “2 + 2 is less than 3”,
“option_D”: “2 + 2 is less than 5”,
“correct_options”: “D”, # Only D is correct here based on the options
“explanation”: “2 + 2 = 4. Only ‘2 + 2 is less than 5’ is a true statement among the options.”
}
]

Convert to DataFrame for easier handling (mimics your original app)

quiz_df = pd.DataFrame(questions_data)
total_questions = len(quiz_df)

— CSS for Styling —

st.markdown(“”"

:root { --lh: 1.2em; } /* Styling for the box titles */ .box-title-padding { padding-top: 25px; /* Adds space for the box title */ } /* Question title styling */ .qtitle { border-radius: 8px; padding: 10px 12px; font-weight: 600; font-style: italic; background: #ffffff; color: #111111; min-height: calc(5 * var(--lh)); display: flex; align-items: flex-start; margin-bottom: 8px; } /* Marker for the options container */ .marker-optwrap + div[data-testid="stVerticalBlock"] { background: #f2f2f2; /* Grey background for the outer options wrapper */ padding: 8px; border-radius: 12px; margin-bottom: 10px; overflow: hidden; position: relative; /* Set parent to relative for absolute positioning of title */ } /* Options box (inner white container) - THIS IS THE ONE WE WANT TO FIX HEIGHT */ .marker-optwrap + div[data-testid="stVerticalBlock"] > div[data-testid="stVerticalBlock"] { background-color: #ffffff; border: 1px solid rgba(0,0,0,0.08); border-radius: 8px; min-height: 250px !important; /* <--- PROBLEM AREA: This min-height is not consistently applied */ padding-top: 2px; } /* Consistent gap for options (inside the white box) */ .marker-optwrap + div[data-testid="stVerticalBlock"] > div[data-testid="stVerticalBlock"] > div[data-testid="stVerticalBlock"] { display: flex; flex-direction: column; gap: 1.5rem; padding-left: 1rem; padding-right: 1rem; } /* Button box container */ .button-box-container { position: relative; /* Set parent to relative for absolute positioning of title */ } /* Answer/Explanation Box styling */ .answer-box { border-radius: 12px; border: 1px solid rgba(0,128,0,0.25); box-shadow: 0 2px 8px rgba(0,128,0,0.08); margin-top: 8px; margin-bottom: 20px; padding: 12px 14px; min-height: 100px; /* Placeholder height for empty answer box */ position: relative; /* Set parent to relative for absolute positioning of title */ } @media (prefers-color-scheme: dark) { .qtitle { background: #1a1a1a; color: #f0f2f6; } .marker-optwrap + div[data-testid="stVerticalBlock"] { background: #2a2a2a; } .marker-optwrap + div[data-testid="stVerticalBlock"] > div[data-testid="stVerticalBlock"] { background-color: #1a1a1a; border-color: #444444; min-height: 250px !important; /* Dark mode fixed height */ } .marker-optwrap + div[data-testid="stVerticalBlock"] > div[data-testid="stVerticalBlock"] > div[data-testid="stVerticalBlock"] { gap: 1.5rem; } .answer-box { background: rgba(0,128,0,0.1); border-color: rgba(0,128,0,0.5); } .box-title { color: #8c9096; } }

“”", unsafe_allow_html=True)

— Question Renderer Function —

def render_question_content(row, idx, total_count):
qid = row[“qid”]
qtype = row[“type”]
opts = {k: v for k, v in row.items() if k.startswith(“option_”) and pd.notna(v)}
correct_letters = [x.strip() for x in str(row.get(“correct_options”,“”)).split(“,”) if str(x).strip()]

is_revealed = st.session_state.revealed.get(qid, False)

# 1. Question Title Box
with st.container(border=True):
    st.markdown('<div class="box-title">Question Box</div>', unsafe_allow_html=True)
    st.markdown(f"""<div class="qtitle box-title-padding">Q{idx+1}. {row['question']}</div>""", unsafe_allow_html=True)

# 2. Options Box (with intended fixed height)
st.markdown('<div class="marker-optwrap"></div>', unsafe_allow_html=True) # Marker for CSS
with st.container(): # Outer grey container
    with st.container(border=True): # Inner white container (target for fixed height)
        st.markdown('<div class="box-title">Option Box</div>', unsafe_allow_html=True)
        st.markdown('<div class="box-title-padding"></div>', unsafe_allow_html=True)
        if qtype in ["true/false", "single"]:
            st.markdown("**Select One**", unsafe_allow_html=True)
            choice = st.radio(
                "Select one",
                list(opts.keys()),
                format_func=lambda l: f"{l}. {opts.get(l,'')}",
                disabled=is_revealed,
                key=f"radio_{qid}",
                label_visibility="collapsed"
            )
            st.session_state.responses[qid] = [choice] if choice else []
        elif qtype == "multiple":
            st.markdown("**Select As Many As Apply**", unsafe_allow_html=True)
            current_responses = st.session_state.responses.get(qid, [])
            selected_options = []
            for letter, text in opts.items():
                checked = st.checkbox(
                    f"{letter}. {text}",
                    value=(letter in current_responses),
                    disabled=is_revealed,
                    key=f"chk_{qid}_{letter}"
                )
                if checked:
                    selected_options.append(letter)
            st.session_state.responses[qid] = selected_options
        else:
            st.warning("Unknown question type.")
            st.session_state.responses[qid] = []

# 3. Buttons Container (will jump if options box height changes)
st.markdown('<div class="button-box-container">', unsafe_allow_html=True)
with st.container(border=True):
    st.markdown('<div class="box-title">Button Box</div>', unsafe_allow_html=True)
    st.markdown('<div class="box-title-padding"></div>', unsafe_allow_html=True)
    col1, col2, col3 = st.columns(3)
    with col1:
        if st.button("Previous", use_container_width=True, key=f"prev_{qid}", disabled=(idx == 0)):
            st.session_state.current_q_index = max(0, idx - 1)
            st.rerun()
    with col2:
        if st.button("Submit", use_container_width=True, key=f"submit_{qid}", disabled=is_revealed):
            st.session_state.revealed[qid] = True # For this demo, submit also reveals
            st.rerun()
    with col3:
        if st.button("Next", use_container_width=True, key=f"next_{qid}", disabled=(idx == total_questions - 1)):
            st.session_state.current_q_index = min(total_questions - 1, idx + 1)
            st.rerun()
st.markdown('</div>', unsafe_allow_html=True)

# 4. Answer/Explanation Box
with st.container():
    ans_text = ""
    expl_html = ""
    if is_revealed:
        user_ans = st.session_state.responses.get(qid, [])
        ok = (set(user_ans) == set(correct_letters)) if qtype == "multiple" else (user_ans == correct_letters)
        user_display = ", ".join(user_ans) if user_ans else "—"
        ans_text = (f"✅ Correct. Answer: {', '.join(correct_letters)}"
                     if ok else f"❌ Incorrect. Correct answer: {', '.join(correct_letters)} • Your answer: {user_display}")
        expl_content = row.get("explanation","")
        expl_html = f"<div style='margin-top:6px; opacity:0.9'>{expl_content}</div>" if isinstance(expl_content, str) and expl_content.strip() else ""
    
    st.markdown(f"""
        <div class="answer-box">
            <div class="box-title">Answer Box</div>
            <div class="box-title-padding" style="font-weight:600">{ans_text}</div>
            {expl_html}
        </div>
    """, unsafe_allow_html=True)

— Main App Logic —

Display the current question

current_q_row = quiz_df.iloc[st.session_state.current_q_index]
render_question_content(current_q_row, st.session_state.current_q_index, total_questions)

@Bhaskar2, May be you could try something like this.

import streamlit as st

# Set page layout
st.set_page_config(layout='wide')

# Sample questions data
questions = [
    {
        "question": "What is the capital of France?",
        "options": ["Paris", "London", "Berlin", "Rome", "Madrid"]
    },
    {
        "question": "Which is the largest planet in our Solar System?",
        "options": ["Earth", "Venus", "Jupiter", "Mars", "Saturn", "Neptune"]
    },
    {
        "question": "Who developed the theory of relativity?",
        "options": ["Newton", "Einstein", "Galileo", "Tesla"]
    }
]

# Initialize session state
if "current_q" not in st.session_state:
    st.session_state.current_q = 0
if "answers" not in st.session_state:
    st.session_state.answers = {}

# Current question data
q_data = questions[st.session_state.current_q]
q_text = q_data["question"]
q_options = q_data["options"]

# Container 1 - Question
with st.container(border=True, height=100):
    st.markdown(f"**Q{st.session_state.current_q + 1}: {q_text}**")

# Container 2 - Options
with st.container(border=True, height=100):
    selected = st.radio(
        "Choose the options",
        options=q_options,
        index=q_options.index(st.session_state.answers.get(st.session_state.current_q, q_options[0])) if st.session_state.current_q in st.session_state.answers else 0,
        key=f"q_{st.session_state.current_q}",
        horizontal=True)
    st.session_state.answers[st.session_state.current_q] = selected

# Container 3 - Navigation
with st.container(border=True, height=100):
    btn1, btn2, btn3 = st.columns(3)

    with btn1:
        if st.button("Previous", disabled=st.session_state.current_q == 0):
            st.session_state.current_q -= 1
            st.rerun()

    with btn2:
        if st.button("Submit"):
            st.success(f"You selected: {st.session_state.answers[st.session_state.current_q]}")

    with btn3:
        if st.button("Next", disabled=st.session_state.current_q == len(questions) - 1):
            st.session_state.current_q += 1
            st.rerun()

1 Like

Hello Prakash,

Thank you so much! Your solution solved the problem. :white_check_mark:

Initially, I thought your solution :thinking: worked because you arranged the options horizontally, but a deeper look showed me that the height parameter of st.container() was the real savior :smiling_face_with_sunglasses:. It’s the native and most effective way to solve this problem, and it worked perfectly. Thank you so much for your guidance. :handshake:

You might be wondering why I didn’t use this feature, which has been available since Streamlit 1.28 in October 2023. The reason is that I was relying on AI tools like ChatGPT and Gemini. I had given them a starting point using CSS, and when I later asked them about the height parameter, they immediately agreed it was the solution.

This experience has been a reminder that even for seemingly simple troubleshooting, AI tools can sometimes lead you down a complex path. I appreciate you taking the time to provide a working solution. :100: :folded_hands:

1 Like

May I ask a follow-up question instead of opening a new thread? :thinking:Since the quiz contains both radio-button options and checkbox options, I’ve noticed that when users switch between questions, Streamlit renders different vertical spacing for the two widget types. This inconsistency doesn’t look very clean from a UI/UX perspective. :expressionless_face:

Could you advise if there is a method or styling trick I can apply to ensure that the vertical spacing between radio buttons and checkboxes remains uniform across different questions? :persevering_face:

Look here, low spacing between radio buttons,

User goes to next question, and now there is much more spacing between check boxes,

Glad that it worked for you!
The spacing between radio buttons and checkboxes can be controlled by targeting their internal component identifiers using CSS.

Simply place the following code right after st.set_page_config(layout="wide") in your app.

st.markdown("""
    <style>
    div[data-testid="stCheckbox"] {
        margin-bottom: 20px;
    }
    div[data-testid="stRadio"] label {
        margin-bottom: 20px;
    }
    </style>
    """, unsafe_allow_html=True)
1 Like

Wonderful Prakash :handshake:. Thank you so much. You made my weekend. More power to you. :folded_hands:

1 Like

This topic was automatically closed 2 days after the last reply. New replies are no longer allowed.