The complete frontend stack for Python.
Citry is a free, open source HTML-first component framework for Python web applications. From server-rendered HTML to browser behavior and back to a Python handler, one component holds all of it. No second application, no separate build.
pip install citryfrom citry import Component, SlotInput
class ProductCard(Component):
class Kwargs:
tags: list[str]
likes: int = 0
accent: str = "#175cd3"
class Slots:
body: SlotInput
footer: SlotInput | None = None
class State(Kwargs):
pass
class Events:
def like(self, state):
return ProductCard(
tags=state.tags,
likes=state.likes + 1,
)
def template_data(self, kwargs, slots):
return {
"likes": kwargs.likes,
"tags": kwargs.tags,
}
def js_data(self, kwargs, slots):
return {"likes": kwargs.likes}
def css_data(self, kwargs, slots):
return {"accent": kwargs.accent}
template = """
<article
class="card"
x-data="{ open: false }"
>
<c-slot name="body" />
<c-for each="tag in tags">
<c-Tag
c-label="tag"
$c-props="{ highlight: open }"
@click="open = !open"
/>
</c-for>
<c-empty>
<p>{{ tr("product-card-no-tags") }}</p>
</c-empty>
<button type="button" @c-click="like">
Like <span x-text="likes">{{ likes }}</span>
</button>
<c-slot name="footer">
No footer yet
</c-slot>
</article>
"""
js = """
$component(({ els, data }) => {
const cardEl = els[0];
animateLikes(cardEl, data.likes);
});
"""
css = """
.card {
border-left: 3px solid var(--accent);
}
.tag--active {
color: var(--accent);
}
"""
messages = """
product-card-no-tags = No tags yet.
"""
class Dependencies:
js = ["https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"]
css = ["https://unpkg.com/normalize.css@8.0.1/normalize.css"]
html = str(ProductCard(
tags=["new", "sale"],
slots={"body": "Aurora Lamp"}
))
Development sponsored by
One file holds the entire component end-to-end.
Inputs, slots, markup, translated messages, server events and state, browser behavior, and styles, all live together. No context switching.
Point at any marked line below to see what it does:
from citry import Component, SlotInputclass ProductCard(Component): class Kwargs: tags: list[str] likes: int = 0 accent: str = "#175cd3" class Slots: body: SlotInput footer: SlotInput | None = None class State(Kwargs): pass class Events: def like(self, state): return ProductCard( tags=state.tags, likes=state.likes + 1, ) def template_data(self, kwargs, slots): return { "likes": kwargs.likes, "tags": kwargs.tags, } def js_data(self, kwargs, slots): return {"likes": kwargs.likes} def css_data(self, kwargs, slots): return {"accent": kwargs.accent} template = """ <article class="card" x-data="{ open: false }" > <c-slot name="body" /> <c-for each="tag in tags"> <c-Tag c-label="tag" $c-props="{ highlight: open }" @click="open = !open" /> </c-for> <c-empty> <p>{{ tr("product-card-no-tags") }}</p> </c-empty> <button type="button" @c-click="like"> Like <span x-text="likes">{{ likes }}</span> </button> <c-slot name="footer"> No footer yet </c-slot> </article> """ js = """ $component(({ els, data }) => { const cardEl = els[0]; animateLikes(cardEl, data.likes); }); """ css = """ .card { border-left: 3px solid var(--accent); } .tag--active { color: var(--accent); } """ messages = """ product-card-no-tags = No tags yet. """ class Dependencies: js = ["https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"] css = ["https://unpkg.com/normalize.css@8.0.1/normalize.css"]html = str(ProductCard( tags=["new", "sale"], slots={"body": "Aurora Lamp"}))Point at a marked line to see what it does.
Every input this component accepts, with its type and any default. Passing an unknown name, or leaving out a required one, is reported when the component renders rather than quietly producing a gap in the page.
Named places a caller passes markup into. body is required and footer is optional, so the contract covers content as well as data.
Server-side state available across Python event handler calls. Travels between the server and the browser. Inheriting Kwargs makes the State carry the same fields.
A public method here can be called from the browser using @c-event="like". like reads the current state and renders the updated component, which the browser then displays.
template_data prepares template variables, js_data seeds Alpine variables from JSON, and css_data creates CSS variables scoped to this one instance.
x-data holds what only the browser cares about. Opening and closing the card needs no server, so it never asks one.
<c-slot> marks the spot the caller's content drops into, inside markup this component still controls.
<c-for> repeats a child component, while <c-empty> runs when there are no tags at all. The child component <c-Tag> receives label as Python value, and highlight as Alpine (browser) value through $c-props. You can listen to children's Alpine events with regular @click.
tr() translates text to this render's locale. Translation keys are defined as Fluent syntax in this same component file.
@click stays in the browser for instant feedback, while @c-click calls the Python handler set in the value, like.
Content between the tags is the fallback for an optional slot, so a caller who skips it still gets something sensible.
Templates use js_data values directly. Add $component when an imperative library or other setup needs this instance's elements and data.
var(--accent) reads the custom property css_data produced, and it is scoped to this instance, so two cards on one page can differ without a second stylesheet.
With Fluent by Mozilla, you can define translation keys that can easily handle genders, counts, composition, or even formatting. Export the messages to translate the catalog to other locales.
Libraries this component needs. Citry loads each script only once per page, however many components may use it.
Rendering returns ordinary HTML. This makes components easy to integrate with web frameworks, or test with plain Python.
Use with any web server
or standalone.
Citry's server-side events need a route on your application. Two lines of code and you're all set. If you don't need events, you can use Citry without a server.
See the web framework integrations and server events.
from contextlib import asynccontextmanager
from fastapi import FastAPI
from citry import citry
from citry.contrib.fastapi import mount
@asynccontextmanager
async def lifespan(_app: FastAPI):
citry.initialize()
yield
app = FastAPI(lifespan=lifespan)
mount(app, citry)
from flask import Flask
from citry import citry
from citry.contrib.flask import mount
app = Flask(__name__)
mount(app, citry, prefix="/citry")
citry.initialize()
from django.urls import path
from citry import citry
from citry.contrib.django import urlpatterns as citry_urls
urlpatterns = [
path("", home_view),
*citry_urls(citry, prefix="/citry"),
]
from citry import citry
from citry.contrib.asgi import asgi_app
citry.initialize()
app = asgi_app(citry)
from citry import citry
from citry.contrib.wsgi import wsgi_app
citry.initialize()
application = wsgi_app(citry)
Catch mistakes early.
Citry was born out of frustration with Django's silent coerctions and leaky isolations.
In Citry, what you see (in your component) is what you get:
- Variables NEVER leak to other components.
- Data passing is ALWAYS explicit contracts.
- Missing values are ALWAYS error in Citry.
Read about inputs and validation, error boundaries, and testing components.
card = StatusCard(
complete=18,
total=25,
)
# Rendering is where the component's inputs are checked
str(card)
Rejected as the component is called
TypeErrorAn error occurred while rendering components StatusCard: StatusCard.Kwargs.__init__() missing 1 required positional argument: 'title'
card = StatusCard(
titel="Deploy preview",
complete=18,
total=25,
)
str(card)
Rejected, and the name you meant is offered
TypeErrorAn error occurred while rendering components StatusCard: StatusCard.Kwargs.__init__() got an unexpected keyword argument 'titel'. Did you mean 'title'?
class Greeting(Component):
class Kwargs:
name: str
def template_data(self, kwargs, slots):
return {"name": kwargs.name}
template = "<p>Hello, {{ naem }}!</p>"
str(Greeting(name="Ada"))
Pointed at the line that asked for it
KeyErrorAn error occurred while rendering components Greeting:
Error in variable: KeyError: 'naem'
1 | naem
^^^^
In template of 'Greeting':
1 | <p>Hello, {{ naem }}!</p>
^^^^^^^^^^class Child(Component):
template = "<span>{{ user_name }}</span>"
class Parent(Component):
def template_data(self, kwargs, slots):
return {"user_name": "Ada"}
template = "<div>{{ user_name }}<c-child /></div>"
str(Parent())
A child never inherits the parent's variables
KeyErrorAn error occurred while rendering components Parent > Child:
Error in variable: KeyError: 'user_name'
1 | user_name
^^^^^^^^^
In template of 'Child':
1 | <span>{{ user_name }}</span>
^^^^^^^^^^^^^^^class Page(Component):
template = """
<c-StatusCrad title="Deploy preview" />
"""
str(Page())
Named at the tag that asked for it
NotRegisteredAn error occurred while rendering components Page:
No component registered as 'statuscrad'.
In template of 'Page':
1 |
2 | <c-StatusCrad title="Deploy preview" />
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
3 | class Broken(Component):
template = "<div><span>Deploy preview</div>"
str(Broken())
The parser names the tag it expected to close
SyntaxErrorIn template Broken: Parse error: --> 1:26 | 1 | <div><span>Deploy preview</div> | ^----^ | = Mismatched tags: expected closing tag '</span>', found '</div>'
class Danger(Component):
template = "<i>{{ __import__('os').system('ls') }}</i>"
str(Danger())
Template expressions cannot reach the interpreter
SecurityErrorAn error occurred while rendering components Danger:
Error in variable: SecurityError: variable '__import__' is unsafe
1 | __import__('os').system('ls')
^^^^^^^^^^
In template of 'Danger':
1 | <i>{{ __import__('os').system('ls') }}</i>
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^Your editor understands the whole component.
Citry's VSCode extension connects all parts of the component - Py, HTML, JS, CSS. Surface errors or trace values across the languages. Add completions, diagnostics, and hover hints.
Install the VS Code extension.
Hover or focus any dotted symbol below. Ctrl-click / ⌘-click on a symbol to go to its definition.
from typing import TypedDict
from citry import CitryRender, Component
class InvitePanel(Component):
template = """
<section class="invite-panel">
<h2>{{ }}</h2>
<template x-for=" in ">
<c-
="member."
="<>
<small =''>
Available now
</small>
</>"
="{ : member. }"
/>
</template>
<p>{{ }}</p>
<form
@submit.prevent="('', { email })"
:aria-busy="('invite')"
>
<input x-model="" type="email" />
<button :disabled=" || ">
Invite member
</button>
<small x-show="$error('')">
Could not send invite.
</small>
</form>
</section>
"""
js = """
({
props: { compact: { type: Boolean } },
init: ({ , , , }) => {
scope.email = "";
effect(() => {
scope.visibleMembers = props.compact
? data..slice(0, 3)
: data.;
});
},
});
"""
class Kwargs:
title: str
members: list[]
class Events:
def invite(self, data: ) -> None:
send_invite(data["email"])
def template_data(self, kwargs, slots):
return {"title": kwargs.}
def js_data(self, kwargs, slots):
return {
"members": kwargs.,
"inviting": False,
}
class MemberChip(Component):
class Kwargs:
name: str
status: CitryRender
template = """
<span class="member-chip">
{{ name }}
{{ status }}
</span>
"""
js = """
$component({
props: { online: { type: Boolean } },
});
"""
class Member(TypedDict):
name: str
online: bool
class InviteIn(TypedDict):
email: str
Build fast with 60+ ready-made components.
Citry UI gives you accessible, themeable components for layout, forms, actions, navigation, feedback, and data display. Install here.
Grow without a rewrite.
A product that works starts running into different problems. None of them need a different framework.
Read about caching, extensions, internationalization, CSRF protection, strict CSP, HTML fragments, component libraries, and perf optimizations.
Use cache in two ways: Either cache single Component class, or cache a region in the template.
Connect any backend. Use version to retire old entries on deploy.
class ProductCard(Component):
class Kwargs:
product_id: int
class Slots:
pass
# Cache every time this component is called
class Cache:
enabled = True
ttl = 300
version = 1
template = """
<div>
{# Cache only this region #}
<c-cache key="expensive">
<c-ExpensiveUI />
</c-cache>
</div>
"""
Extend all components at once with extensions that hook into Citry's machinery.
Hooks cover the render lifecycle, components' JS and CSS scripts, and more. Extensions can be configured globally or per-component. Extensions can also add their own URL endpoints and CLI commands.
from citry import Citry, Extension
class TimingExtension(Extension):
name = "timing"
def on_component_rendered(self, ctx):
record(type(ctx.component).__name__)
return None # keep the original render
app = Citry(extensions=[TimingExtension])
Citry has first-class support for internationalisation (i18n) and localisation (l10n). Use Fluent messages beside components to define translatable text and its inputs. Put translations in locale catalogs, or install catalogs from third-party packages.
Also handles locale-aware formatting and parsing of numbers, dates, currencies, direction, and more.
from citry import Component
from citry.ext.i18n import make_context
class AccountCard(Component):
class Kwargs:
name: str
template = """
<h2>{{ tr("account-greeting", name=name) }}</h2>
"""
messages = """
# @param {str} $name - User name.
account-greeting = Welcome, { $name }.
"""
context = make_context(app, locale=request.locale)
AccountCard(name=user.name).render(
provides={"citry_i18n": context},
)
The web framework of your choice generates and validates the CSRF token:
- Django's middleware protects Citry routes unchanged
- Other hosts can plug in their own token check and browser token source.
class Profile(Component):
class Events:
# Optional for hosts with a custom token scheme.
# Django's CsrfViewMiddleware needs no Citry setup.
_csrf = check_csrf
def save(self, data: ProfileIn):
update_profile(data)
Citry can automatically pass the CSP nonce to all scripts and styles.
Citry has 2 CSP modes:
- Strict - Uses Alpine CSP build, raises error on incompatible syntax. Use for production.
- Warning - Uses regular Alpine, prints all incompatibilities, but doesn't block you. Use for development.
Optionally add verified SHA-384 integrity to the scripts.
app = Citry(
security_csp="strict",
security_script_integrity="citry",
)
# Generate a new value for every response.
nonce = new_response_nonce()
result = Page().render().serialize_result(csp_nonce=nonce)
response = HTMLResponse(result.html)
response.headers["Content-Security-Policy"] = (
f"script-src 'self' 'nonce-{nonce}'; "
f"style-src 'self' 'nonce-{nonce}'"
)
Easily integrate with HTMX. Instead of rendering a full page, render only small HTML to update one region.
Fragments carry their own JS and CSS. Citry loads whatever that region needs. Duplicate assets are never loaded twice.
card = Card(title="Welcome")
# The browser gets the markup and whatever JS and CSS it still needs
card.render().serialize(deps_strategy="fragment")
Share and publish components across projects as component libraries. Install a library with Citry.register_library()
Ideal for design systems or publishing to registries.
from citry import (
ComponentLibrary,
LibraryComponent,
SlotInput,
citry,
)
# Define library components
class AcmeBadge(LibraryComponent):
class Kwargs:
tone: str = "neutral"
class Slots:
default: SlotInput | None = None
# Create Library
acme_library = ComponentLibrary(
name="acme",
components=[AcmeBadge],
)
# Register library with Citry
citry.register_library(acme_library)
Stop re-rendering what doesn't change. Mark constants and literals with Const. Citry will recognize it and optimize the template graph.
Marking a value in Const is a promise that the value will not change between renders.
from citry import Const
# The parts that never vary are rendered once and reused
Card(cols=Const(3))
Built in public by people who care about Python and the web.
Citry is the successor to django-components (1.5k stars), distilling years of experience into an elegant and powerful framework.
This project would be nothing without its community. The people below have contributed to Citry or django-components:
Fund the work
Citry is built in the open with funding from its sponsors. They get the roadmap early, a direct line to the maintainer, and a say in what gets built next.
Sponsor Citry¤
Creator and maintainer of Citry
Discover frontend that brings joy.
pip install citry