StarHTML — Core Skill
StarHTML = Python objects that compile to reactive Datastar HTML.
After generating any component, validate with: starhtml-check <file.py>
If
starhtml-checkis not installed:curl -L https://raw.githubusercontent.com/renatocaliari/starhtml-skill/main/starhtml_check.py \ -o /usr/local/bin/starhtml-check && chmod +x /usr/local/bin/starhtml-check
UI Components: For production-ready UI, use StarUI — shadcn/ui for Python. See
./reference/starui.mdfor all 34+ components (Button, Card, Dialog, Table, etc.)
Sub-references (load when needed, same directory as this file):
./reference/starui.md·./reference/icons.md·./reference/js.md·./reference/handlers.md·./reference/slots.md·./reference/demos.mdOfficial demos (canonical runnable examples, always from official framework repo):
https://raw.githubusercontent.com/banditburai/starHTML/main/web/demos/NN_name.py
The 6 Rules You Must Not Break
R1 — No f-strings in reactive attributes (they become static)
# WRONG — evaluated once in Python, never updates in browser:
data_text=f"Count: {counter}"
# RIGHT — reactive, updates when signal changes:
data_text="Count: " + counter # for 1-2 signals
data_text=f("Count: {c}", c=counter) # for 3+ signals
# f() requires: from starhtml.datastar import f
R2 — data_show always needs flash prevention
# WRONG — element flashes visible before JS loads:
Div("modal content", data_show=is_open)
# RIGHT — hidden by default, shown reactively:
Div("modal content", style="display: none", data_show=is_open)
# Alternatives:
Div("modal content", cls="hidden", data_class_hidden=~is_open)
Div("modal content", style="opacity:0;transition:opacity .3s",
data_style_opacity=is_open.if_("1", "0"))
R3 — Positional arguments BEFORE keyword arguments
# WRONG — SyntaxError at runtime:
Div(cls="container", "Hello World")
# RIGHT:
Div("Hello World", cls="container")
# Rule: content/signals first, then attributes/handlers
R4 — Signal names must be snake_case
# WRONG — will error at runtime:
(myCounter := Signal("myCounter", 0))
# RIGHT:
(my_counter := Signal("my_counter", 0))
R5 — Walrus operator := must be wrapped in outer parentheses
# WRONG — won't register as positional argument:
name := Signal("name", "")
# RIGHT — works as positional argument in HTML elements:
(name := Signal("name", ""))
R6 — Signals are reactive state references, NOT data containers
# ❌ WRONG — Signals are NOT for storing/accessing data:
todos = Signal("todos", [])
todos.value.append(item) # .value does NOT exist!
print(len(todos)) # Signals don't support len()!
# ✅ RIGHT — Use plain Python variables for data:
todos_data = [] # Python variable for the actual data
(todos_count := Signal("todos_count", 0)) # Signal for reactive UI state
# Add item to data:
todos_data.append({"id": 1, "text": "Buy milk"})
# Update Signal (syncs to frontend, can be received in backend):
yield signals(todos_count=len(todos_data))
Understanding Signals vs Data
Signals are reactive state references that sync between frontend ($signalName in JS) and backend (Python Signal objects). They are NOT Python containers for data.
Data lives in regular Python variables (lists, dicts, strings, etc.).
Pattern: Python Data + Reactive Signals
# State management with pure Python
todos_data = [] # The actual data (Python list)
next_id = 1 # Counter (Python int)
# Signals for reactive state (frontend + backend)
(todos_count := Signal("todos_count", 0))
(is_loading := Signal("is_loading", False))
@rt("/todos/add", methods=["POST"])
@sse
def add_todo(todo_text: str):
global next_id
# 1. Update Python data (always use variables for data)
todos_data.append({"id": next_id, "text": todo_text})
next_id += 1
# 2. Send updated UI to client
yield elements(render_todo_item(todos_data[-1]), "#todo-list", "append")
# 3. Update Signals (syncs to frontend AND can be received in backend)
yield signals(todos_count=len(todos_data), is_loading=False)
Signals Can Flow Both Ways
# Frontend → Backend: Signal as parameter
@rt("/api/search")
@sse
def search(req, query: Signal): # Receives Signal from frontend
results = db.search(query) # Can read Signal in backend
yield signals(results_count=len(results))
Frontend-Only Signals (optional _ prefix)
# Signal with _ prefix = frontend-only by convention
# (no backend parameter, not received in SSE handlers)
(_animation_state := Signal("_animation_state", "idle"))
# With _ref_only=True, Signal is excluded from data-signals HTML attribute
(_cache := Signal("_cache", {}, _ref_only=True))
⚠️ Common Signal Mistakes
Mistake 1: f-string with Signal — displays $signal_name literal
(thought_count := Signal("thought_count", 5))
# ❌ WRONG — f-string converts Signal to literal string "$thought_count"
Div(f"{thought_count} thoughts captured") # Shows: "$thought_count thoughts captured"
# ✅ RIGHT — Signal as argument (concatenates value)
Div(thought_count, " thoughts captured") # Shows: "5 thoughts captured"
# ✅ RIGHT — Reactive attribute (updates dynamically)
Span(data_text=thought_count)
Mistake 2: Signal in Python conditional — always truthy
(is_saving := Signal("is_saving", False))
# ❌ WRONG — Signal object is always truthy in Python
Button("Save" if not is_saving else "Saving...") # Always shows "Saving..."
# ✅ RIGHT — Use reactive attribute
Button(
"Save this thought",
data_text=is_saving.if_("Saving...", "Save this thought")
)
Key points:
- Never try to read
signal.valueor uselen(signal)— Signals are not containers - Store data in Python variables; use Signals for reactive state that syncs UI
- Signals without
_prefix automatically sync to backend via parameters - In SSE: always
yield signals(...)at the end to reset state - NEVER use f-strings with Signals — use arguments or
data_text - **NEVER use Signals in Python conditionals — use reactive attributes
Quick Reference
from starhtml import *
# Define reactive state — walrus := always in outer ()
(counter := Signal("counter", 0))
(name := Signal("name", ""))
(visible := Signal("visible", True))
# Reactive attributes
data_show=visible # show/hide element
data_text=name # display signal value as text
data_bind=name # two-way binding (inputs)
data_class_active=visible # toggle class "active"
# Events
data_on_click=counter.add(1)
data_on_input=(search_fn, {"debounce": 300}) # wait 300ms after typing
data_on_scroll=(update_fn, {"throttle": 16}) # max 60fps
data_on_submit=(post("/api/save"), {"prevent": True})
# Signal operations
counter.add(1) # increment: $counter + 1
counter.set(0) # assign: $counter = 0
visible.toggle() # flip boolean: !$visible
name.upper() # string method: $name.toUpperCase()
count.default(0) # nullish fallback: $count ?? 0
count.default(0).clamp(0, 99) # chain with other methods
theme.one_of("light","dark") # constrain to valid values
sig.then(action) # conditional execute if truthy
# Value guards (chainable)
status.one_of("draft", "published") # validate enum
theme.one_of("light", "dark", default="light")
# Logical operators
name & email # → $name && $email
~is_visible # → !$is_visible
all(a, b, c) # → !!$a && !!$b && !!$c (preferred for 3+)
any(a, b) # → $a || $b
age >= 18 # → $age >= 18
📄 Runnable example: 01_basic_signals.py (fetch from demos URL above)
Conditional Helpers
| Helper | Behavior | Use for |
|---|---|---|
sig.if_("a", "b") | exclusive — one result | simple true/false choice |
match(sig, a="x", default="z") | exclusive — maps value to output | value-based mapping |
switch([(cond, "msg"), ...], default="") | exclusive — first match wins | validation chains |
collect([(cond, "cls"), ...]) | inclusive — ALL true combined | CSS class building |
# EXCLUSIVE — only one result ever returned
data_text=status.if_("Active", "Inactive")
data_attr_class=match(theme,
light="bg-white text-black",
dark="bg-gray-900 text-white",
default="bg-white")
msg = switch([
(~name, "Name is required"),
(name.length < 2, "Name too short"),
(~email.contains("@"), "Invalid email"),
], default="")
# INCLUSIVE — combines ALL true conditions (correct for CSS classes)
data_attr_class=collect([
(True, "btn"),
(is_primary, "btn-primary"),
(is_large, "btn-lg"),
(is_disabled, "opacity-50 cursor-not-allowed"),
])
# WRONG: using collect() for exclusive logic (use switch or if_ instead)
# WRONG: using switch() to combine CSS classes (use collect instead)
📄 See: 06_control_attributes.py, 25_advanced_toggle_patterns.py, 28_datastar_helpers_showcase.py
Forms and Binding
Form(
(name := Signal("name", "")),
(email := Signal("email", "")),
(valid := Signal("valid", all(name, email.contains("@")))),
Input(type="text", data_bind=name, placeholder="Name"),
Span(
data_text=switch([(~name, "Required"), (name.length < 2, "Too short")],
default=""),
data_show=~name, style="display:none"
),
Input(type="email", data_bind=email, placeholder="Email"),
Button("Submit",
data_attr_disabled=~valid,
type="submit"),
data_on_submit=(is_valid.then(post("/api/submit", name=name, email=email)),
{"prevent": True})
)
📄 See: 03_forms_binding.py
HTTP Actions and SSE
# HTTP actions — pass signals as params, never f-strings
data_on_click=get("/api/data")
data_on_click=post("/api/submit", name=name_sig, email=email_sig)
data_on_click=is_valid.then(post("/api/submit")) # conditional
data_on_click=delete("/api/item")
# Conditional execution with .then()
data_on_click=is_confirmed.then(delete("/api/item")) # only if confirmed
data_effect=is_form_complete.then(auto_save) # side effect
# WRONG — f-string URL is Python-static, signal value not reactive:
data_on_click=get(f"/api/{item_id}")
# RIGHT — pass signal as parameter:
data_on_click=get("/api/item", id=item_id_sig)
⚠️ POST Endpoint Data Parsing (Critical!)
Datastar sends JSON by default, NOT form data.
# ❌ WRONG — Datastar sends JSON, req.form() returns empty!
@rt("/todos", methods=["POST"])
def create(req):
form = req.form() # Returns empty dict!
text = form.get("text", "") # Always empty
mood = form.get("mood", "")
# ✅ RIGHT — Parse JSON first (Datastar default)
@rt("/todos", methods=["POST"])
def create(req):
import json
data = json.loads(req.body())
text = data.get("text", "")
mood = data.get("mood", "")
# ✅ RIGHT — Support both JSON and form data (defensive)
@rt("/todos", methods=["POST"])
def create(req):
import json
try:
data = json.loads(req.body())
text = data.get("text", "")
mood = data.get("mood", "")
except (json.JSONDecodeError, ValueError):
# Fallback for traditional form submissions
form = req.form()
text = form.get("text", "")
mood = form.get("mood", "")
Why this happens:
- Datastar uses
fetch()withContent-Type: application/json - Traditional HTML forms use
Content-Type: application/x-www-form-urlencoded - StarHTML/StarUI components send JSON by default
Best Practice: Always parse JSON first, optionally fallback to form data for compatibility.
# SSE endpoint — always yield signals() at end to reset client state
@rt("/send", methods=["POST"])
@sse
def send():
yield signals(is_sending=True)
yield elements(Div("msg", cls="msg"), "#chat", "append") # append mode
yield elements(Div("x", id="chat"), "#chat") # replace/morph mode
yield signals(is_sending=False, message="") # REQUIRED: reset state
# Hypermedia morph rule: returned element id MUST match the target selector
@rt("/partial")
def partial():
return Div("new content", id="target") # id="target" matches get("/partial") target
# SSE Best Practices:
# 1. Always yield signals() at end to reset client state
# 2. For replace-mode: preserve id attribute for future targeting
# 3. Use append/prepend for lists, replace for single elements
📄 See: 02_sse_elements.py, 04_live_updates.py, 05_background_tasks.py, 08_routing_patterns.py
Styling
SSR vs Reactive Attributes
| Use Case | SSR Needed? | Pattern |
|---|---|---|
| Toggle single class | No | data_class_active=signal |
| Tailwind special chars | No | data_attr_class=signal.if_("hover:bg-blue-500", "") |
| Show/hide elements | Yes | style="display: none" + data_show=signal |
| Base + toggle classes | Yes | cls="base" + data_class_* |
| Base + dynamic classes | Yes | cls="base" + data_attr_cls=reactive |
CSS Classes
# Simple class names (no special characters) → data_class_*
data_class_active=is_active # adds/removes class "active"
data_class_hidden=~is_visible # adds/removes class "hidden"
# Special characters (: / [ ]) in class names → data_attr_class
# WRONG — colon in keyword name is a Python parse error:
data_class_hover:bg-blue=sig
# RIGHT:
data_attr_class=is_active.if_("hover:bg-blue-500 focus:ring-2", "")
data_attr_class=is_loading.if_("bg-blue-500/50", "bg-blue-500")
data_attr_class=is_custom.if_("bg-[#1da1f2]", "bg-gray-500")
# data_attr_cls vs data_attr_class — DIFFERENT behaviors:
# data_attr_cls = ADDITIVE — merges with base cls= classes
# data_attr_class = REPLACES — sets the full class attribute
Button("OK",
cls="btn", # base classes
data_attr_cls=is_valid.if_("btn-success", "btn-error")) # additive
Button("OK",
data_attr_class=collect([(True, "btn"),
(is_primary, "btn-primary")])) # replaces
# Dictionary syntax for conditional classes
data_class={"active selected": role == "admin", "disabled": role == "guest"}
CSS Properties
# Static CSS (SSR)
style="background-color: red; font-size: 16px"
# Reactive CSS properties
data_style_width=progress + "px"
data_style_opacity=is_visible.if_("1", "0")
# CSS template with multiple signals
from starhtml.datastar import f
data_attr_style=f("color: {c}; opacity: {o}", c=theme_color, o=opacity)
Computed Signals and Effects
# Computed Signal — pass expression (not literal) as initial value
# auto-updates whenever dependencies change
(first := Signal("first", ""))
(last := Signal("last", ""))
(full_name := Signal("full_name", first + " " + last)) # string computed
(is_valid := Signal("is_valid", all(name, email))) # boolean computed
(total := Signal("total", price * quantity)) # math computed
# data_effect — side effects when signals change (assignments, not return values)
data_effect=total.set(price * quantity)
data_effect=[
total.set(price * quantity),
tax.set(total * 0.1),
final.set(total + tax),
]
# Performance: exclude internal-only signals from HTML output
(cache := Signal("cache", {}, _ref_only=True))
📄 See: 27_nested_property_chaining.py
Plugins
Each plugin requires import from starhtml.plugins and registration with the app.
Fetch the demo file for the exact integration pattern — demo files are complete, runnable examples.
Base URL for all demos: https://raw.githubusercontent.com/banditburai/starHTML/main/web/demos/
from starhtml.plugins import persist, scroll, resize, drag, canvas, position, motion, markdown, split
app, rt = star_app()
app.register(persist) # register each plugin you need
app.register(motion)
| Plugin | Demo file(s) | What it does |
|---|---|---|
| persist | 09_persist_plugin.py | Sync signals to localStorage/sessionStorage |
| scroll | 10_scroll_plugin.py | Track scroll position, page progress |
| resize | 11_resize_plugin.py | Window/element resize events |
| drag | 12_drag_plugin.py, 16_freeform_drag.py | Drag and drop, sortable lists |
| canvas | 17_canvas_plugin.py, 18_canvas_fullpage.py, 29_drawing_canvas.py | Canvas drawing |
| position | 20_position_plugin.py | Element positioning |
| motion | 23_motion_plugin.py, 24_motion_svg_plugin.py | CSS/SVG animations (enter, exit, hover, in_view) |
| markdown | 13_markdown_plugin.py | Render markdown content via data_markdown |
| katex | 14_katex_plugin.py | Math / LaTeX rendering |
| mermaid | 15_mermaid_plugin.py | Diagram rendering |
| split | 21_split_responsive.py, 22_split_universal.py | Resizable split panes |
| nodegraph | 19_nodegraph_demo.py | Node graph UI |
Also fetch: 07_todo_list.py (complete real-world app), 30_debugger_demo.py (debugger tool)
For full demo index with descriptions: see ./reference/demos.md
For Icon() component: see ./reference/icons.md
For js(), f(), regex(): see ./reference/js.md
For plugins API (persist, scroll, resize, drag, canvas, position, motion): see ./reference/handlers.md
For slot system: see ./reference/slots.md
Common Errors (and How to Fix Them)
| Error | Cause | Solution |
|---|---|---|
Signal has no len() or AttributeError: 'Signal' object has no attribute 'value' | Treating Signal as data container (Signals are reactive state, not data) | Use Python variables for data: todos_data = [] instead of Signal("todos", []) |
signals() takes 0 to 1 positional arguments | Passing positional args to signals() | Use kwargs: yield signals(count=1, status="done") not signals(count, status) |
Method not found on $signal (JS console) | Using plugin attributes without registering | Import and register: app.register(persist) |
NameError: name 'xyz' is not defined | Using Signal without walrus := parentheses | Wrap in parens: (xyz := Signal("xyz", 0)) |
SyntaxError: positional argument follows keyword argument | Wrong argument order | Content first, attributes after: Div("text", cls="class") |
Displays $signal_name literal | f-string with Signal — converts to string | Use arguments: Div(count, " items") or data_text=count |
| Button always shows wrong text | Signal in Python conditional — always truthy | Use reactive: data_text=is_saving.if_("Saving...", "Save") |
| Element flashes before hiding on load | Missing flash prevention | Add style="display:none" with data_show |
| Form submits and reloads page | Missing {"prevent": True} | Add: data_on_submit=(post("/api"), {"prevent": True}) |
| POST returns 400 Bad Request | Datastar sends JSON, not form data | Parse JSON: data = json.loads(req.body()) |
| SSE endpoint leaves UI in loading state | Missing yield signals() reset | Always end with: yield signals(loading=False) |
| Signal value not updating in backend handler | Trying to read signal.value | Receive Signal as parameter: def handler(req, my_sig: Signal) |
| Direct Datastar import | Importing @getdatastar/datastar manually | Remove it — StarHTML manages Datastar automatically |
Checker Tool
The checker is a standalone CLI (zero dependencies, stdlib only) that validates StarHTML components.
Install (one-time)
Check if installed:
starhtml-check --help || echo "Not installed"
Install if missing:
# macOS / Linux - global install (recommended)
curl -L https://raw.githubusercontent.com/renatocaliari/starhtml-skill/main/starhtml_check.py \
-o /usr/local/bin/starhtml-check && chmod +x /usr/local/bin/starhtml-check
# Or user-local (no sudo required)
curl -L https://raw.githubusercontent.com/renatocaliari/starhtml-skill/main/starhtml_check.py \
-o ~/.local/bin/starhtml-check && chmod +x ~/.local/bin/starhtml-check
Verify installation:
starhtml-check --version # Should show help
Updating
Once installed, update to the latest version anytime:
starhtml-check --update
This fetches the latest version from GitHub, creates a backup (.bak), and updates automatically.
Usage
# After generating StarHTML code, always run:
starhtml-check component.py # full analysis
starhtml-check --summary f.py # compact output (fewer tokens)
starhtml-check --update # check for updates and update
Development Loop: write → check → fix ERRORs → re-run → ✓ no issues
Output Levels
- ERRORS — must fix, will break runtime or reactivity
- WARNINGS — should fix, may cause subtle bugs or UX issues
- SUMMARY — signal inventory + total counts