Modern components for Python frontend development.
Built on the Crank.js framework.
- Pythonic Hyperscript - Clean template
h.div["content"]syntax inspired by JSX - Generator Components - Natural state management using Python generators
- Async Components - Components can use
async def/awaitandawait for - Lifecycle Decorators -
@ctx.refresh,@ctx.after,@ctx.cleanup - Dual Runtime - Works with both Pyodide (full support) and MicroPython (basic components)
- Browser Native - No build step
<py-config type="toml">
packages = ["crankpy"]
[js_modules.main]
"https://esm.run/@b9g/crank@latest/crank.js" = "crank_core"
"https://esm.run/@b9g/crank@latest/dom.js" = "crank_dom"
</py-config>pip install crankpyfrom crank import h, component
from crank.dom import renderer
from js import document
@component
def Greeting(ctx):
for _ in ctx:
yield h.div["Hello, Crank.py!"]
renderer.render(h(Greeting), document.body)@component
def Counter(ctx):
count = 0
@ctx.refresh
def increment():
nonlocal count
count += 1
@ctx.refresh
def decrement():
nonlocal count
count -= 1
for _ in ctx:
yield h.div[
h.h2[f"Count: {count}"],
h.button(onclick=increment)["+"],
h.button(onclick=decrement)["-"]
]On Python 3.14+ (and MicroPython v1.28.0+), the jsx tag builds elements
from PEP 750 template strings. The grammar matches the Crank.js jsx
template tag. The html name is an alias for the same tag.
from crank import component
from crank.dom import renderer
from crank.template import jsx
from js import document
@component
def Greeting(ctx, props):
name = props.get("name", "World")
for _ in ctx:
yield jsx(t'<div class="greeting">Hello, {name}!</div>')
renderer.render(jsx(t'<{Greeting} name="Crank" />'), document.body)The tag supports props (id="a", checked, onclick={handler}), spread
props (...{props}), fragments (bare children), component tags
(<{Component}>), the generic closing tag (<//>), and comments
(<!-- -->). Parse results are cached per template, so components can
call jsx on every render.
@component
async def UserProfile(ctx, props):
old_user_id = None
for props in ctx: # Props automatically update!
user_id = props.user_id
if user_id != old_user_id:
user = await fetch_user(user_id) # Fetches when props change
yield h.div[
h.img(src=user.avatar),
h.h2[user.name],
h.p[user.bio]
]
else:
yield h(Copy)
old_user_id = user_id
# Usage
h(UserProfile, user_id=123)Crank.py uses a clean, Pythonic hyperscript syntax:
# Simple text content
h.div["Hello World"]
h.p["Some text"]
# With props
h.input(type="text", value=text)
h.div(className="my-class")["Content"]
# No props or children
h.hr()
h.br()
# Snake_case → kebab-case conversion
h.div(
data_test_id="button", # becomes data-test-id
aria_hidden="true" # becomes aria-hidden
)["Content"]
# Props spreading (explicit + spread)
h.button(className="btn", **userProps)["Click me"]
h.input(type="text", required=True, **formProps)
# Multiple dict merging (when needed)
h.div(**{**defaults, **themeProps, **userProps})["Content"]
# Nested elements
h.ul[
h.li["Item 1"],
h.li["Item 2"],
h.li[
"Item with ",
h.strong["nested"],
" content"
]
]
# Style objects (snake_case → kebab-case)
h.div(style={
"background_color": "#f0f0f0", # becomes background-color
"border_radius": "5px" # becomes border-radius
})["Styled content"]
# Reserved keywords with spreading
h.div(**{"class": "container", **userProps})["Content"]
# Or better: use className instead of class
h.div(className="container", **userProps)["Content"]# Component without props
h(MyComponent)
# Component with props
h(MyComponent, name="Alice", count=42)
# Component with children
h(MyComponent)[
h.p["Child content"]
]
# Component with props and children
h(MyComponent, title="Hello")[
h.p["Child content"]
]# Simple fragments - just use Python lists!
["Multiple", "children", "without", "wrapper"]
[h.div["Item 1"], h.div["Item 2"]]
# Fragment with props (when you need keys, etc.)
h("", key="my-fragment")["Child 1", "Child 2"]
# In context
h.div[
h.h1["Title"],
[h.p["Para 1"], h.p["Para 2"]], # Simple fragment
h.footer["Footer"]
]Crank.py supports three component signatures:
# 1. Static components (no state)
@component
def Logo():
return h.div["⚙️ Crank.py"]
# 2. Context-only (internal state)
@component
def Timer(ctx):
start_time = time.time()
for _ in ctx:
elapsed = time.time() - start_time
yield h.div[f"Time: {elapsed:.1f}s"]
# 3. Context + Props (dynamic)
@component
def TodoItem(ctx, props):
for props in ctx: # New props each iteration
todo = props.todo
yield h.li[
h.input(type="checkbox", checked=todo.done),
h.span[todo.text]
]@component
def MyComponent(ctx):
@ctx.refresh
def handle_click():
# Automatically triggers re-render
pass
@ctx.schedule
def schedule_render():
# Runs before the DOM nodes are inserted
pass
@ctx.after
def after_render(node):
# Runs after DOM updates
node.style.color = "blue"
@ctx.cleanup
def on_unmount():
# Cleanup when component unmounts
clear_interval(timer)
for _ in ctx:
yield h.div(onclick=handle_click)["Click me"]@component
def TodoApp(ctx):
todos = []
new_todo = ""
@ctx.refresh
def add_todo():
nonlocal todos, new_todo
if new_todo.strip():
todos.append({"text": new_todo, "done": False})
new_todo = ""
@ctx.refresh
def toggle_todo(index):
nonlocal todos
todos[index]["done"] = not todos[index]["done"]
for _ in ctx:
yield h.div[
h.h1["Todo List"],
h.input(
type="text",
value=new_todo,
oninput=lambda e: setattr(sys.modules[__name__], 'new_todo', e.target.value)
),
h.button(onclick=add_todo)["Add"],
h.ul[
[h.li(key=i)[
h.input(
type="checkbox",
checked=todo["done"],
onchange=lambda i=i: toggle_todo(i)
),
h.span[todo["text"]]
] for i, todo in enumerate(todos)]
]
]@component
def Clock(ctx):
import asyncio
async def update_time():
while True:
await asyncio.sleep(1)
ctx.refresh()
# Start the update loop
asyncio.create_task(update_time())
for _ in ctx:
current_time = time.strftime("%H:%M:%S")
yield h.div[
h.strong["Current time: "],
current_time
]Crank.py provides comprehensive type safety with TypedDict interfaces, Context typing, and full IDE support through Pyright.
Define strict component interfaces using TypedDict:
from typing import TypedDict, Callable, Optional
from crank import component, Context, Props, Children
# Required and optional props
class ButtonProps(TypedDict, total=False):
onclick: Callable[[], None] # Event handlers always lowercase
disabled: bool
variant: str # e.g., "primary", "secondary"
children: Children
# Complex component with nested data
class TodoItemProps(TypedDict):
todo: "TodoDict" # Reference to another type
ontoggle: Callable[[int], None]
ondelete: Callable[[int], None]
onedit: Callable[[int, str], None]
class TodoDict(TypedDict):
id: int
title: str
completed: bool
# Type-safe components
@component
def Button(ctx: Context, props: ButtonProps):
for props in ctx:
yield h.button(
onclick=props.get("onclick"),
disabled=props.get("disabled", False),
className=f"btn btn-{props.get('variant', 'primary')}"
)[props.get("children", "Click me")]
@component
def TodoItem(ctx: Context, props: TodoItemProps):
for props in ctx:
todo = props["todo"]
yield h.li[
h.input(
type="checkbox",
checked=todo["completed"],
onchange=lambda: props["ontoggle"](todo["id"])
),
h.span[todo["title"]],
h.button(onclick=lambda: props["ondelete"](todo["id"]))["×"]
]from crank import Element, Context, Props, Children
# Basic types
Props = Dict[str, Any] # General props dict
Children = Union[str, Element, List["Children"]] # Nested content
# Generic Context typing (similar to Crank.js)
Context[PropsType, ResultType] # T = props type, TResult = element result type
# Context with full method typing
def my_component(ctx: Context[MyProps, Element], props: MyProps):
# All context methods are typed
ctx.refresh() # () -> None
ctx.schedule(callback) # (Callable) -> None
ctx.after(callback) # (Callable) -> None
ctx.cleanup(callback) # (Callable) -> None
# Iterator protocol for generator components
for props in ctx: # Each iteration gets updated props (typed as MyProps)
yield h.div["Updated with new props"]
# Direct props access with typing
current_props: MyProps = ctx.propsCreate reusable, typed component patterns:
from typing import TypedDict, Generic, TypeVar, List
# Generic list component
T = TypeVar('T')
class ListProps(TypedDict, Generic[T]):
items: List[T]
render_item: Callable[[T], Element]
onselect: Callable[[T], None]
@component
def GenericList(ctx: Context[ListProps[T], Element], props: ListProps[T]):
for props in ctx: # props is properly typed as ListProps[T]
yield h.ul[
[h.li(
key=i,
onclick=lambda item=item: props["onselect"](item)
)[props["render_item"](item)]
for i, item in enumerate(props["items"])]
]
# Usage with type inference
user_list_props: ListProps[User] = {
"items": users,
"render_item": lambda user: h.span[user.name],
"onselect": handle_user_select
}# Union types for polymorphic components
from typing import Union, Literal
class IconButtonProps(TypedDict, total=False):
variant: Literal["icon", "text", "both"]
icon: str
onclick: Callable[[], None]
children: Children
class FormFieldProps(TypedDict):
name: str
value: Union[str, int, bool]
onchange: Callable[[Union[str, int, bool]], None]
# Discriminated union based on field type
field_type: Literal["text", "number", "checkbox"]
@component
def FormField(ctx: Context, props: FormFieldProps):
for props in ctx:
field_type = props["field_type"]
if field_type == "checkbox":
yield h.input(
type="checkbox",
name=props["name"],
checked=bool(props["value"]),
onchange=lambda e: props["onchange"](e.target.checked)
)
elif field_type == "number":
yield h.input(
type="number",
name=props["name"],
value=str(props["value"]),
onchange=lambda e: props["onchange"](int(e.target.value))
)
else: # text
yield h.input(
type="text",
name=props["name"],
value=str(props["value"]),
onchange=lambda e: props["onchange"](e.target.value)
)Install and configure Pyright for comprehensive type checking:
# Install type checker
uv add --dev pyright
# Run type checking
uv run pyright crank/
# Run all checks (lint + types)
make checkpyproject.toml configuration:
[tool.pyright]
pythonVersion = "3.8"
typeCheckingMode = "basic"
reportUnknownMemberType = false # For JS interop
reportMissingImports = false # Ignore PyScript imports
include = ["crank"]
exclude = ["tests", "examples"]Components receive props as Python dictionaries (converted from JS objects):
@component
def MyComponent(ctx: Context, props: Props):
for props in ctx:
# Access props using dict syntax
title = props["title"]
onclick = props["onclick"]
yield h.div[
h.h1[title],
h.button(onclick=onclick)["Click me"]
]Use lowercase for all event and callback props:
onclicknotonClickonchangenotonChangeontogglenotonToggle
This matches HTML attribute conventions and provides consistency.
Run the test suite:
# Install dependencies
pip install pytest playwright
# Run tests
pytest tests/# Clone the repository
git clone https://github.com/bikeshaving/crankpy.git crankpy
cd crankpy
# Install in development mode
pip install -e ".[dev]"
# Run examples
python -m http.server 8000
# Visit http://localhost:8000/examples/Traditional Python web frameworks use templates and server-side rendering. Crank.py brings component-based architecture to Python:
- Reusable Components - Build UIs from composable pieces
- Dynamic Updates - Explicit re-rendering with ctx.refresh()
- Generator-Powered - Natural state management with Python generators
- Browser-Native - Run Python directly in the browser via PyScript
- PyScript Applications - Rich client-side Python apps
- Educational Projects - Teaching web development with Python
- Prototyping - Rapid UI development without JavaScript
- Data Visualization - Interactive Python data apps in the browser
Use ref callbacks to access rendered DOM elements directly:
@component
def VideoPlayer(ctx):
video_element = None
def set_video_ref(el):
nonlocal video_element
video_element = el
@ctx.refresh
def play():
if video_element:
video_element.play()
@ctx.refresh
def pause():
if video_element:
video_element.pause()
for _ in ctx:
yield h.div[
h.video(
src="/path/to/video.mp4",
ref=set_video_ref
),
h.button(onclick=play)["Play"],
h.button(onclick=pause)["Pause"]
]Ref Patterns:
- Refs fire once when elements are first rendered
- Don't work on fragments - use on host elements only
- For components, explicitly pass
refprop to child elements, or call it yourself - Useful for focus management, DOM measurements, third-party integrations
@component
def AutoFocusInput(ctx, props):
for props in ctx:
yield h.input(
type="text",
placeholder=props.get("placeholder", ""),
ref=lambda el: el.focus() # Auto-focus when rendered
)Fragments let you return multiple elements without extra DOM nodes:
# Simple fragments - just use Python lists!
@component
def UserInfo(ctx, props):
user = props["user"]
for props in ctx:
yield [
h.h2[user["name"]],
h.p[user["bio"]],
h.span[f"Joined: {user['joined']}"]
]
# Fragment with props (for keys, etc.)
@component
def ConditionalContent(ctx, props):
show_content = props.get("show", False)
for props in ctx:
if show_content:
yield h("", key="content-fragment")[
h.div["Content block 1"],
h.div["Content block 2"]
]
else:
yield h("", key="empty-fragment")["No content"]
# Mixed fragments in JSX-like syntax
@component
def Navigation(ctx):
for _ in ctx:
yield h.nav[
h.div(className="logo")["MyApp"],
[ # Fragment for nav items
h.a(href="/home")["Home"],
h.a(href="/about")["About"],
h.a(href="/contact")["Contact"]
],
h.button["Menu"]
]Keys help Crank identify which elements have changed in lists:
@component
def TodoList(ctx, props):
for props in ctx:
todos = props["todos"]
yield h.ul[
[h.li(key=todo["id"])[
h.input(
type="checkbox",
checked=todo["completed"],
onchange=lambda todo_id=todo["id"]: props["onToggle"](todo_id)
),
h.span[todo["text"]],
h.button(onclick=lambda todo_id=todo["id"]: props["onDelete"](todo_id))["×"]
] for todo in todos]
]
# Without keys - elements match by position (can cause issues)
# With keys - elements match by identity (preserves state correctly)
@component
def DynamicList(ctx):
items = ["A", "B", "C", "D"]
reversed_items = False
@ctx.refresh
def toggle_order():
nonlocal reversed_items
reversed_items = not reversed_items
for _ in ctx:
current_items = items[::-1] if reversed_items else items
yield h.div[
h.button(onclick=toggle_order)["Toggle Order"],
h.ul[
[h.li(key=item)[
f"Item {item} (with preserved state)"
] for item in current_items]
]
]Key Guidelines:
- Use stable, unique values (IDs, not array indices)
- Keys only need to be unique among siblings
- Can be strings, numbers, or any JavaScript value
- Essential for stateful components and form inputs
The copy prop prevents elements from re-rendering for performance optimization:
@component
def ExpensiveList(ctx, props):
for props in ctx:
items = props["items"]
yield h.ul[
[h.li(
key=item["id"],
copy=not item.get("hasChanged", True) # Skip render if unchanged
)[
h(ExpensiveComponent, data=item["data"])
] for item in items]
]
# Copy with string selectors (Crank 0.7+)
@component
def SmartForm():
return h.form[
# Copy all props except value (keeps input uncontrolled)
h.input(
copy="!value",
type="text",
placeholder="Enter text...",
name="username"
value=props["value"]
),
# Copy only specific props
h.div(
copy="class id",
className="form-section",
id="user-info",
data_updated=props.get("timestamp")
)[
h.label["Username"]
],
# Copy children from previous render
h.div(copy="children", className="dynamic")[
# Children preserved from last render
props["children"]
]
]Copy Prop Syntax:
copy=True- Prevent all re-renderingcopy=False- Normal re-rendering (default)copy="!value"- Copy all props exceptvaluecopy="class children"- Copy onlyclassandchildren- Cannot mix
!and regular syntax
@component
def MarkdownRenderer(ctx, props):
for props in ctx:
# Process markdown to HTML
markdown_text = props["markdown"]
html_content = markdown_to_html(markdown_text) # Your markdown processor
yield h.div[
h(Raw, value=html_content)
]
# Insert actual DOM nodes
@component
def CanvasChart(ctx, props):
for props in ctx:
# Create chart canvas with external library
canvas_node = create_chart(props["data"])
yield h.div[
h.h3["Sales Chart"],
h(Raw, value=canvas_node)
]from js import document
@component
def Modal(ctx, props):
for props in ctx:
is_open = props.get("isOpen", False)
if is_open:
# Render modal into document body instead of current location
modal_root = document.getElementById("modal-root")
yield h(Portal, root=modal_root)[
h.div(className="modal-backdrop", onclick=props["onClose"])[
h.div(className="modal-content", onclick=lambda e: e.stopPropagation())[
h.div(className="modal-header")[
h.h2[props["title"]],
h.button(onclick=props["onClose"])["×"]
],
h.div(className="modal-body")[
props.get("children", [])
]
]
]
]
# Usage
@component
def App(ctx):
show_modal = False
@ctx.refresh
def open_modal():
nonlocal show_modal
show_modal = True
@ctx.refresh
def close_modal():
nonlocal show_modal
show_modal = False
for _ in ctx:
yield h.div[
h.h1["My App"],
h.button(onclick=open_modal)["Open Modal"],
h(Modal,
isOpen=show_modal,
title="Example Modal",
onClose=close_modal
)["Modal content here!"]
]@component
def TextManipulator(ctx):
text_node = None
def set_text_ref(text):
nonlocal text_node
text_node = text
@ctx.refresh
def update_text():
if text_node:
text_node.data = "Updated directly!"
for _ in ctx:
yield h.div[
h(Text, value="Original text", ref=set_text_ref),
h.button(onclick=update_text)["Update Text"]
]@component
def MemoizedComponent(ctx, props):
last_props = None
for props in ctx:
if last_props and props_equal(props, last_props):
# Don't re-render if props haven't changed
yield h(Copy)
else:
yield h(ExpensiveComponent, **props)
last_props = props
def props_equal(a, b):
"""Shallow comparison of props"""
return (
set(a.keys()) == set(b.keys()) and
all(a[key] == b[key] for key in a.keys())
)
# Higher-order memo component
def memo(Component):
@component
def MemoWrapper(ctx, props):
last_props = None
yield h(Component, **props)
for props in ctx:
if last_props and props_equal(props, last_props):
yield h(Copy)
else:
yield h(Component, **props)
last_props = props
return MemoWrapper
# Usage
@memo
@component
def ExpensiveItem(ctx, props):
for props in ctx:
# Expensive computation here
yield h.div[f"Processed: {props['data']}"]# Combining keys, copy, and memoization
@component
def OptimizedList(ctx, props):
for props in ctx:
items = props["items"]
yield h.ul[
[h.li(
key=item["id"],
copy=not item.get("_dirty", False) # Skip clean items
)[
h(MemoizedItem,
data=item["data"],
onUpdate=props["onItemUpdate"]
)
] for item in items]
]
# Selective prop copying for performance
@component
def SmartComponent(ctx, props):
for props in ctx:
yield h.div[
# Only re-renders children, does not touch class style
h.div(
className=props["className"],
style=props["style"],
copy="class style"
)[
props["dynamicContent"]
],
# Expensive chart that rarely changes
h.div(copy=not props.get("chartDataChanged", False))[
h(ChartComponent, data=props["chartData"])
]
]Crank.py runs on Pyodide and on MicroPython.
Element construction is pure Python on both runtimes. The h builder makes
plain Python nodes. Crank.py transforms these nodes into createElement
calls at the render boundary and at component boundaries.
- All features work: sync components, generators, async functions, and async generators.
- Sync components and generator components work.
- Async generators (
async defwithyield) are not available. MicroPython does not compile them. Use a regulardefwithyieldinstead. - MicroPython proxies do not expose
Symbol.iterator, so Crank.py drives generators through a wrapper that implements the JavaScript iterator protocol.
# Works in both runtimes
@component
def Counter(ctx):
count = 0
@ctx.refresh
def increment():
nonlocal count
count += 1
for _ in ctx:
yield h.div[
h.h1[f"Count: {count}"],
h.button(onclick=increment)["+"]
]- Crank.js Documentation - The underlying framework
- PyScript Guide - Running Python in browsers
- Examples - See Crank.py in action
Contributions welcome! Please read our Contributing Guide first.
MIT © 2025