Skip to content

Pages and templates

Pages are the building blocks of uproot experiments. Each page represents a screen that participants see, for instance, instructions, questions, feedback, or results. Pages are defined as Python classes and rendered using HTML or Markdown templates.

Defining a page

A page is a class that inherits from Page:

class Welcome(Page):
    pass

This minimal page displays the template Welcome.html from your app directory. Every page needs a matching template: Welcome.html, or Welcome.md if you prefer Markdown (see Markdown pages).

The page_order list

The page_order list defines which pages participants see and in what sequence:

page_order = [
    Welcome,
    Instructions,
    Task,
    Results,
]

Participants progress through pages in order. You can use SmoothOperators to randomize, repeat, or conditionally select pages.

page_order can also be a callable that takes player as a keyword argument, letting you build the sequence dynamically per participant:

def page_order(player):
    pages = [Instructions, Task]
    if player.in_treatment:
        pages.append(TreatmentPage)
    pages.append(Results)
    return pages

Nested lists are flattened automatically, so you can compose page sequences from reusable building blocks, including SmoothOperators:

def page_order(player):
    warmup = [Instructions, RandomOrder(PracticeA, PracticeB)]
    main_task = [Rounds(Decision, Feedback, n=5)]
    return [warmup, main_task, Results]

Templates

Templates define what participants see. Most are HTML files; you can also write Markdown pages. uproot uses Jinja2 for templating.

Template naming and location

By default, uproot looks for a template matching the page class name:

my_app/
├── __init__.py      # Contains class Welcome(Page)
└── Welcome.html     # Template for Welcome page (or Welcome.md)

To use a custom template path:

class Welcome(Page):
    template = f"{__name__}/Page0.html"

Explicit template paths start at the project root, so include the app module name as shown.

Basic template structure

A typical template extends the base layout and defines content:

{% extends "Base.html" %}


{% block main %}
<h1>Welcome to the experiment</h1>
<p>Thank you for participating.</p>
{% endblock %}

The Base.html base template provides the form wrapper, navigation buttons, and styling. Your content goes in the main block.

Available blocks:

Block Position
title Page title (shown in the browser tab and as the heading)
head Extra content in <head> (CSS, meta tags)
pre_main Before the main content, outside the Bootstrap container
main Main page content (inside the container)
main_full_width After main, full viewport width; use this for banners or charts that should break out of the container
main2 A second container section after main_full_width
late Extra content at the end of <body> (scripts)

There are also main2_full_width, main3, late2, footer, header_start, and header_end if you need more slots.

Base template switches

The built-in templates also read a few optional Jinja variables. Set them near the top of a child template when you need to disable part of the default layout:

{% extends "Base.html" %}
{% set buttons = False %}
{% set disable_connection_lost_modal = True %}
Variable Default Effect when set to True
buttons True Set to False to hide the default Back/Next buttons (see Placing the buttons yourself)
disable_bootstrap False Do not load uproot’s bundled Bootstrap CSS and JavaScript
disable_uproot_fonts False Do not load uproot’s bundled web fonts
disable_tabular_numbers False Do not load the tabular-number font stylesheet
disable_terms False Do not load the terms script
disable_auto_start False Do not run the default uproot.init() and WebSocket startup hook
disable_connection_lost_modal False Do not show or enable the connection-lost modal

For admin templates that extend the built-in admin layout, disable_navigation = True hides the admin navigation bar.

Adding a form

To collect data, include form fields in your template:

{% extends "Base.html" %}

{% block main %}
<h1>Your decision</h1>

{{ form.amount.label }}
{{ form.amount }}

{% endblock %}

See Collecting data with forms for details on defining form fields.

Passing data to templates

The templatevars method

Use the templatevars method to pass data from Python to your template:

class Results(Page):
    @classmethod
    def templatevars(page, player):
        return dict(
            earnings=player.payoff,
            partner_choice=player.other_in_group.choice,
        )

Then use these variables in your template:

{% extends "Base.html" %}

{% block main %}
<h1>Results</h1>
<p>You earned {{ earnings }} points.</p>
<p>Your partner chose: {{ partner_choice }}</p>
{% endblock %}

The templatevars method receives page (the page class) and player (the current participant’s data).

The PlayerContext class

Use PlayerContext when you want to compute the same value on several pages. Define a Context class in your app, then add a property for each value:

class Context(PlayerContext):
    @property
    def earnings(self):
        return self.player.payoff

    @property
    def partner_choice(self):
        return self.player.other_in_group.choice

Access these in templates:

<p>You earned {{ player.context.earnings }} points.</p>
<p>Your partner chose: {{ player.context.partner_choice }}</p>

You can use the same properties in Python page methods:

class Results(Page):
    @classmethod
    def show(page, player):
        return player.context.earnings > 0

The self.player attribute gives the Context object access to the current participant. Store lasting data on self.player, self.player.group, or self.player.session—not on the Context object itself. Each access to player.context creates a lightweight Context object.

How uproot selects the Context class

player.context refers to the Context class of the app that the participant is currently using. uproot reads player.app, finds that app’s Context class, and constructs it with the player. If the current app does not define a Context class, player.context is None.

While the participant is inside an app that defines Context, these two expressions therefore produce the same result:

player.context.earnings
Context(player).earnings

The second expression does not need to discover an app. Context is an ordinary Python name that refers directly to the class defined in the current app module. The constructor inherited from PlayerContext stores player as self.player.

Usually, prefer player.context: it is concise and automatically follows the participant’s current app. Use Context(player) when you are outside page execution (see the warning below) or when code must target a specific app’s Context class.

Do not use player.context outside page execution

player.context relies on player.app, which tracks whichever app the participant is currently in. Outside page methods and templates, that value is unreliable: it may be None (before the first app), or it may point to a different app entirely.

Do not use player.context in page_order(player), new_player(player), digest(session), pipeline(session, data), or any other app-level function. In those places, construct the context explicitly:

context = Context(player)
value = context.earnings

See PlayerContext in the public_goods_game example · trust_game example · bertrand example

Built-in template variables

Every template has access to these variables:

Variable Description
player The current participant’s data
form The form instance (if the page has fields)
page The page class
C Constants defined in your app’s C class
session The current session
_("text") Translation function for internationalization

Accessing player data

<p>Your name: {{ player.participant_name }}</p>
<p>Current round: {{ player.round }}</p>

Using constants

Define constants in your app:

class C:
    ENDOWMENT = 100
    EXCHANGE_RATE = 0.10

Use them in templates:

<p>You start with {{ C.ENDOWMENT }} points.</p>
<p>Each point is worth ${{ C.EXCHANGE_RATE }}.</p>

To also use constants in JavaScript (and Alpine.js), list their names on C.__export__:

class C:
    WORD_LENGTH = 5
    DURATION = 120
    __export__ = ["WORD_LENGTH", "DURATION"]

uproot copies those values to window.C, so C.WORD_LENGTH works in scripts. Set __export__ = ... (Ellipsis) to export every non-dunder attribute.

See C.export in the encryption_task example · emoji_sort example

Static files

App-specific static files

Place static files (images, CSS, JavaScript) in an _static/ folder within your app:

my_app/
├── __init__.py
├── Welcome.html
└── _static/
    ├── diagram.png
    └── custom.css

Reference them using appstatic():

<img src="{{ appstatic('diagram.png') }}" alt="Diagram">
<link rel="stylesheet" href="{{ appstatic('custom.css') }}">

Project-wide static files

For files shared across apps, put them in _static/ at the project root and use projectstatic():

<script src="{{ projectstatic('shared.js') }}"></script>

To inject HTML into every page, add ProjectHead.html (inside <head>) or ProjectBody.html (inside <body>) at the project root.

Markdown pages

If Welcome.html is missing, uproot looks for Welcome.md instead. Put exactly one level-one heading (#) in the file; uproot uses it as the page title and removes it from the main content.

You can still use Jinja. That is, fields, player, C, and the rest work as usual:

# Welcome

Thank you for participating. You start with {{ C.ENDOWMENT }} points.

{{ field(form.consent) }}

If the Markdown file name does not match the page class, set its project-root-relative path:

class Welcome(Page):
    template = f"{__name__}/Instructions.md"

See the anchoring_markdown example

Translations

uproot’s built-in interface strings ship in English (en), French (fr), German (de), Spanish (es), and Japanese (ja). This includes buttons, wait pages, and error messages of forms. Set the default in main.py:

upd.LANGUAGE = "de"

To choose a language per participant, define language(player) in the app. It should return an ISO 639 code:

def language(player):
    return player.session.settings.get("language", "en")

In templates, _("text") looks up a translation for the current language. Wrap phrases in {% translate %}...{% endtranslate %} when you want the same lookup with whitespace collapsed. In JavaScript, _("text") works the same way.

To add your own phrases, put YAML files in a directory (one file per language, or one file with all languages) and load them at startup:

import uproot.i18n as i18n

i18n.load("locales/")

A file per language is named after its language code, such as locales/fr.yml. Each entry maps the phrase as written in your template to its translation:

? "Please answer the following questions:"
: "Veuillez répondre aux questions suivantes :"

? "Click “Next” when you are ready."
: "Cliquez sur « Suivant » lorsque vous êtes prêt(e)."

Your files can also translate uproot’s built-in strings, such as Next or Please wait. This lets you run studies in a language that uproot does not ship. The built-in strings are listed in uproot’s en.yml.

Keys must match exactly

uproot finds a translation only if the key matches the phrase in your template exactly, apart from whitespace. Straight quotes ("Next") and typographical quotes (“Next”) are different keys. If no translation matches, uproot silently shows the phrase itself, usually in English.

To find such problems, run uproot check-translations in your project directory:

uproot check-translations

It lists every phrase that is missing for a language that has a YAML file in your project, including the labels and choices of form fields. It also points out keys that differ only in quotes or spacing.

Conditional page display

Use the show method to conditionally display pages:

class BonusRound(Page):
    @classmethod
    def show(page, player):
        return player.score >= 80  # Only show if score is high enough

Pages where show returns False are skipped automatically.

Role-based pages

A common pattern for multiplayer experiments:

class ProposerDecision(Page):
    @classmethod
    def show(page, player):
        return player.role == "proposer"

class ResponderDecision(Page):
    @classmethod
    def show(page, player):
        return player.role == "responder"

Allowing back navigation

By default, participants can only move forward. To allow going back:

class Survey(Page):
    allow_back = True

This adds a “Back” button that lets participants revisit and change previous answers.

Back leads to the nearest earlier page that is shown: pages whose show is False are passed over. Back never crosses the start of an app, a round of Rounds or an iteration of Repeat, because player.app and player.round would not go back with the participant. If there is no such page, or allow_back is False on the current page, the participant stays where they are. In particular, a “Back” button on the first page of an app does nothing.

Going back does not ask may_proceed, and it saves nothing from the page that the participant leaves. Back checks show to select the destination before running its entry hooks. It then enters and displays that page without checking show again, even if an entry hook changes its visibility. If the participant has not seen it before (for example, because it was not shown when they first passed it), its before_once runs. After a page’s timeout has expired, Back counts as a timed-out submission: the participant moves on, not back.

Set keep_values = True to pre-fill the form from values already stored on the player. This is useful when someone returns to a page and should see their previous answers.

Note

Going back re-displays the page but does not undo any data that was already saved. Submission hooks like after_once do not run again.

Placing the buttons yourself

uproot puts a “Next” button (and a “Back” button, if allowed) below your page content. To put them elsewhere, or to style or relabel them, call button_next() and button_back() in your template:

{% extends "Base.html" %}

{% block main %}
<p>Please read the consent form below.</p>

<div class="d-flex justify-content-center gap-3 my-5">
    {{ button_back() }}
    {{ button_next(label="I agree", class_="btn btn-success btn-lg") }}
</div>
{% endblock main %}

As soon as you place either button, uproot no longer adds the default buttons. Both helpers accept HTML attributes as keyword arguments. Write class_ (with an underscore) for the class attribute, because class is a reserved word in Python. label replaces the button text. button_back() renders nothing on pages without allow_back.

Submitting from JavaScript

Sometimes the page should only be submitted after your own JavaScript has run, for example after a confirmation dialog. Make the button an ordinary button with type="button", so that clicking it does not submit the page, and call uproot.submit() when you are ready:

{{ button_next(type="button", id="review", label="Submit") }}

<script>
document.getElementById("review").addEventListener("click", () => {
    if (confirm("Submit your answers? You cannot change them later.")) {
        uproot.submit();
    }
});
</script>

uproot.submit() sends the page’s form fields like a click on “Next” would. Form inputs without a name attribute are never sent.

Pages without buttons

To show no buttons at all, set buttons to False at the top of the template:

{% extends "Base.html" %}
{% set buttons = False %}

Participants then stay on this page until something else moves them on, such as a timeout, live method, or the admin. If you put such a page last, participants never reach the end page. This can be what you want, but the admin’s session monitor then shows them on that page rather than on End.html.

See the button_placement example

The end page

After the last page in the page order, participants see uproot’s end page: “You have reached the end of this study. You may now close this page.” To add your own content below this text, create a file named EndInfo.html:

<p>Your completion code is <b>{{ player.completion_code }}</b>.</p>

EndInfo.html is included in the end page, so it must not extend Base.html. You can use player and the other built-in template variables. uproot looks for the file in the app of the participant’s last page first, then in the project root.

NoshowPage for logic-only pages

Sometimes you need to run code without displaying anything to participants. Use NoshowPage:

class CalculatePayoffs(NoshowPage):
    @classmethod
    def after_always_once(page, player):
        player.payoff = player.correct_answers * 10

NoshowPage runs its lifecycle methods but never renders a template. Use it for:

  • Calculating scores or payoffs
  • Initializing player data
  • Setting up randomization

See NoshowPage in the big5 example

Page lifecycle methods

Pages have several methods that run at different points:

Method When it runs
show Before displaying; return False to skip the page
early Earliest hook when entering the page; has the HTTP request
before_always_once Once when this page position is reached
before_once Once per player, before first display
templatevars Before rendering; return template variables
after_once Once per player, after first submission
after_always_once Once after this page position is submitted

Example: one-time initialization

class Task(Page):
    @classmethod
    def before_once(page, player):
        # Runs once when the player first sees this page
        player.start_time = time()

Example: cleanup after submission

class Task(Page):
    @classmethod
    def after_always_once(page, player):
        # Runs after this page position is submitted
        player.attempts += 1

See Page methods reference for the complete list.

JavaScript variables

To pass data to JavaScript, use the jsvars method:

class TradingGame(Page):
    @classmethod
    def jsvars(page, player):
        return dict(
            initial_price=player.price,
            max_trades=C.MAX_TRADES,
        )

Access these in your template’s JavaScript:

<script>
const price = uproot.vars.initial_price;
const maxTrades = uproot.vars.max_trades;
</script>

Do not paste template variables into scripts

It is tempting to write const nickname = "{{ player.nickname }}"; instead. This breaks as soon as a value contains a quotation mark, a backslash, or a line break: the participant’s browser then sees a different value, or the whole script fails. jsvars converts values to JSON, which keeps strings, numbers, lists, and dictionaries intact.

jsvars can only pass values that JSON knows: strings, numbers, Booleans, None, lists, tuples, and dictionaries. Money amounts are a common exception. player.payoff and values created with cu are Decimal numbers, and returning one from jsvars fails with TypeError: Type is not JSON serializable: decimal.Decimal. Convert it first: str(player.payoff) keeps the exact value, while float(player.payoff) gives a number you can calculate with in JavaScript.

Complete example

Here is a complete page with context, conditional display, and form handling:

class Offer(Page):
    allow_back = True

    fields = dict(
        amount=IntegerField(
            label="How much do you offer?",
            min=0,
            max=100,
        ),
    )

    @classmethod
    def show(page, player):
        return player.role == "proposer"

    @classmethod
    def templatevars(page, player):
        return dict(
            endowment=C.ENDOWMENT,
            partner=player.other_in_group.name,
        )

    @classmethod
    def after_once(page, player):
        player.offer_made = True
{% extends "Base.html" %}

{% block main %}
<h1>Make an offer</h1>

<p>You have {{ endowment }} points to split with {{ partner }}.</p>

{{ form.amount.label }}
{{ form.amount }}

{% endblock %}

See complete examples in uproot-examples