View Nodes
Complete reference for all view node types in Constela
Overview
View nodes are the building blocks of Constela UIs. There are 9 node types, each serving a specific purpose in describing your interface.
Element Node
Renders an HTML element with optional props and children.
{
"kind": "element",
"tag": "div",
"props": { "class": "container" },
"children": [ ... ]
}| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| kind | "element" | Yes | - | Node type identifier. |
| tag | string | Yes | - | HTML tag name (e.g., "div", "span", "button"). |
| props | Record<string, Expression | string> | No | - | HTML attributes and event handlers. Event handlers use action names as strings. |
| children | ViewNode[] | No | - | Child nodes to render inside this element. |
Event Handling
Event handlers are specified as objects with event, action, and optional payload fields in props:
{
"kind": "element",
"tag": "button",
"props": {
"onClick": { "event": "click", "action": "handleClick" },
"onMouseOver": { "event": "mouseover", "action": "showTooltip" }
},
"children": [...]
}Text Node
Renders dynamic or static text content.
{
"kind": "text",
"value": { "expr": "state", "name": "message" }
}| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| kind | "text" | Yes | - | Node type identifier. |
| value | Expression | Yes | - | Expression that evaluates to the text content. |
Examples
// Static text
{ "kind": "text", "value": { "expr": "lit", "value": "Hello, World!" } }
// Dynamic text from state
{ "kind": "text", "value": { "expr": "state", "name": "userName" } }
// Computed text
{
"kind": "text",
"value": {
"expr": "bin",
"op": "+",
"left": { "expr": "lit", "value": "Count: " },
"right": { "expr": "state", "name": "count" }
}
}If Node
Conditionally renders content based on an expression.
{
"kind": "if",
"condition": { "expr": "state", "name": "isLoggedIn" },
"then": { ... },
"else": { ... }
}| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| kind | "if" | Yes | - | Node type identifier. |
| condition | Expression | Yes | - | Expression that evaluates to a boolean. |
| then | ViewNode | Yes | - | Node to render when condition is truthy. |
| else | ViewNode | No | - | Node to render when condition is falsy. |
| transition | TransitionDirective | No | - | CSS class-based enter/exit transition. See Transitions section below. |
Example
{
"kind": "if",
"condition": {
"expr": "bin",
"op": ">",
"left": { "expr": "state", "name": "count" },
"right": { "expr": "lit", "value": 0 }
},
"then": {
"kind": "text",
"value": { "expr": "lit", "value": "Positive!" }
},
"else": {
"kind": "text",
"value": { "expr": "lit", "value": "Zero or negative" }
}
}Transitions
Add smooth enter/exit animations when content is conditionally rendered:
{
"kind": "if",
"condition": { "expr": "state", "name": "visible" },
"transition": {
"enter": "fade-enter",
"enterActive": "fade-enter-active",
"exit": "fade-exit",
"exitActive": "fade-exit-active"
},
"then": {
"kind": "element",
"tag": "div",
"children": [
{ "kind": "text", "value": { "expr": "lit", "value": "Hello!" } }
]
}
}Define the corresponding CSS:
.fade-enter { opacity: 0; }
.fade-enter-active { transition: opacity 300ms ease; opacity: 1; }
.fade-exit { opacity: 1; }
.fade-exit-active { transition: opacity 300ms ease; opacity: 0; }How it works:
- Enter: When the condition becomes truthy, the
enterclass is applied on the first frame, thenenterActiveon the next frame to trigger the CSS transition - Exit: When the condition becomes falsy, the
exitclass is applied first, thenexitActivetriggers the transition. The element is removed aftertransitionendfires
TransitionDirective
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| enter | string | Yes | - | CSS class applied on the initial frame of enter. |
| enterActive | string | Yes | - | CSS class applied on the next frame to trigger the enter animation. |
| exit | string | Yes | - | CSS class applied on the initial frame of exit. |
| exitActive | string | Yes | - | CSS class applied on the next frame to trigger the exit animation. |
| duration | number | No | - | Fallback duration in milliseconds. Defaults to transitionend event detection. |
Note
Transitions are ignored during server-side rendering (SSR). Content is rendered immediately without animation.
Each Node
Iterates over an array and renders content for each item.
{
"kind": "each",
"items": { "expr": "state", "name": "todos" },
"as": "todo",
"index": "i",
"key": "id",
"body": { ... }
}| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| kind | "each" | Yes | - | Node type identifier. |
| items | Expression | Yes | - | Expression that evaluates to an array. |
| as | string | Yes | - | Variable name for the current item in each iteration. |
| index | string | No | - | Variable name for the current index. |
| key | Expression | No | - | Expression that evaluates to a unique key for each item (e.g., item.id). |
| body | ViewNode | Yes | - | Node to render for each item. |
| transition | TransitionDirective | No | - | CSS class-based enter/exit transition for list items. |
Note
Transition support for each nodes with keyed reconciliation is planned for a future release.
Example
{
"kind": "each",
"items": { "expr": "state", "name": "users" },
"as": "user",
"index": "idx",
"key": { "expr": "var", "name": "user", "path": "id" },
"body": {
"kind": "element",
"tag": "li",
"children": [
{
"kind": "text",
"value": { "expr": "var", "name": "user", "path": "name" }
}
]
}
}The Key Property
The key property provides a stable identity for each item in the list. This is essential for the reconciliation algorithm to efficiently update the DOM when the list changes.
Why Keys Matter
When a list is re-rendered, Constela needs to determine which elements have been added, removed, or moved. Without keys, the only option is to compare elements by their position (index), which leads to inefficient updates and bugs.
| Scenario | Without Key | With Key |
|---|---|---|
| Item added at start | Re-renders all elements | Only inserts new element |
| Item removed from middle | Re-renders all following elements | Only removes that element |
| Items reordered | Re-renders all changed positions | Moves existing DOM nodes |
| Input state preservation | Input values shift to wrong items | Input values stay with their items |
Key Best Practices
- Use unique, stable identifiers: Database IDs, UUIDs, or other persistent identifiers work best.
// Good: Stable ID from data
"key": { "expr": "var", "name": "todo", "path": "id" }
// Bad: Index changes when items are reordered
"key": { "expr": "var", "name": "index" }- Avoid using index as key unless the list is static and will never be reordered.
- Keys must be unique among siblings. Duplicate keys cause undefined behavior.
How Reconciliation Works
Constela's reconciliation algorithm uses keys to minimize DOM operations:
- Build key maps: Creates a mapping of keys to existing DOM elements
- Identify changes: Compares old and new key sets to find additions, removals, and moves
- Apply minimal updates: Reorders existing elements, inserts new ones, and removes deleted ones
This key-based approach ensures that:
- Existing DOM nodes are reused when possible
- Form input values and focus states are preserved
- Animations and transitions work correctly across updates
- Performance remains optimal even with large lists
Example: Preserving Input State
Without proper keys, input values can shift incorrectly when items are added or removed:
{
"kind": "each",
"items": { "expr": "state", "name": "todos" },
"as": "todo",
"index": "i",
"key": { "expr": "var", "name": "todo", "path": "id" },
"body": {
"kind": "element",
"tag": "li",
"children": [
{
"kind": "element",
"tag": "input",
"props": {
"type": { "expr": "lit", "value": "text" },
"value": { "expr": "var", "name": "todo", "path": "title" },
"onInput": { "event": "input", "action": "updateTodo", "payload": { "index": { "expr": "var", "name": "i" } } }
}
}
]
}
}With the id-based key, even if a todo is removed from the middle of the list, the input values for the remaining items stay correctly associated with their respective todos.
Component Node
Renders a reusable component with props.
{
"kind": "component",
"name": "Button",
"props": {
"label": { "expr": "lit", "value": "Click me" }
},
"children": [ ... ]
}| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| kind | "component" | Yes | - | Node type identifier. |
| name | string | Yes | - | Name of the component to render (must be defined in components). |
| props | Record<string, Expression> | No | - | Props to pass to the component. |
| children | ViewNode[] | No | - | Children to pass into the component's slot. |
Example
{
"kind": "component",
"name": "Card",
"props": {
"title": { "expr": "lit", "value": "Welcome" },
"variant": { "expr": "state", "name": "cardStyle" }
},
"children": [
{ "kind": "text", "value": { "expr": "lit", "value": "Card content here" } }
]
}Slot Node
Placeholder for children passed to a component.
{
"kind": "slot"
}| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| kind | "slot" | Yes | - | Node type identifier. No additional props. |
Usage in Component Definition
{
"components": {
"Card": {
"params": {
"title": { "type": "string" }
},
"view": {
"kind": "element",
"tag": "div",
"props": { "class": { "expr": "lit", "value": "card" } },
"children": [
{
"kind": "element",
"tag": "h2",
"children": [
{ "kind": "text", "value": { "expr": "param", "name": "title" } }
]
},
{ "kind": "slot" }
]
}
}
}
}When using this component:
{
"kind": "component",
"name": "Card",
"props": { "title": { "expr": "lit", "value": "Hello" } },
"children": [
{ "kind": "text", "value": { "expr": "lit", "value": "This goes into the slot" } }
]
}Markdown Node
Renders Markdown content as HTML with GFM (GitHub Flavored Markdown) support.
{
"kind": "markdown",
"content": { "expr": "lit", "value": "# Hello\n\nThis is **bold** text." }
}| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| kind | "markdown" | Yes | - | Node type identifier. |
| content | Expression | Yes | - | Expression that evaluates to a Markdown string. |
Features
- GFM support: Tables, strikethrough, task lists, and more
- XSS protection: Sanitized via DOMPurify
- SSR compatible: Works with server-rendered HTML
- CSS class:
.constela-markdownfor styling
Examples
// Static markdown
{
"kind": "markdown",
"content": { "expr": "lit", "value": "## Features\n\n- Item 1\n- Item 2" }
}
// Dynamic markdown from state
{
"kind": "markdown",
"content": { "expr": "state", "name": "articleBody" }
}Code Node
Renders code with VS Code-quality syntax highlighting powered by Shiki.
{
"kind": "code",
"language": { "expr": "lit", "value": "typescript" },
"content": { "expr": "lit", "value": "const x: number = 1;" }
}| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| kind | "code" | Yes | - | Node type identifier. |
| language | Expression | Yes | - | Expression that evaluates to the language name (e.g., "typescript", "javascript", "json"). |
| content | Expression | Yes | - | Expression that evaluates to the code string. |
Features
- Shiki highlighting: VS Code-quality syntax highlighting
- SSR compatible: Pre-highlighted on the server
- CSS class:
.constela-codefor styling
Examples
// Static code block
{
"kind": "code",
"language": { "expr": "lit", "value": "json" },
"content": { "expr": "lit", "value": "{ \"key\": \"value\" }" }
}
// Dynamic code from state
{
"kind": "code",
"language": { "expr": "state", "name": "selectedLanguage" },
"content": { "expr": "state", "name": "codeSnippet" }
}Portal Node
Renders children to a different location in the DOM, outside the normal component tree. Ideal for modals, tooltips, and dropdowns.
{
"kind": "portal",
"target": "body",
"children": [
{ "kind": "element", "tag": "div", "children": [...] }
]
}| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| kind | "portal" | Yes | - | Node type identifier. |
| target | string | Yes | - | Target location: "body", "head", or a CSS selector. |
| children | ViewNode[] | Yes | - | Child nodes to render at the target location. |
Target Options
| Target | Description |
|---|---|
"body" | Renders to document.body |
"head" | Renders to document.head (for meta tags, etc.) |
| CSS selector | Renders to matching element (e.g., "#modal-root") |
Example: Modal Dialog
{
"state": {
"isModalOpen": { "type": "boolean", "initial": false }
},
"actions": [
{
"name": "openModal",
"steps": [{ "do": "set", "target": "isModalOpen", "value": { "expr": "lit", "value": true } }]
},
{
"name": "closeModal",
"steps": [{ "do": "set", "target": "isModalOpen", "value": { "expr": "lit", "value": false } }]
}
],
"view": {
"kind": "element",
"tag": "div",
"children": [
{
"kind": "element",
"tag": "button",
"props": { "onClick": { "event": "click", "action": "openModal" } },
"children": [{ "kind": "text", "value": { "expr": "lit", "value": "Open Modal" } }]
},
{
"kind": "if",
"condition": { "expr": "state", "name": "isModalOpen" },
"then": {
"kind": "portal",
"target": "body",
"children": [{
"kind": "element",
"tag": "div",
"props": { "className": { "expr": "lit", "value": "fixed inset-0 bg-black/50 flex items-center justify-center" } },
"children": [{
"kind": "element",
"tag": "div",
"props": { "className": { "expr": "lit", "value": "bg-white p-6 rounded-lg" } },
"children": [
{ "kind": "text", "value": { "expr": "lit", "value": "Modal Content" } },
{
"kind": "element",
"tag": "button",
"props": { "onClick": { "event": "click", "action": "closeModal" } },
"children": [{ "kind": "text", "value": { "expr": "lit", "value": "Close" } }]
}
]
}]
}]
}
}
]
}
}Example: Tooltip
{
"kind": "portal",
"target": "#tooltip-root",
"children": [{
"kind": "element",
"tag": "div",
"props": {
"className": { "expr": "lit", "value": "absolute bg-gray-800 text-white px-2 py-1 rounded text-sm" },
"style": {
"expr": "concat",
"items": [
{ "expr": "lit", "value": "left: " },
{ "expr": "state", "name": "tooltipX" },
{ "expr": "lit", "value": "px; top: " },
{ "expr": "state", "name": "tooltipY" },
{ "expr": "lit", "value": "px;" }
]
}
},
"children": [{ "kind": "text", "value": { "expr": "state", "name": "tooltipText" } }]
}]
}Tip
Portal children are cleaned up automatically when the portal node is removed from the tree. Use portals for any content that needs to break out of CSS stacking contexts or overflow constraints.
Note
When using a CSS selector as the target, the element must exist in the DOM. If not found, children are not rendered. Consider adding a dedicated container element like <div id="modal-root"></div> to your HTML template.
Suspense Node
Renders fallback content while async content is loading. Used with Streaming SSR for progressive HTML delivery.
{
"kind": "suspense",
"id": "user-data",
"fallback": {
"kind": "element",
"tag": "div",
"props": { "className": { "expr": "lit", "value": "skeleton" } }
},
"content": {
"kind": "component",
"name": "UserProfile"
}
}| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| kind | "suspense" | Yes | - | Node type identifier. |
| id | string | Yes | - | Unique identifier for the suspense boundary. |
| fallback | ViewNode | Yes | - | Content to show while loading (e.g., skeleton, spinner). |
| content | ViewNode | Yes | - | Async content to load and render. |
| timeout | number | No | - | Fallback timeout in milliseconds. |
Example: Dashboard with Loading States
{
"kind": "element",
"tag": "div",
"children": [
{
"kind": "suspense",
"id": "user-profile",
"fallback": { "kind": "component", "name": "ProfileSkeleton" },
"content": { "kind": "component", "name": "UserProfile" }
},
{
"kind": "suspense",
"id": "activity-feed",
"fallback": { "kind": "component", "name": "FeedSkeleton" },
"content": { "kind": "component", "name": "ActivityFeed" }
}
]
}Tip
Suspense boundaries can be nested for granular loading states. Each boundary streams independently when its content is ready.
Note
Suspense nodes require Streaming SSR to be enabled. Without streaming, the server waits for all content before sending the response.