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, Random(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
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), or any other app-level function. In those places, construct the context explicitly:

context = Context(player)
value = context.earnings

See PlayerContext in the prisoners_dilemma 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.

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.

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>

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