Ludic Components
Ludic is a Python framework for building HTML pages with a typed, React-like component model that pairs with htmx. v1.x targets Python 3.14+ and uses PEP 750 t-strings (template strings) for templating.
This skill helps you write idiomatic Ludic components: well-composed, type-safe under mypy (with the Ludic mypy plugin), safe by default, and legible. For wiring components into a web app (endpoints, request handling, URL generation, framework integrations), use the companion ludic-web skill.
Mental model
Three layers, top to bottom:
- HTML elements in
ludic.html(div,a,html,head,body,table,form, ...). Each element has a typed signature describing its allowed children and attributes (e.g.br()accepts no children;html(...)requiresheadas the first child,bodyas the second). - Components in
ludic.components—Component[TChildren, TAttrs]and the variadicComponentStrict[*TChildrenArgs, TAttrs]. You subclass these and implementrender(), which returns an element tree. Components are the unit of reuse and can carry CSS via class-levelclassesandstylesdeclarations and read the currentThemeviaself.theme. - Catalog in
ludic.catalog— opinionated widgets built on top of the core: layouts (PageLayout,Stack,Cluster,Sidebar), forms (Form,FormField), tables (Table,TableHead,TableRow), navigation, typography, buttons, messages, etc. Reach for these before composing layouts out of rawdivs — they encode sensible defaults.
The rendering pipeline runs once when you call .to_html(): it walks children, expands any t-string Template instances, escapes untrusted text, formats attributes (aliases like class_ → class, dict-style style={...}, list-style classes=[...]), and emits a single HTML string.
The two things that surprise people
1. Use t-strings, not f-strings
In v1.x, code that mixes HTML elements with interpolated text uses t-strings (PEP 750), not f-strings:
# correct — v1.x
div(t"Hello {b('World')}!")
# wrong — f-strings eagerly stringify b(...), losing escaping and child-tracking
div(f"Hello {b('World')}!")
The t"..." literal produces a Template object that Ludic's element constructor detects and expands. If you see f"... {some_element} ..." mixing HTML elements with text, change it to t"...".
2. The trusted/untrusted content boundary
Everything is HTML-escaped by default. To opt out — for content you've already verified is safe HTML — wrap it in Safe:
from ludic.types import Safe
div("Hello <b>World</b>").to_html()
# → '<div>Hello <b>World</b></div>' (escaped — safe default)
div(Safe("Hello <b>World</b>")).to_html()
# → '<div>Hello <b>World</b></div>' (raw — you take responsibility)
Only use Safe for content you control or have sanitized. Never wrap user input in Safe — that's the XSS hole this whole system exists to prevent. There's also JavaScript(Safe) for inline JS bodies.
Writing a component
Two component classes:
Component[TChildren, TAttrs]— accepts a variable number of children all of one type, plus a singleTAttrsTypedDict for attributes.ComponentStrict[*TChildrenArgs, TAttrs]— uses aTypeVarTupleto enforce an exact positional sequence of child types (e.g. "exactly oneTableHeadfollowed by zero-or-moreTableRow").
Use ComponentStrict when the shape of children matters structurally; use Component when children are a homogeneous list.
from typing import override
from ludic import Attrs, Component
from ludic.html import a
class LinkAttrs(Attrs):
to: str
class Link(Component[str, LinkAttrs]):
classes = ["link"] # CSS classes added to the rendered root
styles = {"a.link": {"color": "blue"}} # collected by the style system
@override
def render(self) -> a:
return a(
*self.children,
href=self.attrs["to"],
style={"color": self.theme.colors.primary},
)
A few rules the Ludic mypy plugin enforces — make sure your app's pyproject.toml (or mypy.ini) has plugins = ["ludic.mypy_plugin"], otherwise you'll get false positives on every component:
- The generic parameters on
Component[...]/ComponentStrict[...]synthesize__init__, so call sites get checked againstTChildrenandTAttrs. Attrs(and the per-element TypedDicts inludic.attrs) describe allowed attributes. Unknown attributes are a type error.- For HTML attribute names that collide with Python keywords or contain dashes, use the Python-side alias:
class_→class,for_→for,hx_get→hx-get, etc. Thestyle=attribute accepts a dict;classes=accepts a list.
htmx attributes on elements
htmx attributes are exposed as hx_* keyword arguments on every element with GlobalAttrs:
button(
"Load more",
hx_get="/items?page=2",
hx_target="#items",
hx_swap="beforeend",
)
When you serve the rendered HTML from an endpoint, htmx swaps it into the page. The endpoint side is covered in the ludic-web skill — including how to generate the URLs you pass to hx_get / hx_post safely.
Catalog: prefer it to raw HTML
Before composing a layout out of divs, check ludic.catalog:
catalog.layouts—PageLayout,Stack,Cluster,Sidebar,Box,Center,Cover,Grid,Switcher,Frame,Reel,Imposter(adapted from Every Layout).catalog.forms—Form,FormField,InputField,TextAreaField,SelectField,CheckboxField.catalog.tables—Table,TableHead,TableRow.catalog.navigation,catalog.buttons,catalog.typography,catalog.messages,catalog.loaders,catalog.headers,catalog.items,catalog.lists,catalog.quotes,catalog.icons,catalog.pages.
These are also the best place to learn idiomatic Ludic — they exercise the full surface area of the framework.
Theming and styles
Component subclasses can declare:
classes: list[str]— appended to the root element's class list at render time.styles: GlobalStyles— collected at startup so you can emit a single<style>block for the page.
Read the active theme via self.theme (returns the default theme if none is in scope). Themes flow down the tree automatically, so children see the same theme as the parent rendering them.
A complete worked example
from typing import override
from ludic import Attrs, Component
from ludic.catalog.layouts import Stack
from ludic.catalog.typography import Paragraph
from ludic.html import b, h2
class GreetingAttrs(Attrs):
name: str
class Greeting(Component[str, GreetingAttrs]):
classes = ["greeting"]
@override
def render(self) -> Stack:
return Stack(
h2(t"Hello, {b(self.attrs['name'])}!"),
Paragraph(*self.children),
)
# Usage
Greeting("Welcome to Ludic.", name="Ada").to_html()
What to notice:
Component[str, GreetingAttrs]says: children are strings, attrs matchGreetingAttrs.- The t-string in
h2(t"Hello, {b(...)}!")keepsb(...)as a real element — it isn't stringified. self.attrs['name']flows intob(...)and is HTML-escaped on output (because it isn't wrapped inSafe).Stackfrom the catalog handles vertical spacing — no need to roll adivwith margin classes.
When in doubt
- The Ludic docs cover the public API end-to-end.
- The catalog source on GitHub is the fastest read for idiomatic component patterns.
- The examples folder shows real components used in htmx patterns (click-to-edit, infinite scroll, lazy loading, file upload, ...).
Source: getludic/ludic — distributed by TomeVault.