Creating Components¶
Components are the building blocks of your UI. Each component is a Python class paired with a Jinja2 template.
Basic Component¶
A component has two parts:
1. Python Class¶
from pyjinhx import BaseComponent
class Card(BaseComponent):
id: str # Required - unique identifier
title: str # Required field
subtitle: str = "" # Optional with default
Extra CSS/JS assets are auto-discovered from adjacent .css/.js files sharing the component's snake_case stem — the same stem as its template, so Card picks up card.css and card.js — see Asset Collection — not declared as fields on the class.
2. HTML Template¶
PyJinHX uses Jinja2 templates for its components:
<!-- card.pjx -->
<div id="{{ id }}" class="card">
<h2>{{ title }}</h2>
{% if subtitle %}
<p class="subtitle">{{ subtitle }}</p>
{% endif %}
</div>
You can use PascalCase components inside templates
You can even use PascalCase components as custom tags inside your component templates. This lets you compose components by nesting, like <Button .../> or <UserCard>...</UserCard>, directly within other templates. PyJinHx will automatically discover and render them as components.
The id Field¶
id is auto-generated (pjx-<n>) when omitted. Pass an explicit id for stable hooks (CSS selectors, htmx targets, reactive OOB targeting):
button = Button(id="submit", text="Submit") # explicit — stable hook
button = Button(text="Submit") # auto-generated pjx-<n>
Using your own id scheme
Override id with a default_factory to apply your own generation strategy — for example a UUID:
import uuid
from pyjinhx import BaseComponent
from pydantic import Field
class MyComponent(BaseComponent):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
# ... other fields ...
A subclass that redeclares id: str without a default makes it required at instantiation time.
Turning auto-ids off
Set the auto_id class var to False to make id mandatory for that component — the pjx-<n> fallback is disabled and omitting id fails validation, whether you instantiate it from Python or write <Tag/> in a template (see PascalCase Tags).
Reactive components need an explicit id
ReactiveComponent declares no id of its own, so it inherits the same pjx-<n> fallback — and that value is not stable across renders. A reactive region has to stay addressable for out-of-band swaps to find it, so always pass an explicit, stable id= (e.g. TodoCounter(id="todo-counter")).
Template Discovery¶
Templates are automatically discovered based on the class name, which is converted to
snake_case and given the .pjx extension. That is the only naming convention there is —
no .html/.jinja fallbacks, and no kebab-case:
| Class Name | Template File |
|---|---|
Button |
button.pjx |
ActionButton |
action_button.pjx |
UserCard |
user_card.pjx |
Template Location Requirement
Templates must be in the same directory as the Python class file.
A subclass with no adjacent template inherits the nearest ancestor's template and class
assets, each resolved independently (first found per kind walking the MRO). Mixing a
framework base with a component base works the same way — class LiveBadge(ReactiveComponent,
PJXBadge, react={...}) renders pjx_badge.pjx, the nearest template on the MRO.
Single-root rule¶
Every component template must render exactly one top-level HTML element. Rendering a
template with zero or two or more sibling top-level elements raises a ValueError:
<!-- WRONG: two siblings at the top level -->
<h2>{{ title }}</h2>
<p>{{ body }}</p>
<!-- RIGHT: wrap them in a single root -->
<div id="{{ id }}" class="card">
<h2>{{ title }}</h2>
<p>{{ body }}</p>
</div>
Conditional roots are fine — the check runs on the rendered output, so any branch that resolves to a single element passes:
Attribute pass-through¶
Every inline tag attribute becomes a constructor argument of the component class, so on a
declared component each attribute must be a declared field. There is no automatic
injection of stray attributes onto the root element: an attribute the class doesn't declare
is a ValidationError, not a pass-through.
class Card(BaseComponent):
id: str
title: str # declared field — fills the template context
subtitle: str = "" # declared field — fills the template context
<!-- ValidationError: hx-get is not a Card field -->
<Card id="orders" title="Orders" hx-get="/orders"/>
To let a component carry arbitrary hx-* / data-* attributes, declare a field for them and
emit it on the root yourself — this is what the built-in UI components do with their
extra_attrs dict:
from pydantic import Field
class Card(BaseComponent):
id: str
title: str
extra_attrs: dict[str, str] = Field(default_factory=dict)
<div id="{{ id }}"{% for name, value in extra_attrs.items() %} {{ name }}="{{ value }}"{% endfor %}>
<h2>{{ title }}</h2>
</div>
Template-only components (no Python class, or created with component()) are the loose case:
they accept undeclared attributes and expose them as template variables, so you decide where —
and whether — they land in the markup.
Escaping and slots¶
Template output is HTML-escaped by default. pyjinhx runs Jinja with
autoescape=True, so the special characters & < > " ' in a value are turned
into entities (& < > " ') before they reach the page. This is
the safe default: a scalar prop, text, an attribute value, or a value derived in
a {% for %} loop is escaped, so user-supplied data can't inject markup or break
out of an attribute.
PJXCardHeader(id="c", title="<script>alert(1)</script>")
# title renders as <script>alert(1)</script> — not executable
What renders as raw HTML (not escaped):
- A component's children/
contentfield (the_pjx_children_field, e.g.PJXCardBody.content) — its string value is emitted verbatim. - Any field declared
Slot(from pyjinhx import Slot). ASlotfield isstr | BaseComponent; its string value renders raw.Slotcollections work too — string elements inside aSlot-annotatedlist/dict(e.g.PJXDropdown.items) render raw. - Any
BaseComponentvalue — a nested component always renders its own HTML raw via the__html__protocol, whether passed directly or inside a list/dict.
# content is the children field → raw HTML
PJXCardBody(id="c", content="<p data-x='1'>hi</p>")
# renders <p data-x='1'>hi</p> verbatim
Escape hatches — when you trust the markup and want it raw in a scalar field, choose one:
- Declare the field as
Slot(field: Slot = ""). - Mark it safe in the template:
{{ value|safe }}. - Pass a
BaseComponentinstance — it renders raw via__html__.
Raw HTML is only as safe as its source. Reserve slots /
|safe/ nested components for markup you control; never pass unsanitized user input raw.
Prop-header props ({#def ... #}, below) follow the same rule: a header-declared
prop is escaped unless you mark it safe in the template ({{ prop|safe }}) or
the prop is the component's children field. Header props can't be typed Slot
directly, so use |safe for intentional raw HTML there.
HTML-only components¶
A component doesn't always need a Python class. If you have a template with no
behaviour or typed fields — just markup — you can reference it from Python with
the component() factory instead of hand-writing a BaseComponent subclass:
from pyjinhx import component
Card = component("Card") # finds card.pjx under the default environment
Card(title="Hi", content="body").render()
component(name, template_dir=None) returns a registered BaseComponent subclass
bound to the discovered template (card.pjx for "Card"). By default it resolves the
template the same way tag rendering does — under the directory setup(components_root=...)
last registered — but you can pass template_dir explicitly to point at a different
directory instead. The result is a first-class component: instantiate it, pass it as a field
of another component, or use <Card/> in a template — they all resolve to the same class.
It's idempotent and never shadows a component you've actually declared. See
component().
Prop headers for classless components¶
A template-only component can declare its props in a {#def ... #} header — the
first thing in the file. pyjinhx parses it into a validated pydantic model, so a
classless component gets defaults, required-checks, and type coercion without a
Python class:
{#def title: str, count: int = 0, variant: str = "primary" #}
<article class="pjx-card pjx-card--{{ variant }}">
<h3>{{ title }}</h3>
<span class="badge">{{ count }}</span>
<div>{{ content }}</div>
</article>
- The signature is Python-style:
name,name: type,name = default, orname: type = default. A prop with no default is required. - Supported types:
str,int,float,bool,list,dict, andT | None; anything else (or no annotation) is treated asAny(no coercion). - Declared props are validated (
<Card/>with a missing required prop, or a value that can't coerce, raises a clear error). Undeclared attributes are still accepted rather than rejected (hx-*,data-*,@click,class) and reach the template context — nothing places them in the markup for you. - The header is a normal Jinja comment, so it never appears in the output.
- Header-declared props are HTML-escaped like any scalar value (see
Escaping & slots). For intentional raw HTML, mark it safe
in the template with
{{ prop|safe }}— header props can't be typedSlot. - A hand-written Python class always takes precedence over a header.
The header is read when the class is built, so component() needs the template
to be resolvable at call time — pass template_dir, or run
setup(components_root=...) first. Discovery deliberately leaves an orphan
.pjx unregistered, so materialize it with component() once before using
<Tag/> for it in another template.
Extra Fields¶
BaseComponent is strict: like a plain Pydantic BaseModel, it rejects unknown keyword
arguments with a ValidationError. Every prop a component accepts from Python has to be a
declared field.
from pyjinhx import BaseComponent
class Example(BaseComponent):
foo: int
Example(foo=1, bar=2) # ValidationError: Extra inputs are not permitted
Classless components are the exception. A component built from a template — by
component() or by a {#def #} header — is generated with extra="allow", so it
accepts undeclared keys and makes them available in the template context. That is what
lets hx-*, data-* and other stray attributes pass through to the root element (see
Attribute pass-through).
A hand-written class can opt into the same thing with pydantic's own config — there is no pyjinhx-specific base to import: