Creating HTML Tags
Overview¶
Every element you write in ReactPy is a function call that returns a dictionary. html.div(...) is not a keyword, a macro, or a parser — it is an attribute lookup that manufactures a constructor on first use, and a __call__ that assembles the model. Knowing how that works explains why there is no allow-list to fight, why html.del_ means <del>, and where the handful of hard errors come from.
You will learn
- How
htmlturns an attribute name into a tag name - The rule that decides whether your first argument is attributes or a child
- Which elements reject children, and why
html.scriptis stricter still - How to build fragments, SVG, and tag names that are not valid Python identifiers
html manufactures tags on demand¶
html is an instance of HtmlConstructor with a single __getattr__ (_html.py). Ask it for anything and you get a Vdom constructor back, cached by name:
1 2 3 4 5 6 | |
1 | |
There is no allow-list. ReactPy does not know or care whether <definitely-not-a-real-tag> exists; the string travels to the browser and the browser decides. The names spelled out on HtmlConstructor exist only so your editor can autocomplete them — the comment in the source says as much. Two practical consequences:
- A misspelled tag renders as an unknown element with your text inside it, silently. Nothing in ReactPy will tell you that
html.hdeaderis wrong. - Custom elements work with no ceremony:
html.my_widget(...)produces{"tagName": "my-widget"}and the browser hands it to whatever registered that name.
The underscore rules¶
__getattr__ applies exactly two transformations — value.rstrip("_").replace("_", "-") — which is how you write names Python will not let you spell:
1 2 3 4 5 6 7 8 | |
1 | |
Read the rules literally, because they are applied blindly:
| You write | Tag name | Rule |
|---|---|---|
html.del_ | del | trailing _ stripped (a keyword) |
html.data_table | data-table | every _ becomes - |
html.my_widget | my-widget | same |
html.h1 | h1 | no underscore, nothing changes |
html._private | -private | leading underscore is not special |
html.a__b | a--b | doubles are not collapsed |
Casing is passed through untouched, so html.myWidget gives you myWidget. Because translation happens before the cache lookup, html.data_table is html.data_table holds — but two spellings that translate to the same tag are the same object, and two that do not are different elements.
If a name cannot be written as an attribute at all — a leading digit, or one you are building from a variable — construct it directly:
from reactpy import Vdom
tag = Vdom("2fa-code")
tag("123456") # {'tagName': '2fa-code', 'children': ['123456']}
Vdom(tag_name, /, allow_children=True, custom_constructor=None, import_source=None) is the same class every html.* name is built from, so you get identical behaviour. It is exported from the package root. Reach for it only for generated or unspellable names; anything else should go through html so it reads consistently.
html is not a namespace of plain functions
html.div is a Vdom object, not a function — html.div("x") works because Vdom.__call__ is the constructor. So html.div.__name__ is the tag name rather than a qualified name, and any name you ask for is fair game: html.allow_children("x") returns a perfectly valid {"tagName": "allow-children"} element rather than tripping over the Vdom attribute of the same name.
The two call shapes¶
Vdom.__call__ accepts either children only, or an attribute dictionary followed by children (core/vdom.py, Vdom.__call__):
1 2 3 4 5 6 7 8 9 10 | |
1 | |
The discriminator is exact-type, not isinstance: type(values[0]) is dict (core/vdom.py, separate_attributes_and_children). Two consequences that bite:
- A
VdomDict— which is adictsubclass — in first position is treated as a child, which is what you want when nesting models you built earlier. - Any other
dictsubclass in first position is also a child, so a custom mapping you meant as attributes becomes a rendered object instead. Pass a plaindictliteral for attributes.
A dict in any later position is always a child. There is one slot for attributes and it is the first.
Attribute values are forwarded verbatim¶
ReactPy renames nothing. className, class, htmlFor, and for all reach the DOM as whichever string you typed, and unknown names like data-* and aria-* pass straight through. Nothing validates names against a list, so a typo is a silent no-op — {"class_name": "card"} renders <div class_name="card">, an attribute nobody reads.
There are no exceptions to this rule on the live path. class_name is not a thing. See Common Props for the full picture, including the one case where numeric style values behave differently between executors.
Attributes must be JSON-serializable, but you may not be told
Everything in the attribute dict is JSON-encoded on its way to the browser. ReactPy checks that with json.dumps only when REACTPY_CHECK_JSON_ATTRS is on — and that option's parent is REACTPY_DEBUG, so turning debug on turns this on too:
from reactpy import config
config.REACTPY_DEBUG.current = True
With the check on, html.div({"data": object()}) raises TypeError: Object of type object is not JSON serializable at the tag call, pointing at your line. With it off, the model is accepted and the failure surfaces much later, inside the websocket send, as an orjson error in a server log. If a page renders nothing and the browser console is empty, this is why.
Fragments¶
html.fragment(...) and the shorter html(...) build a child list with no element of its own — tagName is the empty string, and the client renders it as nothing:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
1 | |
A fragment accepts key and nothing else: a className, a style, or an event handler all raise TypeError: Fragments cannot have attributes besides 'key'. There is no node to put them on, so ReactPy refuses rather than dropping them quietly.
Use a fragment when a component must return siblings and a wrapper <div> would break the layout or the CSS. When the children are already in a list, pass the list — you do not need a fragment for that, though you do need keys, covered in Rendering Lists.
Void elements¶
html.br("x") is a hard error, not a warning that scrolls past:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | |
1 | |
The rejecting set is NO_CHILDREN_ALLOWED_HTML_BODY (_html.py): area, base, br, col, command, embed, hr, img, input, iframe, keygen, link, meta, param, portal, source, track, wbr. The check happens after the model is built, so the message names the tag rather than your mistake — read the name in the error.
iframe is on that list, which surprises people who put fallback content inside one. ReactPy will not let you, and the browser-side renderer would not have shown it anyway.
html.script is its own thing¶
html.script uses a custom constructor (_html.py, _script) with three rules and a side effect:
- At most one child, and it must be a
str—ValueError: 'script' nodes may have, at most, one child.andThe child of a 'script' must be a string. - No event handlers at all —
ValueError: 'script' elements do not support event handlers. Attach behaviour to a sibling element instead, or use the inline-JavaScript handler form on a normal tag (Creating VDOM Event Handlers). - A
keyis injected from the script's content, or from itssrcif it has no content. Upstream documents the purpose as allowing the script "to be run multiple times if its key changes", browser permitting. Pass your ownkeyto control re-execution yourself.
Scripts are an injection surface
The child string reaches the browser as JavaScript. Never interpolate untrusted text into it — ReactPy's own docstring on _script says so, and there is no escaping step to save you. Build the string from your own constants, or pass a src you control.
SVG is a second constructor¶
html.svg is a separate SvgConstructor, so SVG element names live in their own namespace and cannot collide with HTML names. Children come off it:
1 2 3 4 5 6 7 8 9 10 | |
1 | |
It has its own void set (NO_CHILDREN_ALLOWED_SVG), and that set is broader and less intuitive than the HTML one — it includes text, tspan, title, desc, path, use, and foreignObject. So the element you most need children inside is exactly the one that rejects them:
html.svg.text("hello") # TypeError: 'text' nodes cannot have children.
Build those nodes with Vdom, which shares all the behaviour minus the void check:
from reactpy import Vdom
Vdom("text")("hello") # {'tagName': 'text', 'children': ['hello']}
The bundled client creates every element with document.createElementNS(namespace, ...) and picks the namespace from the tag name — static/index-bz53xswt.js sets Q = "http://www.w3.org/2000/svg" when the type is "svg" and the MathML URI for "math". Inside the SVG namespace attribute names are forwarded verbatim rather than lower-cased, so viewBox keeps its capital B — write it that way, since viewbox is a different attribute to the DOM.
Choosing children over attributes¶
html.div({"children": ["a", "b"]}) does not do what it looks like. The dict is attributes, so you get an attribute literally named children and no children at all. Children are always positional arguments after the attribute dict — that is the only way to supply them:
html.div({"id": "list"}, html.li("one"), html.li("two")) # correct
html.ul({"children": [html.li("one")]}) # an attribute named children
Children are flattened fully, not one level at a time, for any iterable that is not a string or a mapping — core/vdom.py, _flatten_children recurses, and verified html.div([[["a"], "b"]]) yields children: ['a', 'b']. A list or tuple comprehension therefore works at any nesting depth, and None is skipped by the layout. Two rules from there are worth internalizing now, because both failures are quiet:
- Elements inside a list need keys, in debug mode, or you get
Key not specified for child in list .... - A generator in place of a list skips that check entirely, with
Did not verify key-path integrity of children in generator .... Pass alist. The deeper hazard — storing an iterator in state — is covered in Rendering Lists.
Summary¶
htmlinvents a tag for any attribute name; the two rules are trailing_stripped and every_turned into-. There is no allow-list and no warning for a typo.- The first argument is attributes only if it is exactly a
dict; everything else, includingVdomDictanddictsubclasses, is a child. - Attribute names are forwarded verbatim — use the DOM's spelling. Turn on
REACTPY_DEBUGto catch un-serializable attribute values at the call site. html.fragmentandhtml()take only akey.- The void elements in
NO_CHILDREN_ALLOWED_HTML_BODYreject children with aTypeError;html.scriptadditionally allows one string child, forbids handlers, and infers its ownkey. html.svg.*uses a broader void set that includestext; build those withVdom.Vdom(tag_name)is the class behind everyhtml.*name — use it for tag names you cannot spell as an attribute, and for the SVG nodeshtml.svgrejects.
Related¶
- Common Props — attribute naming,
style, and the executor differences - Creating VDOM Event Handlers — the
eventHandlershalf of the model - VDOM Mutations — editing a model after you built it
- Conditional Rendering and Rendering Lists — the child rules with their failure modes
- Convert Between VDOM and HTML — the same models as text, and what that loses