API, node shape, layout and style — create schema from definition, render to DOM.
- Quick Start
- Flow Overview
- API Overview
- Refs and Lifecycle
- Helper (el)
- Node Shape
- Layout and Style
- Void Tags and Special Elements
- Security
- Boolean Attributes
- Form Elements
- Image
- Responsive Images (picture, srcset, sizes)
- Iframe
- Links (Anchor)
- SVG Attributes
- Accessibility
- API Reference
- Validation Errors
- Type Reference
- Reference
Flow: definition ({ root: Node[] }) → create(definition) → schema → render(schema, container). Each call to render appends to the container; Schema2UI never clears it.
import { create, render } from '@neabyte/schema2ui'
// Step 1: Define definition object with root array of nodes
const schema = create({
root: [
// node 1: div with id, layout (width/height in px), text content
{ type: 'div', id: 'box', layout: { width: 200, height: 100 }, content: 'Hello' },
// node 2: anchor with href in attrs, text content
{ type: 'a', attrs: { href: '/about' }, content: 'About' }
]
})
// Step 2: Render schema into a DOM container (creates elements and appends to it)
render(schema, document.getElementById('app'))- Definition — Plain object
{ root: Node[] }. Each node has at leasttype(HTML tag name); optional:id,layout,style,attrs,content,src/alt(img),children. - Create —
create(definition)validates the definition (root array, node shape, allowed keys, void tags must not have children). Returns a frozen, normalizedSchema. Throws on invalid input. - Render —
render(schema, container)walksschema.root, creates DOM elements (or SVG viacreateElementNSwhere needed), appliesattrs,layout,style, setscontent/src/alt, appends children. Results are appended tocontainer(HTMLElement). The container is not cleared before appending.
Example: definition → create → render
// Step 1: Definition — plain object with root array of nodes
const def = { root: [{ type: 'div', content: 'Hi' }] }
// Step 2: Create — validate and freeze; returns schema
const schema = create(def)
// Step 3: Render — build DOM and append to container
render(schema, document.getElementById('app'))| Function / export | Returns | Description |
|---|---|---|
create(definition) |
Schema |
Validate and freeze definition; return schema. |
render(schema, container, options?) |
void |
Build DOM from schema and append to container; optional refs and onNodeMount. |
el |
namespace | Helper: el.root, el.node, el.div, etc. |
Named exports: create, render, el, and Types (re-exported). You can also use the default export as a callable or via static members:
import Schema2UI from '@neabyte/schema2ui'
// Step 1: Callable — call default export to get { create, render, el }
const api = Schema2UI()
api.create({ root: [] })
api.render(schema, container)
// Step 2: Static — use Schema2UI.create / .render / .el without calling first
Schema2UI.create({ root: [{ type: 'div', content: 'Hi' }] })
Schema2UI.render(schema, container, { refs })
Schema2UI.el.root(el.div('Hello'))Use the el namespace to build definitions with less boilerplate. Same flow: create(el.root(...)) → schema → render(schema, container).
// Step 1: Import create, render, and the el helper
import { create, render, el } from '@neabyte/schema2ui'
// Step 2: Build definition with el.root(...); each el.* is a tag alias (el.header, el.main, el.h1, el.a)
const schema = create(
el.root(
// header node: props object + child (h1 with content 'Hello')
el.header(
{ id: 'header', layout: { width: '100%', height: 56 }, style: { fill: '#1a1a2e' } },
el.h1({ id: 'title', style: { font: '20px sans-serif', fill: '#eee' } }, 'Hello')
),
// main node: props + child link with href and text 'Home'
el.main({ layout: { width: '100%', flex: 1 } }, el.a({ attrs: { href: '/' } }, 'Home'))
)
)
// Step 3: create() validates and freezes; then render into container
render(schema, document.getElementById('app'))To get element references after render (e.g. for showModal(), focus(), or addEventListener), pass options as the third argument to render:
refs— AMap<string, Element>you provide; Schema2UI fills it withnode.id → elementfor every node that has anid.onNodeMount— A callback(node, element) => voidinvoked once per node after that node’s element has been created and appended to its parent. Use it to attach event listeners or run imperative code.signal— OptionalAbortSignal; use it when attaching listeners inonNodeMountso thatcontroller.abort()removes them (e.g. on teardown).
Example: refs and dialog
Definition as JSON (e.g. from JSON.stringify(definition, null, 2)):
{
"root": [
{
"type": "dialog",
"id": "my-dialog",
"children": [{ "type": "p", "content": "Hello" }]
}
]
}// Step 1: Create schema with a dialog that has an id (so we can look it up via refs)
const schema = create({
root: [{ type: 'dialog', id: 'my-dialog', children: [{ type: 'p', content: 'Hello' }] }]
})
// Step 2: Pass a Map as options.refs; render will fill it with id → element
const refs = new Map<string, Element>()
render(schema, document.getElementById('app'), { refs })
// Step 3: After render, get the dialog by id and call showModal()
const dialog = refs.get('my-dialog') as HTMLDialogElement
dialog?.showModal()Example: onNodeMount and AbortController (event handling and cleanup)
// Step 1: Create AbortController so we can clean up listeners later
const ac = new AbortController()
// Step 2: Pass onNodeMount in options; it runs once per node after that node is mounted
render(schema, container, {
onNodeMount(node, element) {
// Step 3: For the button node, attach click listener with { signal } so abort() removes it
if (node.id === 'btn') {
element.addEventListener('click', handleClick, { signal: ac.signal })
}
}
})
// Step 4: When tearing down (e.g. unmount), call ac.abort() — listener is removed automaticallyPass options.signal when attaching listeners in onNodeMount so that controller.abort() cleans them up without storing references manually.
- Focus — Get the element via refs or in
onNodeMount, then callelement.focus()(e.g.refs.get('input-id')?.focus()for an input withid: 'input-id'). - Template — For
type: 'template', refs store the template element itself, nottemplate.content.
Example: focus after render
// Step 1: Pass refs map so render fills it with id → element
const refs = new Map<string, Element>()
render(schema, container, { refs })
// Step 2: After render, get input by id and focus it
;(refs.get('first-input') as HTMLInputElement)?.focus()el.root()— returnsDefinitionwith emptyroot: [].el.root(n1, n2, ...)orel.root([n1, n2])— returnsDefinitionforcreate().el.node(type, propsOrContent?, ...rest)— generic factory (e.g. custom elements). Second arg: optional props object, text content, or first child;rest: more children or content. Content (string) and children cannot be mixed. Void tags must not have content or children.- Tag aliases —
el.div,el.span,el.main,el.header,el.h1,el.a,el.table,el.img,el.template,el.details,el.dialog,el.figure,el.figcaption,el.summary,el.search,el.fieldset,el.legend,el.select,el.option,el.optgroup,el.textarea,el.blockquote,el.pre,el.dl,el.dt,el.dd,el.datalist,el.iframe,el.picture,el.slot,el.address,el.caption,el.colgroup,el.tfoot,el.output,el.progress,el.menu,el.meter,el.audio,el.video,el.hgroup,el.abbr,el.cite,el.code,el.strong,el.em,el.mark,el.time,el.q,el.del,el.ins, and the rest fromConstant.tagAliases(container + void). Container tags accept props, content (string), or children (nodes). Void tags (e.g.img,br,input) accept props only.
Each node is an object with:
| Key | Type | Required | Description |
|---|---|---|---|
type |
string |
yes | HTML or SVG tag name, or custom element (e.g. div, a, svg, circle). Lowercased on create. Unknown names yield HTMLUnknownElement. |
id |
string |
no | Element id attribute. |
layout |
Layout |
no | Width, height, position, flex, gap → CSS. |
style |
Style |
no | Fill, stroke, font, border → CSS. |
attrs |
Attrs |
no | HTML attributes (href, class, etc.). Keys starting with on are not applied; use addEventListener after render. |
content |
string |
no | Text content for the node. |
src |
string |
no | Image (or similar) source URL; use with type: 'img'. |
alt |
string |
no | Alt text for img. |
children |
Node[] |
no | Child nodes. Not allowed on void tags. |
Allowed keys are exactly the above. Extra keys cause validation to throw. You can use any tag name (e.g. custom elements): unknown names become HTMLUnknownElement unless the browser defines a custom element for that name.
Example: custom element with el.node
// Step 1: With el helper — any tag name (custom elements become HTMLUnknownElement)
el.node('my-component', { attrs: { 'data-foo': 'bar' } })
// Step 2: Or raw definition — same shape
{ type: 'my-component', attrs: { 'data-foo': 'bar' } }- Layout — Sizing:
width,height,minWidth,maxWidth,minHeight,maxHeight. Flex:display,flexDirection,flexWrap,alignItems,justifyContent,gap,flex. Position:x,y(setleft/topandposition: relativewhen needed). Overflow:overflow,overflowX,overflowY. Number values are converted toNpx; strings (e.g.'100%','flex','center') are used as-is. Settinggapsetsdisplay: flexwhen not already set; settingflexsetsdisplay: blockwhen needed. - Style — Only the following properties are applied from
node.style; each value must be a string. Semantic:fill→backgroundColor,stroke→border. Direct:font,border,color,padding,margin,borderRadius,boxShadow,opacity,transition,scrollMargin,textAlign,textDecoration,letterSpacing,lineHeight,cursor,visibility,background,outline,zIndex. The library does not apply arbitrary CSS keys fromnode.style. For other properties (e.g.width,maxWidth,display,objectFitwhen not using layout), use attrs.style as a string. - Inline CSS via attrs — Use
attrs.styleas a string (e.g.'color: red; margin: 8px; width: 100%') to set any CSS; this is applied viaelement.style.setPropertyand supports all properties. - Scope — Layout and style are applied only to HTML elements. For SVG nodes, use
attrs(e.g.attrs.styleas CSS string, orattrs.fill,attrs.stroke).
Example: layout + style + attrs.style
// layout: sizing, flex, overflow (numbers → px where applicable)
{ type: 'div', layout: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', minWidth: 200, overflow: 'auto' } }
// layout: width, height, position
{ type: 'div', layout: { width: 200, height: 100, x: 10, y: 0 }, style: { fill: '#eee', borderRadius: '8px' } }
// style: typography and cursor
{ type: 'h2', style: { textAlign: 'center', letterSpacing: '0.5px', cursor: 'pointer' }, content: 'Title' }
// inline CSS via attrs.style (string)
{ type: 'span', attrs: { style: 'color: red; font-weight: bold' }, content: 'Alert' }- Void tags — No
children. List includes:area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr. If a void tag has non-emptychildren,create()throws. - template — Child nodes are appended to
element.content(DocumentFragment), not to the template element itself. Refs store the template element, not the content. - svg — SVG elements are created with
createElementNS; descendants stay in SVG namespace when under ansvgnode.
Example: void tags and template
// Step 1: Void tag — no children (br)
{ type: 'br' }
// Step 2: Void tag — img with src/alt
{ type: 'img', src: '/logo.png', alt: 'Logo' }
// Step 3: template — children go into template.content; refs store the template element
{ type: 'template', id: 'tpl', children: [{ type: 'div', content: 'Cloned later' }] }- Text content —
contentis applied viacreateTextNode(), so it is safe from HTML injection. Do not interpretcontentas HTML. - Attributes —
attrsvalues are set withsetAttribute()or property assignment. Use only attribute names and values from trusted sources, or sanitize them. Do not pass unsanitized user input intoattrs(e.g.href,src) without validation or sanitization. For strict policies, consider Trusted Types where supported. - Event handlers — Attribute keys that start with
on(e.g.onclick,onerror) are skipped during render and are never set on the element. UseaddEventListener()on the rendered element instead.
Example: safe content vs attrs
// Step 1: content → text node; safe from HTML injection
{ type: 'p', content: 'User text here' }
// Step 2: attrs (e.g. href) — sanitize user input before passing
{ type: 'a', attrs: { href: userUrl } }These attributes are treated as boolean (MDN: presence = true, absence = false): autofocus, checked, disabled, hidden, inert, multiple, open, readonly, required, selected. Use true or false (or string 'true'/'false'). When the value is false, the attribute is removed from the DOM and the corresponding property is set to false; when true, only the property is set (no attribute string is written). This avoids invalid markup such as disabled="false" (which would still disable the element).
// Step 1: Use true/false; when false, attribute is removed from DOM (avoids disabled="false")
{ type: 'input', attrs: { checked: false } } // unchecked
{ type: 'button', attrs: { disabled: true } } // disabled — not clickable
// Step 2: hidden false → attribute removed (visible); open true → details expanded
{ type: 'div', attrs: { hidden: false } }
{ type: 'details', attrs: { open: true } }- autofocus — On
input,button,select, andtextareatheautofocusproperty is set; on other elements the attribute is set/removed. For accessibility, use sparingly: at most one autofocus control per view, and avoid moving focus away from user context (e.g. modals). - open — On
detailsanddialogtheopenproperty is set (disclosure expanded or dialog visible); when false the attribute is removed. - hidden — For the value
'until-found', the attribute is set as-is (see MDN); otherwise treated as boolean (presence = hidden, removed when false).
- disabled — Applied via property on
input,button,select,textarea,fieldset,optgroup, andoption. Boolean handling as above. - readonly — Applied via
readOnlyproperty oninputandtextarea. - required — Applied via
requiredproperty oninput,select, andtextarea. - multiple — Applied via
multipleproperty oninput(e.g. file, email) andselect. - selected — Applied via
selectedproperty onoption. - checked — Applied via
checkedproperty oninput(e.g. checkbox/radio). - Form UX — Use
attrs.foron labels (applied ashtmlFor), andattrs.form,attrs.inputmode,attrs.enterkeyhinton inputs; all are applied via attribute or property. For imperative APIs (e.g. focus, form submit), get the element via refs or onNodeMount (see Refs and Lifecycle).
Example: label + input with for, inputmode, enterkeyhint
// Step 1: label with attrs.for (→ htmlFor) and content
// Step 2: input with id matching for; inputmode and enterkeyhint for UX
;[
{ type: 'label', attrs: { for: 'email' }, content: 'Email' },
{ type: 'input', id: 'email', attrs: { type: 'text', inputmode: 'email', enterkeyhint: 'done' } }
]For type: 'img', node.src and node.alt are set on the element; attrs.loading ('lazy' or 'eager') and attrs.decoding ('sync', 'async', or 'auto') are applied as properties for lazy loading and decode behavior.
Example
// Step 1: img with src/alt on node; loading and decoding in attrs
{ type: 'img', src: '/hero.jpg', alt: 'Hero', attrs: { loading: 'lazy', decoding: 'async' } }Use a tree with type: 'picture', one or more type: 'source' (void), and one type: 'img'. Set attrs.srcset, attrs.sizes on img and on each source; on source you can also set attrs.media and attrs.type. All are applied via setAttribute. Example:
{
type: 'picture',
children: [
// Step 1: source for narrow viewport — media query, srcset, sizes, type
{ type: 'source', attrs: { srcset: 'small.webp 480w', sizes: '100vw', media: '(max-width: 600px)', type: 'image/webp' } },
// Step 2: source for larger viewport — no media, browser picks when appropriate
{ type: 'source', attrs: { srcset: 'large.webp 1200w', type: 'image/webp' } },
// Step 3: fallback img — src/alt on node; srcset/sizes in attrs for responsive fallback
{ type: 'img', src: 'fallback.jpg', alt: '…', attrs: { srcset: 'fallback.jpg 1x', sizes: '(min-width: 800px) 800px, 100vw' } }
]
}For type: 'iframe', attrs.loading ('lazy' or 'eager') is applied as the loading property. Use attrs.sandbox and attrs.srcdoc for sandboxed inline content; avoid unsanitized user input in srcdoc (XSS risk).
Example
// Step 1: iframe with src; loading and sandbox via attrs
{ type: 'iframe', attrs: { src: '/embed', loading: 'lazy', sandbox: 'allow-scripts' } }For type: 'a', attrs.download is applied as the anchor’s download property: use true for “download with default filename” (empty string) or a string for the suggested filename (e.g. 'report.pdf'). When download is false or omitted, the attribute is removed so the link behaves as a normal navigation link.
Example: normal link and download link
// Normal navigation (no download)
{ type: 'a', attrs: { href: '/about' }, content: 'About' }
// Download with suggested filename
{ type: 'a', attrs: { href: '/file.pdf', download: 'report.pdf' }, content: 'Download PDF' }
// Download with default filename (browser decides)
{ type: 'a', attrs: { href: '/data.json', download: true }, content: 'Download' }Layout and style from the schema are applied only to HTML elements. For SVG nodes (e.g. circle, rect, path under svg), set dimensions and appearance via attrs (e.g. attrs.fill, attrs.stroke, attrs.width, attrs.height, or attrs.viewBox on svg). Use attrs.style for a CSS string if needed.
Example
// Step 1: svg with attrs.viewBox; SVG children use attrs for dimensions and fill/stroke
{ type: 'svg', attrs: { viewBox: '0 0 100 100' }, children: [{ type: 'circle', attrs: { cx: '50', cy: '50', r: '40', fill: 'blue' } }] }- Prefer semantic HTML (
type: 'header',main,nav,button, etc.) so built-in roles and behavior are available. - ARIA — Pass ARIA attributes through
attrs(e.g.attrs['aria-label'],attrs['aria-live'],attrs['aria-hidden']). For dynamic content that updates without a full re-render, consideraria-liveregions so assistive technologies can announce changes. - inert and hidden — Supported as boolean attributes (see Boolean Attributes) for visibility and focus behavior.
- popover — Use
attrs.popoverwith values such as'auto','manual', or'hint'(HTML Popover API). Open/close behavior is controlled by the browser or by your script after render. - dialog — For
type: 'dialog', the element is only created and appended. Use refs or onNodeMount to get the element, then callelement.showModal()orelement.close()(see Refs and Lifecycle).
Example: ARIA and focus
// Step 1: button with id and aria-label via attrs
{ type: 'button', id: 'submit', attrs: { 'aria-label': 'Submit form' }, content: 'Submit' }
// Step 2: After render — get element via refs and focus when needed
refs.get('submit')?.focus()Validate the definition and return a frozen schema. Normalizes node keys (e.g. lowercases type). Deep-freezes the schema and all nested nodes.
definition<Definition | unknown>: Object withroot: Node[]. Can be a plain object; validation ensures shape.- Returns:
<Schema>Frozen object{ root: readonly Node[] }. - Throws: When
definitionis not an object,rootis not an array, or any node is invalid (unknown key, wrong type for a field, void tag with children).
Example: create and use schema
import { create, render } from '@neabyte/schema2ui'
const definition = {
root: [
{ type: 'h1', content: 'Title' },
{ type: 'p', id: 'intro', content: 'Intro text' }
]
}
const schema = create(definition)
// schema is frozen; schema.root has 2 nodes
render(schema, document.getElementById('app'))create() throws in these cases:
- Definition is not a plain object.
rootis missing or not an array.- A node has an unknown key (not in: type, id, layout, style, attrs, content, src, alt, children).
- A node has wrong type for a field (e.g.
contentnot a string,layoutnot an object of number/string). - A void tag has
children(or non-empty children array).
Example: invalid definition throws
import { create } from '@neabyte/schema2ui'
create({ root: [{ type: 'div', unknownKey: 'x' }] })
// throws Error: root[0]: unknown key "unknownKey"
create({ root: [{ type: 'img', children: [{ type: 'span', content: 'x' }] }] })
// throws Error: root[0]: void tag "img" must not have childrenBuild DOM from schema.root and append each resulting node into container. Uses the container’s ownerDocument (or document). Handles template (children into template.content) and SVG (namespace). Does not clear container before appending.
schema<Schema>: Frozen schema fromcreate().container<HTMLElement>: Target element to append to.options<RenderOptions | undefined>: Optional.refs: map to fill with id → element;onNodeMount: callback per node after mount;signal: optional AbortSignal for listener cleanup.- Returns:
void.
Example: render with refs and onNodeMount
import { create, render } from '@neabyte/schema2ui'
const schema = create({
root: [
{ type: 'button', id: 'btn', content: 'Click me' },
{ type: 'dialog', id: 'my-dialog', children: [{ type: 'p', content: 'Dialog body' }] }
]
})
const refs = new Map()
const ac = new AbortController()
render(schema, document.getElementById('app'), {
refs,
signal: ac.signal,
onNodeMount(node, element) {
if (node.id === 'btn') {
element.addEventListener('click', () => refs.get('my-dialog')?.showModal(), {
signal: ac.signal
})
}
}
})
// Later: ac.abort() removes the listenerTypes are re-exported under the Types namespace. Import and use like this:
import type * as Types from '@neabyte/schema2ui'
// Then: Types.Node, Types.Schema, Types.RenderOptions, Types.Attrs, Types.Layout, Types.Style, etc.- Definition:
{ root: readonly Node[] }— Input tocreate. - Schema:
{ readonly root: readonly Node[] }— Output ofcreate; frozen. - RenderOptions: Optional
refs?: Map<string, Element>,onNodeMount?: (node, element) => void,signal?: AbortSignal. Pass as third argument torender. - Node: Object with
type(string) and optionalid,layout,style,attrs,content,src,alt,children(readonly array of Node). Void tags must not havechildren. - Layout: Optional
alignItems,display,flex,flexDirection,flexWrap,gap,height,justifyContent,maxHeight,maxWidth,minHeight,minWidth,overflow,overflowX,overflowY,width,x,y— sizing/position valuesnumber | string, flex/overflow valuesstring. - Style: Optional
background,border,borderRadius,boxShadow,color,cursor,fill,font,letterSpacing,lineHeight,margin,opacity,outline,padding,scrollMargin,stroke,textAlign,textDecoration,transition,visibility,zIndex— eachstring. - Attrs:
Record<string, string | number | boolean>— HTML attributes. Keys starting withonare skipped. Special handling:class/className,style(string),value, boolean attributes, imgloading/decoding, iframeloading, labelfor(ashtmlFor), anchordownload. Other attrs (e.g.form,inputmode,enterkeyhint,sandbox,srcdoc,srcset,sizes,media,type) applied viasetAttribute. - Schema2UIDefault:
{ create, render }— base API shape. Calling the default export returns this plusel(i.e.{ create, render, el }). - Schema2UIDefaultExport: the default export (callable); has
.create,.render,.el; calling it returns{ create, render, el }.
- README — Installation and quick start.
- docs/ — Browser demo: each use case above (create, render, refs, onNodeMount, dialog, form, focus, etc.) has a runnable example in
docs/assets/(e.g.demo.js,sections/dialog.js,sections/form.js). Build then serve and openhttp://localhost:3000/docs/. - Tests under
tests/— Constant, Create, Helper, Index, Render, Validator.