starhtml

from starhtml import *

# Create reactive state
(stars := Signal('stars', 0))

# Build reactive UI
Button("Add", data_on_click=stars.add(1))
Span(data_text="Stars in the sky: " + stars)
Stars in the sky:

Explore more below

Core Philosophy

Four principles that make StarHTML powerful yet simple

Python First

Write Python and build anything. JavaScript available as an escape hatch when needed.

Python → HTMLServer-Side FirstDeclarative AttributesSprinkle Reactivity

Live Demo: Reactive Data Table

Search, select, and toggle status - all built in Python with full reactive power

Showing 6 of 6 employees

Selected employees

Name Role Department Status
Sarah Chen Senior Engineer Engineering
Marcus Johnson Product Manager Product
Elena Rodriguez UX Designer Design
David Kim DevOps Engineer Engineering
Rachel Thompson Marketing Director Marketing
Ahmed Hassan Frontend Developer Engineering

Code Comparison

Drag the divider to compare Python vs JavaScript implementations

Python (~145 lines)
from starhtml import *
import json

def get_sample_employees():
    return [
        {"id": 1, "name": "Sarah Chen", "role": "Senior Engineer", "department": "Engineering", "status": "active"},
        {"id": 2, "name": "Marcus Johnson", "role": "Product Manager", "department": "Product", "status": "active"},
        {"id": 3, "name": "Elena Rodriguez", "role": "UX Designer", "department": "Design", "status": "active"},
        {"id": 4, "name": "David Kim", "role": "DevOps Engineer", "department": "Engineering", "status": "on_leave"},
        {
            "id": 5,
            "name": "Rachel Thompson",
            "role": "Marketing Director",
            "department": "Marketing",
            "status": "active",
        },
        {
            "id": 6,
            "name": "Ahmed Hassan",
            "role": "Frontend Developer",
            "department": "Engineering",
            "status": "active",
        },
    ]

def _get_visible_ids_js(employees):
    search_map = {str(emp["id"]): f"{emp['name']} {emp['role']} {emp['department']}".lower() for emp in employees}
    return f"""
        const searchMap = {json.dumps(search_map)};
        const visibleIds = Object.keys(searchMap).filter(id =>
            !$search || searchMap[id].includes($search.toLowerCase())
        );
    """

def _search_input(search):
    return Input(placeholder="Search employees...", data_bind=search, cls="w-full p-3 border rounded-lg mb-4")

def _employee_stats(employees, selected):
    return Div(
        P(f"Showing {len(employees)} of {len(employees)} employees"),
        P("Selected ", Span(data_text=selected.length, cls="font-bold"), " employees"),
        cls="flex justify-between text-sm text-gray-600 mb-4",
    )

def _checkbox_sync_effect():
    # Browsers ignore checked attribute after user interaction - must update .checked property
    return Div(
        data_effect=js("""
            document.querySelectorAll('[id^=emp-]').forEach(cb =>
                cb.checked = $selected.includes(cb.value)
            );
        """),
        style="display: none",
    )

def _table_header(employees):
    return Thead(
        Tr(
            Th(
                Input(
                    type="checkbox",
                    id="select-all",
                    data_attr_checked=Signal("all_selected", _ref_only=True),
                    data_on_change=js(f"""
                        {_get_visible_ids_js(employees)}
                        $selected = evt.target.checked
                            ? [...$selected.filter(id => !visibleIds.includes(id)), ...visibleIds]
                            : $selected.filter(id => !visibleIds.includes(id));
                    """),
                    cls="cursor-pointer",
                ),
                cls="p-3 text-center",
            ),
            Th("Name", cls="p-3 text-left"),
            Th("Role", cls="p-3 text-left"),
            Th("Department", cls="p-3 text-left"),
            Th("Status", cls="p-3 text-left"),
        )
    )

def _action_buttons(selected):
    return Div(
        Button(
            Icon("material-symbols:download", width="16", height="16", cls="mr-1.5"),
            "Export ",
            Span(data_text=selected.length, cls="mx-1"),
            " selected",
            style="display: none",
            data_show=selected.length > 0,
            data_on_click=js("alert(`Exporting ${$selected.length} employees`)"),
            cls="inline-flex items-center bg-black text-white px-4 py-2 rounded-lg font-medium text-sm hover:bg-gray-800 transition-colors mr-2",
        ),
        Button(
            Icon("material-symbols:close", width="16", height="16", cls="mr-1.5"),
            "Clear selection",
            style="display: none",
            data_on_click=selected.set([]),
            data_show=selected.length > 0,
            cls="inline-flex items-center bg-gray-100 text-gray-700 px-4 py-2 rounded-lg font-medium text-sm hover:bg-gray-200 transition-colors border border-gray-200",
        ),
        cls="mt-4 flex gap-2",
    )

def minimal_reactive_table():
    employees = get_sample_employees()

    return Div(
        (search := Signal("search", "")),
        (selected := Signal("selected", [])),
        (employee_status := Signal("employee_status", {str(emp["id"]): emp["status"] for emp in employees})),
        Signal(
            "all_selected",
            js(f"""
            {_get_visible_ids_js(employees)}
            return visibleIds.length && visibleIds.every(id => $selected.includes(id));
        """),
        ),
        _search_input(search),
        _employee_stats(employees, selected),
        _checkbox_sync_effect(),
        Div(
            Table(
                _table_header(employees),
                Tbody(*[employee_row(emp, search, selected, employee_status) for emp in employees]),
                cls="w-full border-collapse bg-white rounded-lg overflow-hidden data-table",
            ),
            cls="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0",
        ),
        _action_buttons(selected),
    )

def employee_row(emp, search, selected, employee_status):
    emp_id = str(emp["id"])
    status_ref = js(f"$employee_status['{emp_id}']")

    return Tr(
        Td(
            Input(
                type="checkbox",
                value=emp_id,
                id=f"emp-{emp_id}",
                data_on_change=js(f"""
                    $selected = evt.target.checked
                        ? [...$selected, '{emp_id}']
                        : $selected.filter(id => id !== '{emp_id}');
                """),
                cls="cursor-pointer",
            ),
            cls="p-3 text-center",
        ),
        Td(emp["name"], cls="p-3 font-medium text-gray-900"),
        Td(emp["role"], cls="p-3 text-gray-600"),
        Td(emp["department"], cls="p-3 text-gray-600"),
        Td(
            Span(
                data_text=match(
                    status_ref, active="Active", on_leave="On Leave", inactive="Inactive", default="Unknown"
                ),
                data_on_click=employee_status[emp_id].toggle("active", "on_leave", "inactive"),
                data_attr_class=collect(
                    [
                        (value(True), "bold-status-badge"),
                        (status_ref == "active", "status-active"),
                        (status_ref == "on_leave", "status-on_leave"),
                        (status_ref == "inactive", "status-inactive"),
                    ]
                ),
                title="Click to cycle status",
            ),
            cls="p-3",
        ),
        data_show=(search == "")
        | value(emp["name"]).lower().contains(search.lower())
        | value(emp["role"]).lower().contains(search.lower())
        | value(emp["department"]).lower().contains(search.lower()),
        cls="transition-colors",
    )
JavaScript (~180 lines)
// ==================== VANILLA JAVASCRIPT DATA TABLE ====================
// State management
let state = {
    employees: [
        { id: '1', name: "Sarah Chen", role: "Senior Engineer", department: "Engineering", status: "active" },
        { id: '2', name: "Marcus Johnson", role: "Product Manager", department: "Product", status: "active" },
        { id: '3', name: "Elena Rodriguez", role: "UX Designer", department: "Design", status: "active" },
        { id: '4', name: "David Kim", role: "DevOps Engineer", department: "Engineering", status: "on_leave" },
        { id: '5', name: "Rachel Thompson", role: "Marketing Director", department: "Marketing", status: "active" },
        { id: '6', name: "Ahmed Hassan", role: "Frontend Developer", department: "Engineering", status: "active" }
    ],
    search: '',
    selected: [],
    employeeStatus: {
        '1': 'active', '2': 'active', '3': 'active',
        '4': 'on_leave', '5': 'active', '6': 'active'
    }
};

// DOM elements cache
const elements = {
    searchInput: null,
    selectAllCheckbox: null,
    tableBody: null,
    selectedCount: null,
    exportBtn: null,
    clearBtn: null
};

// Initialize
document.addEventListener('DOMContentLoaded', () => {
    elements.searchInput = document.getElementById('search-input');
    elements.selectAllCheckbox = document.getElementById('select-all');
    elements.tableBody = document.getElementById('employee-rows');
    elements.selectedCount = document.getElementById('selected-count');
    elements.exportBtn = document.getElementById('export-btn');
    elements.clearBtn = document.getElementById('clear-btn');

    attachEventListeners();
    render();
});

function attachEventListeners() {
    // Search
    elements.searchInput.addEventListener('input', (e) => {
        state.search = e.target.value;
        render();
    });

    // Select all
    elements.selectAllCheckbox.addEventListener('change', (e) => {
        const visibleIds = getVisibleIds();
        if (e.target.checked) {
            const hiddenSelected = state.selected.filter(id => !visibleIds.includes(id));
            state.selected = [...hiddenSelected, ...visibleIds];
        } else {
            state.selected = state.selected.filter(id => !visibleIds.includes(id));
        }
        render();
    });

    // Export
    elements.exportBtn.addEventListener('click', () => {
        alert(`Exporting ${state.selected.length} employees`);
    });

    // Clear
    elements.clearBtn.addEventListener('click', () => {
        state.selected = [];
        render();
    });
}

function getVisibleIds() {
    if (!state.search) return state.employees.map(e => e.id);
    const query = state.search.toLowerCase();
    return state.employees
        .filter(emp =>
            emp.name.toLowerCase().includes(query) ||
            emp.role.toLowerCase().includes(query) ||
            emp.department.toLowerCase().includes(query)
        )
        .map(e => e.id);
}

function render() {
    renderTable();
    renderCheckboxes();
    renderButtons();
    updateSelectedCount();
    updateSelectAllCheckbox();
}

function renderTable() {
    const query = state.search.toLowerCase();
    elements.tableBody.innerHTML = '';

    state.employees.forEach(emp => {
        const isVisible = !query ||
            emp.name.toLowerCase().includes(query) ||
            emp.role.toLowerCase().includes(query) ||
            emp.department.toLowerCase().includes(query);

        if (!isVisible) return;

        const row = document.createElement('tr');
        row.innerHTML = `
            <td><input type="checkbox" value="${emp.id}" id="cb-${emp.id}"></td>
            <td>${emp.name}</td>
            <td>${emp.role}</td>
            <td>${emp.department}</td>
            <td><span class="status-badge" id="status-${emp.id}"></span></td>
        `;

        const checkbox = row.querySelector('input');
        checkbox.addEventListener('change', (e) => {
            if (e.target.checked) {
                state.selected = [...state.selected, emp.id];
            } else {
                state.selected = state.selected.filter(id => id !== emp.id);
            }
            render();
        });

        const statusBadge = row.querySelector('.status-badge');
        statusBadge.addEventListener('click', () => {
            const current = state.employeeStatus[emp.id];
            state.employeeStatus[emp.id] =
                current === 'active' ? 'on_leave' :
                current === 'on_leave' ? 'inactive' : 'active';
            render();
        });

        elements.tableBody.appendChild(row);
    });
}

function renderCheckboxes() {
    state.employees.forEach(emp => {
        const cb = document.getElementById(`cb-${emp.id}`);
        if (cb) cb.checked = state.selected.includes(emp.id);
    });
}

function renderButtons() {
    const hasSelection = state.selected.length > 0;
    elements.exportBtn.style.display = hasSelection ? 'inline-flex' : 'none';
    elements.clearBtn.style.display = hasSelection ? 'inline-flex' : 'none';
}

function updateSelectedCount() {
    elements.selectedCount.textContent = state.selected.length;
}

function updateSelectAllCheckbox() {
    const visibleIds = getVisibleIds();
    elements.selectAllCheckbox.checked =
        visibleIds.length > 0 && visibleIds.every(id => state.selected.includes(id));
}

// Status badge rendering
function renderStatusBadges() {
    state.employees.forEach(emp => {
        const badge = document.getElementById(`status-${emp.id}`);
        if (!badge) return;

        const status = state.employeeStatus[emp.id];
        badge.textContent =
            status === 'active' ? 'Active' :
            status === 'on_leave' ? 'On Leave' : 'Inactive';
        badge.className = `status-badge status-${status}`;
    });
}

// Call status rendering in main render
const originalRender = render;
render = function() {
    originalRender();
    renderStatusBadges();
};

// Total: ~180 lines vs ~50 lines of Python

Key Transformations

Search Filtering
Python:
value(emp['name']).lower()
  .contains(search.lower())
JavaScript:
emp.name.toLowerCase()
  .includes(search.toLowerCase())
State Management
Python:
selected = Signal('selected', [])
JavaScript:
let selected = []
// + 50 lines of boilerplate
Reactivity
Python:
data_show=count > 0
JavaScript:
el.style.display =
  count > 0 ? 'block' : 'none'

Quick Reference

Copy-paste syntax cheat sheet for common patterns

from starhtml import *

# 1. Define reactive state (walrus := for inline definition)
(counter := Signal("counter", 0))           # Define + assign in one line
(name := Signal("name", ""))                # Available throughout component
(is_visible := Signal("is_visible", True))  # All Signal() objects auto-collected  

# 2. Basic reactivity
data_show=is_visible                        # Show/hide elements
data_text=name                              # Display signal value
data_bind=name                              # Two-way form/input binding
data_class_active=is_visible                # Conditional CSS class

# 3. Event handling  
data_on_click=counter.add(1)               # Increment counter
data_on_input=name.set("")                 # Clear input
data_on_submit=post("/api/save")           # HTTP request

# 4. Signal operations
counter.add(1)                             # → $counter++
counter.set(0)                             # → $counter = 0  
is_visible.toggle()                        # → $is_visible = !$is_visible
name.upper().contains("ADMIN")             # → $name.toUpperCase().includes("ADMIN")

# 5. Logical expressions
all(name, email, age)                      # All truthy → !!$name && !!$email && !!$age
any(error1, error2)                        # Any truthy → $error1 || $error2
name & email                               # Both truthy → $name && $email
~is_visible                                # Negation → !$is_visible

# 6. Conditional helpers  
status.if_("Active", "Inactive")           # Simple binary toggle (EXCLUSIVE)
match(theme, light="☀️", dark="🌙")        # Match signal value to outputs (EXCLUSIVE) 
switch([(~name, "Required"), (name.length < 2, "Too short")], default="Valid")  # First-match-wins (EXCLUSIVE)
collect([(is_active, "active"), (is_large, "lg")])  # Combine multiple classes (INCLUSIVE)
Common Gotcha: Use + for reactive strings ("Count: " + counter), not f-strings which are static!

See It In Action

Explore interactive examples.

Browse Live Demos