Hyper templates are .hyper files that combine Python and HTML.
Set up your project:
app/
├── components/
│ ├── __init__.py
│ └── Hello.hyper
└── main.py
Create components/Hello.hyper:
<h1>Hello World</h1>
Render it in main.py:
from app.components import Hello
print(Hello())<h1>Hello World</h1>Props make your template accept inputs. Define them above ---.
Create components/Greeting.hyper:
name: str
---
<h1>Hello {name}</h1>
from app.components import Greeting
print(Greeting(name="Alice"))<h1>Hello Alice</h1>Props can have defaults:
Create components/Counter.hyper:
name: str = "World"
count: int = 0
---
<h1>Hello {name}</h1>
<p>Count: {count}</p>
from app.components import Counter
print(Counter()) # Uses defaults
print(Counter(name="Bob", count=5)) # Override defaultsSlots let your template accept content from the caller.
Add a layouts/ folder:
app/
├── components/
│ └── ...
├── layouts/
│ ├── __init__.py
│ └── Layout.hyper
└── main.py
Create layouts/Layout.hyper:
title: str = "My Site"
---
<!doctype html>
<html>
<head>
<title>{title}</title>
</head>
<body>
{...}
</body>
</html>
The {...} marks where caller content is inserted. It adds the reserved, keyword-only content argument:
from inspect import signature
signature(Layout)
# (*, title: str = "My Site", content=None)Create pages/Home.hyper:
from app.layouts import Layout
---
<{Layout} title="Home">
<h1>Welcome!</h1>
<p>This replaces the {...} in Layout.</p>
</{Layout}>
Render it:
from app.pages import Home
print(Home())<!doctype html>
<html>
<head>
<title>Home</title>
</head>
<body>
<h1>Welcome!</h1>
<p>This replaces the {...} in Layout.</p>
</body>
</html>Provide fallback content when nothing is passed:
title: str = "My Site"
---
<!doctype html>
<html>
<head>
<title>{title}</title>
</head>
<body>
<{...}>
<p>No content provided.</p>
</{...}>
</body>
</html>
Create components/Nav.hyper:
is_admin: bool
---
<nav>
if is_admin:
<a href="/admin">Admin</a>
else:
<a href="/account">Account</a>
end
</nav>
from app.components import Nav
print(Nav(is_admin=True)) # <nav><a href="/admin">Admin</a></nav>
print(Nav(is_admin=False)) # <nav><a href="/account">Account</a></nav>Create components/List.hyper:
items: list[str]
---
<ul>
for item in items:
<li>{item}</li>
end
</ul>
from app.components import List
print(List(items=["Apple", "Banana", "Cherry"]))<ul>
<li>Apple</li>
<li>Banana</li>
<li>Cherry</li>
</ul>Create components/Status.hyper:
status: str
---
match status:
case "loading":
<p>Loading...</p>
case "error":
<p>Error!</p>
case _:
<p>Ready</p>
end
from app.components import Status
print(Status(status="loading")) # <p>Loading...</p>
print(Status(status="done")) # <p>Ready</p>Indentation defines Python blocks. Use end only when it helps show the boundary:
if show_list:
<ul>
for item in items:
<li>{item}</li>
end
</ul>
end
Both end lines above are optional. If present, each must align with its opener. HTML still needs closing tags.
The same rule applies before and after ---, and in component libraries. Branch clauses share their parent's optional end.
Python's single-line form also works without end:
if missing: return
if ready: <span>Ready</span>
Use Python expressions directly inside {} for inline logic.
Use Python's ternary syntax for inline conditionals:
count: int
---
<span>{count} {"item" if count == 1 else "items"}</span>
print(Template(count=1)) # <span>1 item</span>
print(Template(count=5)) # <span>5 items</span>Use or for fallback values:
title: str | None
---
<h1>{title or "Untitled"}</h1>
print(Template(title="Hello")) # <h1>Hello</h1>
print(Template(title=None)) # <h1>Untitled</h1>| Pattern | Use Case |
|---|---|
for...end block |
Multiple elements, complex logic |
if...end block |
Multiple elements, else/elif branches |
{x if cond else y} |
Inline choice between two values |
{x or fallback} |
Provide default for falsy values |
Templates can have multiple insertion points. Each named slot adds a keyword-only argument with the same name.
Create layouts/Layout.hyper:
<!doctype html>
<html>
<body>
<aside>
<{...sidebar}>
<p>Default sidebar</p>
</{...sidebar}>
</aside>
<main>
{...}
</main>
</body>
</html>
Create pages/Dashboard.hyper:
from app.layouts import Layout
---
<{Layout}>
<{...sidebar}>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</{...sidebar}>
<h1>Main Content</h1>
<p>This goes to the default slot.</p>
</{Layout}>
from app.pages import Dashboard
print(Dashboard())<!doctype html>
<html>
<body>
<aside>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</aside>
<main>
<h1>Main Content</h1>
<p>This goes to the default slot.</p>
</main>
</body>
</html>When a named slot contains one element, mark it directly:
from app.layouts import Layout
---
<{Layout}>
<nav {...sidebar}>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<h1>Main Content</h1>
</{Layout}>
The {...sidebar} on the element marks it as sidebar content. Same output as above.
Default and named slots use the same arguments from Python:
Layout(
title="Dashboard",
content=main_content,
sidebar=navigation,
).render()content is reserved for the default slot. A prop cannot share a named slot's name:
from hyper import Component
def Panel(*, sidebar: str) -> Component:
<aside>{...sidebar}</aside>
end
error: `sidebar` is both a prop and a named slot
Rename the prop or the slot.
Use expressions directly in attribute values:
url: str
---
<a href="{url}">Visit</a>
print(Template(url="https://example.com"))<a href="https://example.com">Visit</a>Without quotes also works:
element_id: str
---
<button id={element_id}>Click</button>
<button id="my-button">Click</button>Multiple substitutions in one attribute:
first: str
last: str
---
<button data-name="{first} {last}">Click</button>
<button data-name="Alice Smith">Click</button>Split HTML and component attributes across lines:
<div
id="profile"
class={["card", {"active": active}]}
{**attrs}
>
Content
</div>
<{Card}
title={title}
selected
/>
The formatting whitespace does not render:
<div id="profile" class="card active" data-role="admin">Content</div>Whitespace inside a quoted value remains part of that value:
<button
_="
on click
toggle .active
"
>
Toggle
</button>
<button _="
on click
toggle .active
">Toggle</button>Multiline expressions follow Python bracket rules:
<div
class={
[
"card",
{"selected": selected},
]
}
>
Content
</div>
True renders the attribute. False omits it:
<button disabled={True} hidden={False}>Submit</button>
<button disabled>Submit</button>The compiler knows which HTML attributes are boolean (disabled, checked, readonly, required, hidden, etc.) and handles them automatically. You don't need to think about whether to render disabled vs disabled="true". Pass a bool and the compiler does the right thing.
When variable name matches attribute name, use shorthand:
disabled: bool = False
title: str
---
<button {disabled} {title}>Click</button>
The {disabled} expands to disabled={disabled}.
print(Template(title="Save", disabled=True))<button disabled title="Save">Click</button>Reserved keywords: Use {class} and {type} directly. The compiler handles them:
class: list
---
<button {class}>Click</button>
Compiles to valid Python using _class internally.
The class attribute has special handling. Provide a list:
class = ["btn", "btn-primary", "active"]
---
<button {class}>Click</button>
<button class="btn btn-primary active">Click</button>Mix strings and conditional dicts:
is_active: bool
is_disabled: bool
---
class = [
"btn",
"btn-primary",
{"active": is_active, "disabled": is_disabled}
]
<button {class}>Click</button>
print(Template(is_active=True, is_disabled=False))<button class="btn btn-primary active">Click</button>Falsy values are filtered out:
class = ["btn", None, False and "hidden", {"active": True}]
---
<button {class}>Click</button>
<button class="btn active">Click</button>Provide a dictionary for inline styles:
style = {"color": "red", "font-weight": "bold", "margin": "10px"}
---
<p {style}>Important text</p>
<p style="color: red; font-weight: bold; margin: 10px">Important text</p>Dictionaries expand to prefixed attributes:
data = {"user-id": 123, "role": "admin"}
aria = {"label": "Close dialog", "hidden": True}
---
<div {data} {aria}>Content</div>
<div data-user-id="123" data-role="admin" aria-label="Close dialog" aria-hidden="true">Content</div>Boolean values in aria become "true" or "false" per the ARIA spec. Unlike boolean HTML attributes, ARIA attributes are never omitted. aria-hidden="false" and the absence of aria-hidden mean different things.
Use {**dict} to spread a dictionary as individual attributes:
attrs = {"href": "https://example.com", "target": "_blank"}
---
<a {**attrs}>External link</a>
<a href="https://example.com" target="_blank">External link</a>Combine spreading with individual attributes:
base_attrs = {"id": "my-link"}
target: str = "_blank"
---
<a {**base_attrs} {target}>Link</a>
<a id="my-link" target="_blank">Link</a>Special attributes like class work when spread:
class = ["btn", {"active": True}]
attrs = {"class": class, "id": "act_now", "data": {"wow": "such-attr"}}
---
<button {**attrs}>Click</button>
<button class="btn active" id="act_now" data-wow="such-attr">Click</button>Think of elements as function calls: <button {**attrs}> is like calling button(**attrs). The ** in the parameter declaration collects extra keyword arguments into a dict, and the ** on the element spreads them back out, the same symmetry as Python functions.
Use one of {**kwargs}, {**props}, {**rest}, {**attrs}, or {**attributes} to accept and forward arbitrary attributes. The compiler adds it to the function signature automatically.
label: str
type: str = "button"
---
<button {type} {**attrs}>
{label}
</button>
def Button(*, label: str, type: str = "button", **attrs):
...<{Button} label="Save" hx-post="/save" class="btn" disabled />
<button type="button" hx-post="/save" class="btn" disabled>
Save
</button>Other names are not auto-injected:
my_dict = {"class": "card"}
<{Card} {**my_dict} />
# my_dict is a local variable, not a parameter
Group elements without adding a wrapper:
<>
<button>Save</button>
<button>Cancel</button>
</>
Render text without adding a wrapper:
<>Text without a wrapper</>
Use -> Component to write a template function. Hyper reads the annotation during compilation, much like FastAPI uses annotations to control behavior.
A standalone {name} in that function is template output, not a Python set expression.
from hyper import Component
def Heading(*, text: str) -> Component:
<h1>{text}</h1>
end
Calling a template definition creates a lazy component instance. Render it explicitly:
heading = Heading(text="Hello")
html = heading.render()A function without template output remains an ordinary Python factory:
from hyper import Component
def Greeting(*, name: str) -> Component:
return Heading(text=f"Hello, {name}")
Use bare return to stop template output:
from hyper import Component
def Greeting(*, name: str | None) -> Component:
if name is None:
<p>Hello, stranger</p>
return
end
<h1>Hello, {name}</h1>
end
Do not mix template output and return value in the same function. Compose components with tags instead:
from hyper import Component
def Greeting() -> Component:
<h1>Hello</h1>
<{Heading} text="Goodbye" />
end
Each function has its own scope. Markup in a nested definition does not make its enclosing factory a template. Ordinary nested helpers retain Python return and yield behavior.
These examples keep optional end markers for visual clarity. See Block Boundaries.
Nested component definitions use normal Python scope. They may capture values from their enclosing function:
from hyper import Component
def Greeting(*, name: str) -> Component:
def Heading() -> Component:
<h1>Hello, {name}</h1>
<{Heading} />
Add @subcomponent when callers also need the nested component:
from hyper import Component, subcomponent
def Page(*, title: str) -> Component:
@subcomponent
def Header(*, title: str) -> Component:
<header>{title}</header>
<{Header} title={title} />
Header receives its own props and does not capture parent render state:
page = Page(title="Home")
page.render()
other_header = Page.Header(title="Other")
other_header.render()<header>Home</header>
<header>Other</header>Page.Header is read-only. Without @subcomponent, Header remains local and Page.Header does not exist.
Every template so far has defined one component named after its file.
Group related components in one library file. Create components/forms.hyper:
from hyper import Component
def Form(*, action: str) -> Component:
<form {action}>
{...}
</form>
end
def Input(*, name: str, type: str = "text") -> Component:
<input {name} {type} />
end
def Button(*, type: str = "submit") -> Component:
<button {type}>
{...}
</button>
end
Create pages/Login.hyper:
from app.components.forms import Form, Input, Button
---
<{Form} action="/login">
<{Input} name="email" type="email" />
<{Input} name="password" type="password" />
<{Button}>Sign In</{Button}>
</{Form}>
from app.pages import Login
print(Login())<form action="/login">
<input name="email" type="email" />
<input name="password" type="password" />
<button type="submit">Sign In</button>
</form>Define imports and helper functions above ---.
Create components/Article.hyper:
from datetime import datetime
def format_date(d: datetime) -> str:
return d.strftime("%B %d, %Y")
end
title: str
created_at: datetime
---
<article>
<h1>{title}</h1>
<p>Published: {format_date(created_at)}</p>
</article>
from datetime import datetime
from app.components import Article
print(Article(title="Hello", created_at=datetime(2024, 12, 25)))<article>
<h1>Hello</h1>
<p>Published: December 25, 2024</p>
</article>Python comments for server-side (not in output):
# This won't appear in HTML
<h1>Title</h1>
HTML comments for client-side:
<!-- This appears in page source -->
<h1>Title</h1>
All values are HTML-escaped by default:
user_input: str
---
<div>{user_input}</div>
If user_input is <script>alert('xss')</script>, output is:
<div><script>alert('xss')</script></div>Render trusted HTML with safe():
from hyper import safe
html_content: str
---
<div>{safe(html_content)}</div>
Only use safe() for content you trust (e.g., sanitized HTML from your database).
Call a component to render all its HTML:
from app.pages import Feed
html = Feed(posts=all_posts).render()Pass stream=True to send chunks as they render:
from fastapi.responses import StreamingResponse
@app.get("/feed")
def feed():
return StreamingResponse(
Feed(posts=all_posts).render(stream=True),
media_type="text/html",
)Write the component normally:
posts: list[Post]
---
<div class="feed">
for post in posts:
<article>{post.title}</article>
end
</div>
Each file is one implicit component or one component library.
If CPython accepts the complete file, Hyper loads it unchanged as a normal module. Use --- or literal markup when the source needs template semantics.
Use --- to select an implicit component and separate setup from rendering:
from utils import helper
name: str
count: int = 0
---
greeting = helper(name)
<div>
<h1>{greeting}</h1>
<span>{count}</span>
</div>
Put imports, helpers, constants, and props above ---. Put rendering code below it.
Plain HTML needs no separator:
<div>Hello World</div>
A component library needs no separator either:
from hyper import Component
def Header(*, title: str) -> Component:
<header>{title}</header>
end
def Footer() -> Component:
<footer>Copyright 2024</footer>
end
from app.components.layout import Header, FooterTop-level output selects an implicit component even without ---:
<h1>Hello</h1>
from app.pages import HomeDeclarations and Python without rendered output select library mode:
from hyper import Component
DEFAULT_TITLE = "Home"
def Header(*, title: str = DEFAULT_TITLE) -> Component:
<header>{title}</header>
end
from app.components.layout import DEFAULT_TITLE, HeaderNormal Python takes precedence when both files exist:
Home.py
Home.hyper
from app.pages import Home # Home.pyImplicit components must live inside a package. Root-level .hyper files work only as component libraries.