Skip to content

Elements

This page describes element syntax and attribute rules. For full grammar, see nx-grammar.md.

type User = { name:string }
let <UserForm user:User isLocked:boolean /> =
<form>
<Button variant="primary">Click Me</Button>
<Input value={user.name} disabled={isLocked}/>
</form>
  • A property value needs no braces when it is a literal, a negated numeric literal, a union case name, or a single element: variant="primary", count=3, tone=primary, padding=<Thickness Left=4.0/>.
  • Braces hold any other expression, and they hold sequences: value={user.name}, points={ <Point x=0 y=0/> <Point x=1 y=1/> }. A sequence keeps its braces even when it holds a single item, which is also how a sequence-valued property formats.
  • Attribute names are case-sensitive and must match the component signature.
<Tooltip
content=<span:>
<strong>Bold</strong> and <em>italic</em> text
</span>
>
Hover over me
</Tooltip>

Attributes may contain markup as values when the parameter type accepts elements.

Property lists can include conditional fragments. A fragment contributes zero or more properties to the invocation before the target component, function, record, or union case is bound.

let <DialogButton primary:boolean /> =
<Button
if primary {
label="Save"
tone="strong"
} else {
label="Cancel"
tone="neutral"
}
/>

Condition-list fragments choose the first true arm:

let <StatusBadge isError:boolean isWarning:boolean /> =
<Badge
if {
isError => tone="danger"
isWarning => tone="warning"
else => tone="neutral"
}
/>

Match-style fragments use the same union case validation and local identifier narrowing as value matches:

type LoadState =
| idle
| failed { message:string }
let <StatusNotice state:LoadState /> =
<Notice
if state is {
LoadState.failed => message={state.message}
else => message=""
}
/>

Required properties must be supplied on every reachable branch. Duplicate property names are rejected when they can occur on the same path, including a direct property plus a conditional branch property. The same property name is allowed in mutually exclusive branches.

Content properties follow the same rules. Body content conflicts with a named content property on any reachable branch, while mutually exclusive named content-property branches are accepted.

<UI.Controls.Button variant="primary">Click</UI.Controls.Button>

Namespaces qualify element names and help avoid collisions in large libraries.

type Check = <function value:string />: boolean
type Rule = { field:string check:Check }
let notBlank(value:string): boolean = { value != "" }
let <SignupForm isLoading:boolean hasErrors:boolean /> =
<Form
rules={ <Rule field="email" check={notBlank}/> <Rule field="name" check={notBlank}/> }
className={"form" + (if isLoading { " loading" } else { "" }) + (if hasErrors { " error" } else { "" })}
>
Form content
</Form>

Expressions, conditionals, sequences, and function values are valid attribute values. A function is passed by name, as a function value.

let <StyledButton variant:string = "primary" content body:Element /> =
<button
style=<Style
backgroundColor={if (variant == "primary") { "#007bff" } else { "#6c757d" }}
color="white"
border="none"
padding="8px 16px"
borderRadius="4px"
cursor="pointer"
/>
>
{body}
</button>

Inline styles follow the same attribute rules as other properties. When a declaration marks exactly one property with content, element body content binds to that property during invocation.

type User = { id:string name:string email:string }
type Point = { x:int y:int }
let card = <UserCard user=<User id="456" name="Jane" email="jane@example.com"/> />
let canvas = <DrawingCanvas points={ <Point x=10 y=20/> <Point x=30 y=40/> } />

Attributes may receive typed objects or sequences when the signature permits. A single object stands on its own; a sequence goes in braces.

Element-shaped construction also builds patches. Every record T has an update record T.Update whose fields are all optional, so the element names only the fields that change:

type User = { name:string email?:string }
<ProfileEditor changes=<User.Update email={} /> />

Here email is cleared — present and empty, encoded as JSON null — and every other User field is left as it was; the value carries only the email key. Clearing is allowed only for a field declared optional, such as email?:string. See Updating state.