How-to guide

Themes, layout, styling, and the callback pattern.

The core premise

Most scientists and engineers already have working Python code — functions that load data, run models, compute results. Guile is designed so that code stays completely untouched. You write a thin app layer on top: some state, a few callbacks that call your existing functions, and a ui() that displays the results. The distance between a working script and a shareable desktop app is typically twenty to thirty lines of glue code. None of those lines touch the code you already wrote.

How a guile app is structured

Every guile app has four parts, always in the same order. Hover a section in the code to see what it does.

import guile as gui
# ── 1. Pure function ────────────────
def to_celsius(f):
    return (f - 32) * 5 / 9
# ── 2. State (module level = global) ─
fahrenheit = gui.state(32.0)
celsius   = gui.state(None)
# ── 3. Callback ─────────────────────
def convert():
    c = to_celsius(fahrenheit.value)
    celsius.set(round(c, 1))
# ── 4. Layout ───────────────────────
@gui.app("Converter", width=360)
def ui():
    gui.number_input("°F", value=fahrenheit,
        on_change=fahrenheit.set)
    gui.button("Convert", on_click=convert)
    if celsius.value is not None:
        gui.text(celsius.value)

1 · Pure function

Ordinary Python — no guile inside. Works identically in a notebook, test, or script. This is the code you already have.

2 · State — global variables

Declared at module level, outside every function. Callbacks need to read and write them, so they must be in scope everywhere. Defined inside ui() they would reset on every render.

3 · Callback

The only place where .value is read for logic and .set() is called. Calls your pure function, stores the result. Re-render happens automatically.

4 · Layout — ui()

Reads .value to display things. Passes .set by reference via on_change=state.set — fine and idiomatic. Never calls state.set(x) as a bare statement — that runs on every render and causes a loop.

State variables and their methods

gui.state(x) creates a reactive variable with initial value x. Any Python type works — number, string, list, DataFrame, None.

Method What it does Where to use it
.valueRead the current valueui() and callbacks
.set(new)Replace the value, trigger re-renderCallbacks; or passed as on_change=x.set
.update(fn)Set based on current value: .set(fn(.value))Callbacks only
.toggle()Flip a boolean: True ↔ FalseCallbacks — for checkbox state

Built-in themes

Call gui.theme() as the first line inside your ui() function. It injects a <style> block that overrides the CSS variables before any widgets render.

Available presets

light
#6366f1
dark
#818cf8
neon
#00f5a0
ocean
#38bdf8
rose
#f43f5e
sand
#b45309
Example
@gui.app("My App", width=480, height=400)
def ui():
    gui.theme("ocean")          # apply theme first

    with gui.card():
        gui.title("Hello")
        gui.button("Click me")
List all presets in Python
import guile as gui
print(list(gui._THEMES.keys()))
# ['light', 'dark', 'neon', 'ocean', 'rose', 'sand']

Custom theme

Start from a preset and override individual values, or build a theme entirely from scratch.

Override part of a preset

gui.theme("ocean", primary="#ff6b6b")     # ocean colours, red accent
gui.theme("light", radius=2)              # light theme, sharp corners
gui.theme("sand",  font="Georgia, serif") # sand but serif font

Fully custom

gui.theme(
    primary = "#0ea5e9",   # sky blue accent
    bg      = "#f0f9ff",   # very light blue background
    surface = "#ffffff",   # white cards
    text    = "#0c1a2e",   # near-black text
    radius  = 8,           # px, also accepts "8px"
    font    = "-apple-system, BlinkMacSystemFont, sans-serif",
)

All derived colours — hover shade, tint, secondary text, borders — are computed automatically from the six values above. You never need to set them manually.

Switch themes at runtime

Because gui.theme() is called inside ui(), it re-runs on every render. Tie it to a state value for a live theme switcher:

theme_name = gui.state("light")

@gui.app("App", width=480, height=300)
def ui():
    gui.theme(theme_name.value)         # re-applied every render

    with gui.card():
        gui.select(
            ["light","dark","ocean","neon","rose","sand"],
            "Theme", value=theme_name, key="theme-sel"
        )

CSS variables reference

These are the six variables gui.theme() accepts. All other colours are derived from them.

Argument CSS variable Default (light) What it controls
primary--primary#6366f1Buttons, sliders, focus rings, links
bg--bg#f2f2f7Window / page background
surface--surface#ffffffCard and input background
text--text#1c1c1ePrimary text colour
radius--r10pxBorder radius of cards and inputs
font--font / font-familysystem-uiBody and widget font stack

The full list of derived variables (set automatically) is in _template.py under Design tokens. You can still override any of them directly via gui.html("<style>:root{--border-focus:#ff0}...</style>").

Unequal columns

Use gui.row() with style="flex:N" on child containers to get proportional widths. The number after flex: is the relative weight.

Two columns — 1/3 + 2/3

with gui.row(gap=16, align="flex-start"):
    with gui.col(style="flex:1"):      # takes 1 part
        with gui.card():
            gui.title("Sidebar")
    with gui.col(style="flex:2"):      # takes 2 parts
        with gui.card():
            gui.title("Main content")

Three columns — equal

with gui.row(gap=12, align="flex-start"):
    for label in ["Alpha", "Beta", "Gamma"]:
        with gui.col(style="flex:1"):
            with gui.card():
                gui.title(label)

Fixed + flexible

with gui.row(gap=12, align="flex-start"):
    with gui.col(style="width:200px;flex-shrink:0"):
        with gui.card():
            gui.title("Fixed 200px")
    with gui.col(style="flex:1"):
        with gui.card():
            gui.title("Fills the rest")

CSS grid

For more control — unequal rows, spanning cells — use display:grid in a style= string on any container.

Two-column grid with fractional widths

with gui.col(style="display:grid;grid-template-columns:1fr 2fr;gap:16px"):
    with gui.card():
        gui.title("Narrow (1fr)")
    with gui.card():
        gui.title("Wide (2fr)")

Three-column dashboard grid

with gui.col(style="display:grid;"
             "grid-template-columns:repeat(3,1fr);gap:12px"):
    for metric, value in [("Revenue","$42k"), ("Users","1,204"), ("Uptime","99.9%")]:
        with gui.card(padding=16):
            gui.text(metric, muted=True, size="sm")
            gui.title(value, size="2xl")

Mixed fixed and flexible columns

with gui.col(style="display:grid;"
             "grid-template-columns:240px 1fr 160px;gap:16px"):
    with gui.card(): gui.title("Nav")
    with gui.card(): gui.title("Content")
    with gui.card(): gui.title("Panel")

A cell that spans multiple columns

with gui.col(style="display:grid;grid-template-columns:1fr 1fr;gap:12px"):
    with gui.card(style="grid-column:1/-1"):   # span all columns
        gui.title("Full-width header")
    with gui.card(): gui.title("Left")
    with gui.card(): gui.title("Right")

Sidebar layout

A common pattern: fixed-width sidebar on the left, scrollable content on the right.

@gui.app("Dashboard", width=800, height=600)
def ui():
    with gui.row(gap=0, style="height:100vh"):

        # ── Sidebar ──────────────────────────────────
        with gui.col(
            padding=16, gap=8,
            style="width:200px;flex-shrink:0;"
                  "border-right:1px solid var(--border);"
                  "background:var(--surface)"
        ):
            gui.title("Menu", size="lg")
            gui.divider()
            gui.button("Dashboard", variant="ghost",
                       style="width:100%;justify-content:flex-start")
            gui.button("Reports",   variant="ghost",
                       style="width:100%;justify-content:flex-start")
            gui.button("Settings",  variant="ghost",
                       style="width:100%;justify-content:flex-start")

        # ── Main content ──────────────────────────────
        with gui.col(padding=24, gap=16, fill=True, scroll=True):
            gui.title("Dashboard")
            gui.text("Main content goes here.")

Inline style=

Every widget and container accepts a style= argument that appends raw CSS to the element. This is for one-off visual tweaks that don't warrant a theme change.

# Coloured text
gui.text("Warning", color="var(--warning)")
gui.text("Custom hex", color="#0ea5e9")

# Extra spacing on a specific card
with gui.card(style="margin-top:24px"):
    gui.title("Spaced out")

# Centred text inside a card
gui.title("Centred", style="text-align:center")

# Fixed-width number display
gui.text(count, size="2xl", bold=True, style="min-width:64px;text-align:center")

# Danger zone card
with gui.card(style="border:1px solid var(--danger-light)"):
    gui.text("Destructive action", color="var(--danger)", bold=True)
Layout props (gap, padding, align, justify) control where children sit. style= controls how the element itself looks. If you find yourself writing style="display:flex;gap:12px", use gui.row(gap=12) instead.

Inject a <style> block

For anything beyond what gui.theme() covers, use gui.html() to drop a raw <style> tag into the page. Because it's called inside ui(), it re-applies on every render — which is fine, the browser just overwrites the rule.

Custom animation

gui.html("""
<style>
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50%       { opacity: 0.4; }
}
.live-dot {
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--success); animation: pulse 1.5s infinite;
  display: inline-block; margin-right: 6px;
}
</style>
""")
gui.html('<span class="live-dot"></span> Live')

Custom scrollbar

gui.html("""
<style>
::-webkit-scrollbar       { width: 4px; }
::-webkit-scrollbar-thumb { background: var(--primary); border-radius: 2px; }
</style>
""")

Google Font

gui.html("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
:root { --font: 'Inter', sans-serif; }
body  { font-family: var(--font); }
</style>
""")

Override individual CSS variables

The full design system uses CSS custom properties. You can override any of them without touching the others. All variables are defined in _template.py.

Change just the border radius

# Sharp, modern look
gui.html("<style>:root { --r: 2px; --r-sm: 2px; --r-lg: 4px; }</style>")

# Very rounded, soft look
gui.html("<style>:root { --r: 20px; --r-sm: 12px; --r-lg: 28px; }</style>")

Change just the shadow

# Flat design — no shadow
gui.html("<style>:root { --shadow: none; --shadow-sm: none; }</style>")

# Heavy shadow
gui.html("<style>:root { --shadow: 0 8px 40px rgba(0,0,0,.18); }</style>")

All available variables

/* Colours */
--primary        accent colour (buttons, sliders, focus rings)
--primary-h      darker hover shade of primary (auto-derived by gui.theme)
--primary-light  tint/ghost background for primary (auto-derived)
--bg             window background
--surface        card and input background
--surface-2      slightly tinted surface (hover rows, secondary panels)
--text           primary text
--text-2         secondary / muted text
--border         default border colour
--border-focus   border colour when an input has focus
--danger         red for danger buttons and error text
--danger-light   tint for danger backgrounds
--success        green for success badges
--success-light  tint for success backgrounds
--warning        amber for warning text
--warning-light  tint for warning backgrounds

/* Shape */
--r              default border radius   (cards, inputs)
--r-sm           small border radius     (buttons, badges)
--r-lg           large border radius     (modals, large cards)

/* Depth */
--shadow         default card shadow
--shadow-sm      subtle shadow
--shadow-lg      prominent shadow

/* Typography */
--mono           monospace font stack

The reactive state cycle

Everything in guile revolves around three concepts. Understanding them means you can build any app.

The three methods

gui.state(initial) — creates the state object. The initial value is your default. Any type works: number, string, list, dict, DataFrame, None.

.value — reads the current value. Call this inside ui() to display it, or inside callbacks to compute with it.

.set(new_value) — replaces the value and triggers a re-render. Always called from a callback, never directly inside ui().

There is also .update(fn) — a functional shorthand for .set(fn(.value)), useful when the new value depends on the current one:

count = gui.state(0)

count.set(10)                       # replace
count.update(lambda x: x + 1)      # increment
count.update(lambda x: x * 2)      # double
count.toggle()                      # bool flip (True ↔ False)

A minimal example showing all three

import guile as gui

# 1. State — declare the default at the top
score = gui.state(0)

# 2. Callbacks — call .set() or .update() here
def add_point():
    score.update(lambda x: x + 1)

def reset():
    score.set(0)

# 3. ui() — read .value, never call .set() here
@gui.app("Score", width=320, height=200)
def ui():
    with gui.col(align="center", justify="center", style="height:100vh"):
        gui.title(score, size="3xl")          # displays score.value
        with gui.row(gap=8):
            gui.button("+1", on_click=add_point)
            gui.button("Reset", variant="ghost", on_click=reset)
The rule: never call state.set(x) as a bare statement inside ui() — it fires on every render and causes an infinite loop. Passing it as a reference via on_change=state.set is fine — it only fires when the user interacts with the widget.

The canonical guile pattern

Every guile app follows the same structure, regardless of complexity. Understanding this structure once means you can build any app.

There are four parts, always in this order:

  1. Your functions — pure Python, no guile inside
  2. State variables — one per piece of data the app needs to remember
  3. Callbacks — call your functions, store the results in state
  4. ui() — reads state, builds the layout, nothing else
import guile as gui


# ── 1. Your function — pure Python, no guile ─────────────────────────
#       Works identically in a script, notebook, or test.
def to_celsius(f):
    return (f - 32) * 5 / 9


# ── 2. State — everything the app needs to remember ──────────────────
fahrenheit = gui.state(32.0)
celsius    = gui.state(None)


# ── 3. Callback — calls your function, stores the result ─────────────
#       This is the only place where your code and guile meet.
def convert():
    celsius.set(round(to_celsius(fahrenheit.value), 2))


# ── 4. ui() — reads state, builds layout, no logic inside ────────────
@gui.app("Temperature converter", width=380, height=300)
def ui():
    with gui.col(padding=24, gap=14):
        gui.title("°F  →  °C")
        gui.number_input("Fahrenheit", value=fahrenheit,
                         on_change=fahrenheit.set, step=0.5)
        gui.button("Convert", on_click=convert)
        if celsius.value is not None:
            gui.text(f"{celsius.value} °C", size="2xl", bold=True)

Why on_change=fahrenheit.set?

When the user types a new value, on_change fires and calls fahrenheit.set(new_value). This stores the value in state so that convert() can read it later via fahrenheit.value. Without on_change= the widget would display correctly but the state would never update, and convert() would always see the original value.

State declarations are your defaults

The gui.state() lines at the top of your script are the single source of truth for starting values.

The value=state argument on a widget may look redundant, but it serves a different purpose — it binds the widget to the state object so they stay in sync. Without it, the widget manages its own internal value and your callbacks have no way to read it.

# Pattern A — widget owns its state (simple cases)
# No pre-declared state needed. Read the value back via f.value inside ui().
f = gui.number_input("°F", value=32.0)

# Pattern B — external state, widget bound to it (most real apps)
# Callbacks defined outside ui() can read fahrenheit.value and call fahrenheit.set().
fahrenheit = gui.state(32.0)
gui.number_input("°F", value=fahrenheit, on_change=fahrenheit.set)

Use Pattern A when the value is only needed inside ui(). Use Pattern B whenever a callback needs to read or update the value — which is most of the time. The 32.0 in gui.state(32.0) sets the starting value; value=fahrenheit connects the widget to that state going forward. They are not saying the same thing twice.

Always use value= and on_change= together. Writing on_change=state.set without value=state is a common mistake. It works on the first render, but as soon as any state changes and ui() re-runs, the widget is recreated without reading from state — so it resets to its initial appearance, silently discarding whatever the user selected. The symptom is a widget that appears to lose its value or selection on every interaction.

# ✗ Incomplete — widget resets on every re-render
gui.multiselect(["A","B","C"], on_change=variables.set, key="vars")

# ✓ Correct — widget reads state on render, writes state on interaction
gui.multiselect(["A","B","C"], value=variables,
                on_change=variables.set, key="vars")

To reset to the default from a button, call station.set("Manhattan") in a callback.

What is key= for?

key= is a stable identifier for the DOM — it tells guile which element is which between re-renders, so inputs keep their focus and don't reset unexpectedly. It has no connection to the state variable name. Writing key="fahrenheit" when you also have fahrenheit = gui.state(...) is purely a readability convention. You could write key="abc" and the app would behave identically. key= can be omitted entirely for simple apps — guile assigns one automatically.

Coming from Streamlit

In Streamlit, buttons and inputs return values you check inline:

# Streamlit habit
run_btn = st.button("Convert")
if run_btn:
    do_something()

In guile, widgets don't return True/False — they fire a callback. The equivalent is:

# Guile equivalent
gui.button("Convert", on_click=do_something)

That's the main habit to unlearn. Everything else — keeping your functions pure, separating logic from display — works the same way in both frameworks.

Keep your functions pure

The real payoff of the callback pattern: your processing functions stay completely unaware of guile. They take inputs and return outputs, nothing more. This means they work identically in Jupyter, in tests, in scripts — the guile app is just one consumer of the same functions.

import guile as gui

# ── Pure functions — no guile imports, no .set() calls ───────────────
# These could live in a separate module. They work the same in a
# Jupyter notebook, a unit test, or a command-line script.

def to_celsius(f: float) -> float:
    return (f - 32) * 5 / 9

def to_fahrenheit(c: float) -> float:
    return c * 9 / 5 + 32

def feels_like(temp_c: float, wind_kmh: float) -> float:
    """Wind chill (°C) — valid for temp ≤ 10 °C and wind ≥ 4.8 km/h."""
    return (13.12 + 0.6215 * temp_c
            - 11.37 * wind_kmh**0.16
            + 0.3965 * temp_c * wind_kmh**0.16)


# ── State ─────────────────────────────────────────────────────────────
temp_f  = gui.state(32.0)
wind    = gui.state(20.0)
results = gui.state(None)   # None = not yet calculated


# ── Callbacks — the thin bridge between pure functions and guile ──────
def calculate():
    f   = temp_f.value
    w   = wind.value
    c   = to_celsius(f)
    wc  = feels_like(c, w) if c <= 10 and w >= 4.8 else None
    results.set({
        "celsius":    round(c, 1),
        "feels_like": round(wc, 1) if wc is not None else "N/A",
    })


# ── UI ────────────────────────────────────────────────────────────────
@gui.app("Weather calculator", width=420, height=360)
def ui():
    with gui.col(padding=24, gap=14):
        gui.title("Weather calculator")

        with gui.card(gap=12):
            gui.number_input("Temperature (°F)", value=temp_f,
                             on_change=temp_f.set, step=0.5)
            gui.number_input("Wind speed (km/h)", value=wind,
                             on_change=wind.set, step=1.0, min=0)
            gui.button("Calculate", on_click=calculate)

        if results.value is not None:
            r = results.value
            with gui.card(gap=8):
                gui.text(f"Temperature:  {r['celsius']} °C",   bold=True)
                gui.text(f"Wind chill:   {r['feels_like']} °C", bold=True)

Notice that to_celsius(), to_fahrenheit(), and feels_like() contain zero guile references. You could copy them into a notebook and use them immediately. The calculate() callback is the only place where guile state and your own logic meet.

Passing arguments to callbacks

Callbacks registered with on_click= take no arguments. When you need to pass a value — the most common case is a list of items each with their own button — use a default-argument lambda to capture the current value at the time the widget is created.

The loop capture problem

cities = ["Nairobi", "Tokyo", "Paris"]

# ✗ Wrong — all three lambdas hold a reference to `city`, not its value.
#   By the time a button is clicked the loop is done and `city` is
#   always "Paris" (the last value). Every button does the same thing.
for city in cities:
    gui.button(f"Show {city}", on_click=lambda: print(city))

# ✓ Correct — c=city is evaluated immediately at lambda creation time.
#   Each lambda gets its own private copy of the current value.
for city in cities:
    gui.button(f"Show {city}", on_click=lambda c=city: print(c))

# ✗ Also wrong — `i` is a required parameter; guile calls on_click
#   with zero arguments, so this crashes on first click.
for city in cities:
    gui.button(f"Show {city}", on_click=lambda i: print(city))

Real example: per-row delete button

import guile as gui

rows = gui.state([
    {"id": 1, "name": "Alice", "score": 92},
    {"id": 2, "name": "Bob",   "score": 78},
    {"id": 3, "name": "Carol", "score": 85},
])

def delete(row_id):
    rows.set([r for r in rows.value if r["id"] != row_id])

@gui.app("Gradebook", width=480, height=360)
def ui():
    with gui.col(padding=20, gap=12):
        gui.title("Gradebook")

        with gui.card(gap=8):
            for row in rows.value:
                with gui.row(justify="space-between", align="center",
                             key=str(row["id"])):
                    gui.text(row["name"], bold=True)
                    gui.badge(str(row["score"]), variant="primary")
                    gui.button("✕", variant="ghost", size="sm",
                               # capture row["id"] now, not at click time
                               on_click=lambda i=row["id"]: delete(i),
                               key=f"del-{row['id']}")

The key=f"del-{row['id']}" on the button is also important here — it gives each button a stable DOM ID so the patcher can tell them apart when the list changes length.

Tabs

gui.tabs() renders a tab strip and returns the active label as a plain string. It manages its own internal state — no gui.state() declaration at module level is needed. The active panel is a plain Python if/elif block on that string.

import guile as gui

@gui.app("Dashboard", width=560, height=420)
def ui():
    with gui.col(padding=20, gap=14):
        gui.title("Dashboard")

        # gui.tabs() renders the strip and returns the active label.
        # key= is required so the active tab survives re-renders.
        tab = gui.tabs(["Overview", "Data", "Info"], key="main-tabs")

        # Each panel is a plain if/elif block — only the active panel
        # is rendered; the others don't exist in the DOM at all.
        if tab == "Overview":
            with gui.card(gap=8):
                gui.text("Summary statistics here.")

        elif tab == "Data":
            with gui.card(padding=0, style="overflow-y:auto;max-height:280px"):
                gui.table(records)

        elif tab == "Info":
            with gui.card(gap=6):
                gui.text("Built with guile.", muted=True, size="sm")
Unlike a hidden-div approach, only the active panel is rendered at all. Inactive panels cost nothing — no DOM nodes, no layout, no memory.

Programmatic tab switching

When a callback needs to switch the active tab — for example, jumping to the Data tab automatically after a file loads — bind to an external State using value= and on_change=, the same pattern every other input widget uses:

import guile as gui

active = gui.state("Overview")   # module level — controls the active tab

def load_file(path):
    records.set(load(path))
    active.set("Data")           # jump to Data tab on load

@gui.app("Dashboard", width=560, height=420)
def ui():
    with gui.col(padding=20, gap=14):
        with gui.row(gap=8, align="center"):
            gui.file_picker("Load CSV", on_change=load_file, key="fp")

        # Pass the external State via value= and on_change= so the
        # strip stays in sync with both user clicks and active.set().
        gui.tabs(["Overview", "Data", "Info"],
                 value=active, on_change=active.set, key="main-tabs")

        if active.value == "Overview":
            ...
        elif active.value == "Data":
            gui.table(records.value)

When using an external state, read active.value in the panel conditions (not the return value of gui.tabs(), which is only the initial value in that render).