Native Tags
Native tags are the built-in HTML elements. In Marko they behave like standard HTML with a few ergonomic enhancements.
Element References
All native tags expose a Tag Variable that provides a getter to the reference of the DOM node.
<div/ref/>
<script>
ref().innerHTML = "Hello World";
</script><div/ref/>
<script>
ref().innerHTML = "Hello World"
</script>div/ref
script
--
ref().innerHTML = "Hello World";
--div/ref
script
--
ref().innerHTML = "Hello World"
--The node reference is only available in the browser. Attempting to access a DOM node from the server will result in an error.
Enhanced Attributes
class=
In addition to strings, Marko supports passing arrays and objects to the class= attribute.
<!-- string-->
<div class="a c"/>
<!-- object-->
<div class={
a: true,
b: false,
c: true
}/>
<!-- array-->
<div class=["a", null, {
c: true
}]/>// string
<div class="a c"/>
// object
<div class={ a: true, b: false, c: true }/>
// array
<div class=["a", null, { c: true }]/><!-- string-->
div class="a c"
<!-- object-->
div class={
a: true,
b: false,
c: true
}
<!-- array-->
div class=["a", null, {
c: true
}]// string
div class="a c"
// object
div class={ a: true, b: false, c: true }
// array
div class=["a", null, { c: true }]All three render the same HTML:
<div class="a c"></div>Each key of an object is a class name, included when its value is truthy. Every falsy value drops the class. Objects are read one level deep: a value is only tested for truthiness, never traversed.
Arrays may be nested to any depth and spread, and their falsy entries are skipped. Class names are not deduplicated, and when nothing remains the attribute is omitted entirely.
<let/query="">
<input
value:=query
class=["field", query && ["field-filled", {
"field-error": !query.trim()
}]]
><let/query="">
<input
value:=query
class=["field", query && ["field-filled", { "field-error": !query.trim() }]]
>let/query=""
input
,value:=query
,class=["field", query && ["field-filled", {
"field-error": !query.trim()
}]]let/query=""
input
,value:=query
,class=["field", query && ["field-filled", { "field-error": !query.trim() }]]style=
In addition to strings, Marko supports passing arrays and objects to the style= attribute.
<!-- string-->
<div style="display:block;margin-right:16px"/>
<!-- object-->
<div style={
display: "block",
"margin-right": "16px"
}/>
<!-- array-->
<div style=["display:block", null, {
"margin-right": "16px"
}]/>// string
<div style="display:block;margin-right:16px"/>
// object
<div style={ display: "block", "margin-right": "16px" }/>
// array
<div style=["display:block", null, { "margin-right": "16px" }]/><!-- string-->
div style="display:block;margin-right:16px"
<!-- object-->
div style={
display: "block",
"margin-right": "16px"
}
<!-- array-->
div style=["display:block", null, {
"margin-right": "16px"
}]// string
div style="display:block;margin-right:16px"
// object
div style={ display: "block", "margin-right": "16px" }
// array
div style=["display:block", null, { "margin-right": "16px" }]All three produce the declaration list display:block;margin-right:16px. Declarations are joined with ;, and no trailing ; is added.
Object keys are written out verbatim, so they must be hyphen-case CSS property names.
Unlike the DOM style API, keys are never converted, so a camelCased key renders as invalid CSS the browser ignores.
<!-- ❌ (INCORRECT) renders `backgroundColor:red`-->
<div style={
backgroundColor: "red"
}/>
<!-- ✅-->
<div style={
"background-color": "red"
}/>// ❌ (INCORRECT) renders `backgroundColor:red`
<div style={ backgroundColor: "red" }/>
// ✅
<div style={ "background-color": "red" }/><!-- ❌ (INCORRECT) renders `backgroundColor:red`-->
div style={
backgroundColor: "red"
}
<!-- ✅-->
div style={
"background-color": "red"
}// ❌ (INCORRECT) renders `backgroundColor:red`
div style={ backgroundColor: "red" }
// ✅
div style={ "background-color": "red" }The compiler warns on camelCased keys it can see statically, and debug builds warn at runtime for keys from dynamic objects. Both suggest the hyphen-case name.
Values are stringified as-is, with no unit inference: style={ "margin-right": 16 } renders margin-right:16, not margin-right:16px. Numbers are only meaningful for unitless properties such as line-height or z-index, and the TypeScript types reject any number other than 0 for length properties.
A declaration is dropped when its value is false, null, undefined or an empty string, but 0 is kept, so style={ width: 0 } renders width:0. This differs from class= objects, where every falsy value drops the class.
The TypeScript types reject false as an object value, so conditional declarations belong at the array level.
<div style=["display:block", isError && {
color: "red"
}]/><div style=["display:block", isError && { color: "red" }]/>div style=["display:block", isError && {
color: "red"
}]div style=["display:block", isError && { color: "red" }]Arrays nest and spread exactly as they do for class=. Custom properties are written out like any other key, though the TypeScript types require registering each one.
content=
Native tags accept their body content as a content= attribute. The value may be a <define> tag variable, an imported template, or the content the surrounding template received.
Because content arrives as part of input, a spread is enough to implement a tag that wraps an element around the content it receives.
<fieldset ...input/>fieldset ...input<field-row class="row">
<label for="email">Email</label>
<input id="email" name="email" type="email">
</field-row>field-row class="row"
label for="email" -- Email
input id="email" name="email" type="email"The spread applies class to the <fieldset> and renders the content inside it:
<fieldset class="row"><label for="email">Email</label><input id="email" name="email" type="email"></fieldset>Passing the attribute explicitly places the content on a specific element, such as an inner wrapper:
<section class="card">
<h2>${input.title}</h2>
<div class="card-body" content=input.content/>
</section>section class="card"
h2 -- ${input.title}
div class="card-body" content=input.contentcontent= is reactive. Swapping the value replaces the rendered content in place, leaving the element itself untouched.
<let/expanded=false>
<define/Summary>
<h2>${input.title}</h2>
</define>
<define/Details>
<h2>${input.title}</h2>
<p>${input.description}</p>
</define>
<article content=(expanded ? Details : Summary)/>
<button onClick() {
expanded = !expanded;
}>toggle</button><let/expanded=false>
<define/Summary>
<h2>${input.title}</h2>
</define>
<define/Details>
<h2>${input.title}</h2>
<p>${input.description}</p>
</define>
<article content=(expanded ? Details : Summary)/>
<button onClick() { expanded = !expanded }>toggle</button>let/expanded=false
define/Summary
h2 -- ${input.title}
define/Details
h2 -- ${input.title}
p -- ${input.description}
article content=(expanded ? Details : Summary)
button onClick() {
expanded = !expanded;
} -- togglelet/expanded=false
define/Summary
h2 -- ${input.title}
define/Details
h2 -- ${input.title}
p -- ${input.description}
article content=(expanded ? Details : Summary)
button onClick() { expanded = !expanded } -- toggleA literal body takes precedence over content=, which is then never rendered. Any body counts, including one that produces no output of its own, such as a body holding only a comment.
<div content=Summary>
<!-- this comment is body content, so `Summary` never renders-->
</div><div content=Summary>
// this comment is body content, so `Summary` never renders
</div>div content=Summary
<!-- this comment is body content, so `Summary` never renders-->div content=Summary
// this comment is body content, so `Summary` never rendersAttributes are merged from left to right, so ordering decides the result when content= accompanies a spread. Setting it after a spread overrides the content coming from that spread, and content=undefined forwards every other attribute while dropping the content entirely.
<div ...input content=undefined/>div ...input content=undefinedTags that cannot contain markup reject the attribute at compile time. Void elements such as <img> report:
The `<img>` tag cannot have content, so it does not support the `content` attribute.<textarea> and <title> take their body as text, and report:
The `<textarea>` tag takes its content from its body as text, so it does not support the `content` attribute.On <meta>, content is a real HTML attribute and keeps that meaning, including when applied through a spread. It is the only native tag whose content is written to the element instead of rendered as content.
<let/height=630>
<meta property="og:image:height" content=height>let/height=630
meta property="og:image:height" content=heightFor typing content on a custom tag, see Typing content.
Event Handlers
Attributes on native tags that begin with on followed by - or a capital letter are attached as event handlers.
When the attribute starts with on- the event name casing is preserved, otherwise the event name is all lowercased.
onDblClick→dblclickon-DblClick→DblClick
<button onClick() {
alert("Hi!");
}>Say Hi</button>
<!-- equivalent to-->
<button on-click() {
alert("Hi!");
}>Say Hi</button><button onClick() { alert("Hi!") }>Say Hi</button>
// equivalent to
<button on-click() { alert("Hi!") }>Say Hi</button>button onClick() {
alert("Hi!");
} -- Say Hi
<!-- equivalent to-->
button on-click() {
alert("Hi!");
} -- Say Hibutton onClick() { alert("Hi!") } -- Say Hi
// equivalent to
button on-click() { alert("Hi!") } -- Say HiEvent handlers are typically written using the method shorthand for readability.
The value for the attribute must be either a function or a falsy value, allowing for conditional event handlers:
<let/clicked=false>
<button onClick=(!clicked && (() => {
alert("First click!");
clicked = true;
}))>
Click me!
</button><let/clicked=false>
<button onClick=!clicked && (() => {
alert("First click!");
clicked = true;
})>
Click me!
</button>let/clicked=false
button onClick=(!clicked && (() => {
alert("First click!");
clicked = true;
})) --
Click me!let/clicked=false
button onClick=!clicked && (() => {
alert("First click!");
clicked = true;
}) --
Click me!Since native events are all lowercase, the onCamelCase event naming can help with readability of multi-word events:
<canvas onContentVisibilityAutoStateChange() {}/><canvas onContentVisibilityAutoStateChange() { }/>canvas onContentVisibilityAutoStateChange() {}canvas onContentVisibilityAutoStateChange() { }Some custom elements may emit non lowercase event names, in which case (pun intended 😏) you should use on- which preserves the casing.
Even though Marko does support native HTML inline event handler attributes, it's recommended to avoid them since they're detached from Marko's reactivity system and may lead to CSP / XSS issues.
<button onclick="this.innerHTML++">0</button>button onclick="this.innerHTML++" -- 0Handler Arguments
An event handler receives two arguments: the Event and the element the handler was attached to.
<form onSubmit(event, form) {
event.preventDefault();
fetch("/subscribe", {
method: "POST",
body: new FormData(form)
});
}>
<input name="email" type="email">
<button>Subscribe</button>
</form><form onSubmit(event, form) {
event.preventDefault();
fetch("/subscribe", { method: "POST", body: new FormData(form) });
}>
<input name="email" type="email">
<button>Subscribe</button>
</form>form onSubmit(event, form) {
event.preventDefault();
fetch("/subscribe", {
method: "POST",
body: new FormData(form)
});
}
input name="email" type="email"
button -- Subscribeform onSubmit(event, form) {
event.preventDefault();
fetch("/subscribe", { method: "POST", body: new FormData(form) });
}
input name="email" type="email"
button -- SubscribeThe second argument matters when an event originates from a descendant. event.target is the element the event was dispatched on, which for a click inside a <button> may be an inner <span>, while the second argument is always the element carrying the on* attribute.
event.currentTarget is not available in Marko event handlers. Because handlers are delegated, currentTarget is the document in an optimized build, and in a debug build reading it logs an error to the console and evaluates to null. The second argument, or an element reference, replaces it.
Delegation
Marko does not call addEventListener for each element. The first time a handler for an event type is attached, a single listener for that type is registered on the document with capture enabled. When the event fires, that listener invokes the handler on the event's target and then, for events that bubble, the handler on each of its ancestors.
This has a few observable effects.
- Marko handlers run before listeners added with
addEventListeneron the element itself or on any ancestor below thedocument. event.stopPropagation()in a Marko handler prevents handlers on ancestor elements from running, and stops the event before it reaches anyaddEventListenerlistener below thedocument, including one on the same element. Calling it from a listener attached below thedocumenthas no effect on Marko handlers, which have already run.- Events that do not bubble, such as
focus,blur, andload, only reach a handler on the element the event was dispatched on. A handler on an ancestor is never called for them.
Change Handlers
Some native tags in Marko have additional attributes that make them controllable. These attributes end with Change and are designed to work with the bind shorthand.
For DOM elements that maintain internal state separate from an associated attribute, Marko uses "uncontrolled" attributes by default, meaning it only sets the attribute value and not the internal value.
<input value="hello">input value="hello"Above is among the simplest of examples, but interestingly its behavior is different across frameworks in subtle ways.
In some frameworks, like React, this would be a "read-only" <input>. Marko takes a different approach, allowing the input's state to be managed natively by the browser.
Adding state introduces some nuances in behavior.
<let/message="hello">
<input value=message>
<div>${message}</div>
<button onClick() {
message = "goodbye";
}>Click Me</button><let/message="hello">
<input value=message>
<div>${message}</div>
<button onClick() { message = "goodbye" }>Click Me</button>let/message="hello"
input value=message
div -- ${message}
button onClick() {
message = "goodbye";
} -- Click Melet/message="hello"
input value=message
div -- ${message}
button onClick() { message = "goodbye" } -- Click MeIn this example, typing in the <input> and then clicking the <button> might not behave as expected. The <div> text updates only when the button is clicked, and the <input> doesn't reflect the new "goodbye" value.
This occurs because there are two separate states, which update independently:
- The Marko-managed state in
<let/message> - The internal state of the
<input>value
To synchronize these two states and their updates, Marko includes a special valueChange attribute on <input>.
<let/message="hello">
<input value=message valueChange() {}>
<div>${message}</div>
<button onClick() {
message = "goodbye";
}>Click Me</button><let/message= "hello">
<input value=message valueChange() {}>
<div>${message}</div>
<button onClick() { message = "goodbye" }>Click Me</button>let/message="hello"
input value=message valueChange() {}
div -- ${message}
button onClick() {
message = "goodbye";
} -- Click Melet/message= "hello"
input value=message valueChange() {}
div -- ${message}
button onClick() { message = "goodbye" } -- Click MeThe valueChange attribute transforms the behavior:
- Typing in the
<input>updates both the<input>and the<div> - Clicking the
<button>updates both the<input>and the<div>
There is now only one state! This synchronization occurs because valueChange:
- Captures internal
<input>changes - Updates the
messagevariable, which then updates thevalue=attribute
The valueChange function is called whenever the <input> would normally update, allowing a parent component to synchronize its state with the input's internal state.
<let/message="hello">
<input value=message valueChange(newMessage) {
message = newMessage;
}>
<div>${message}</div>
<button onClick() {
message = "goodbye";
}>Click Me</button><let/message= "hello">
<input value=message valueChange(newMessage) { message = newMessage }>
<div>${message}</div>
<button onClick() { message = "goodbye" }>Click Me</button>let/message="hello"
input value=message valueChange(newMessage) {
message = newMessage;
}
div -- ${message}
button onClick() {
message = "goodbye";
} -- Click Melet/message= "hello"
input value=message valueChange(newMessage) { message = newMessage }
div -- ${message}
button onClick() { message = "goodbye" } -- Click MeIn this example, there is a single state and updates from both sources are handled. Typing in the <input> and clicking the <button> cause changes to both the <div> and the <input> itself. Everything is in sync!
Marko has a shorthand for simple reflective change handlers like this, allowing the example to be simplified to:
<let/message="Hello">
<input value:=message>
<div>${message}</div>
<button onClick() {
message = "Goodbye";
}>Click Me</button><let/message="Hello">
<input value:=message>
<div>${message}</div>
<button onClick() { message = "Goodbye" }>Click Me</button>let/message="Hello"
input value:=message
div -- ${message}
button onClick() {
message = "Goodbye";
} -- Click Melet/message="Hello"
input value:=message
div -- ${message}
button onClick() { message = "Goodbye" } -- Click MeWith this shorthand all that is needed to go from "uncontrolled" to "controlled" for the value attribute was to swap from value= to value:=.
For cases besides the most simple, manual valueChange handlers are required.
<let/message="hello">
<input
value=message
valueChange(newMessage) {
message = newMessage.toLowerCase();
}
>
<div>${message}</div>
<button onClick() {
message = "goodbye";
}>Click Me</button><let/message= "hello">
<input
value=message
valueChange(newMessage) { message = newMessage.toLowerCase() }
>
<div>${message}</div>
<button onClick() { message = "goodbye" }>Click Me</button>let/message="hello"
input
,value=message
,valueChange(newMessage) {
message = newMessage.toLowerCase();
}
div -- ${message}
button onClick() {
message = "goodbye";
} -- Click Melet/message= "hello"
input
,value=message
,valueChange(newMessage) { message = newMessage.toLowerCase() }
div -- ${message}
button onClick() { message = "goodbye" } -- Click MeAll changes to this <input> are intercepted and manipulated. In this example, all UPPERCASE characters are automatically converted to lowercase. This pattern is useful for input masking and more - and it's built in!
<!-- uncontrolled - The browser owns the state-->
<input value="hello">
<!-- controlled - The `inputValue` tag variable owns the state-->
<let/inputValue="hello">
<input value:=inputValue>
<!-- controlled - Modifications to `<input>` are transformed-->
<let/creditCardNumber="5555 5555 555">
<input
value=creditCardNumber
valueChange(v) {
creditCardNumber = [...v.replace(/\D/g, "").matchAll(/\d{1,4}/g)].join(" ");
}
>// uncontrolled - The browser owns the state
<input value="hello">
// controlled - The `inputValue` tag variable owns the state
<let/inputValue="hello">
<input value:=inputValue>
// controlled - Modifications to `<input>` are transformed
<let/creditCardNumber="5555 5555 555">
<input
value=creditCardNumber
valueChange(v) {
creditCardNumber = [...v.replace(/\D/g, "").matchAll(/\d{1,4}/g)].join(" ");
}
><!-- uncontrolled - The browser owns the state-->
input value="hello"
<!-- controlled - The `inputValue` tag variable owns the state-->
let/inputValue="hello"
input value:=inputValue
<!-- controlled - Modifications to `<input>` are transformed-->
let/creditCardNumber="5555 5555 555"
input
,value=creditCardNumber
,valueChange(v) {
creditCardNumber = [...v.replace(/\D/g, "").matchAll(/\d{1,4}/g)].join(" ");
}// uncontrolled - The browser owns the state
input value="hello"
// controlled - The `inputValue` tag variable owns the state
let/inputValue="hello"
input value:=inputValue
// controlled - Modifications to `<input>` are transformed
let/creditCardNumber="5555 5555 555"
input
,value=creditCardNumber
,valueChange(v) {
creditCardNumber = [...v.replace(/\D/g, "").matchAll(/\d{1,4}/g)].join(" ");
}<input> (valueChange=, checkedChange=, checkedValueChange=)
The <input> tag has 3 change handlers, which are each related to an input type.
The value= attribute may be controlled with valueChange=
<let/text="">
<input type="text" value:=text>
<input
type="text"
value=text
valueChange(value) {
text = value.toLowerCase();
}
><let/text="">
<input type="text" value:=text>
<input type="text" value=text valueChange(value) { text = value.toLowerCase() }>let/text=""
input type="text" value:=text
input type="text" value=text valueChange(value) {
text = value.toLowerCase();
}let/text=""
input type="text" value:=text
input type="text" value=text valueChange(value) { text = value.toLowerCase() }The value of <input> is always a string, so numbers need to be casted.
<let/number=0>
<!-- ❌ (INCORRECT) this will set number to a string when updated-->
<input type="number" value:=number>
<!-- ✅ cast the string value to a number during the change handler-->
<input type="number" value=number valueChange(value) {
number = +value;
}><let/number=0>
// ❌ (INCORRECT) this will set number to a string when updated
<input type="number" value:=number>
// ✅ cast the string value to a number during the change handler
<input type="number" value=number valueChange(value) { number = +value }>let/number=0
<!-- ❌ (INCORRECT) this will set number to a string when updated-->
input type="number" value:=number
<!-- ✅ cast the string value to a number during the change handler-->
input type="number" value=number valueChange(value) {
number = +value;
}let/number=0
// ❌ (INCORRECT) this will set number to a string when updated
input type="number" value:=number
// ✅ cast the string value to a number during the change handler
input type="number" value=number valueChange(value) { number = +value }The checked= attribute may be controlled with checkedChange=
<let/checked=false>
<input type="checkbox" checked:=checked>
<input
type="checkbox"
checked=checked
checkedChange(value) {
checked = value;
}
><let/checked=false>
<input type="checkbox" checked:=checked>
<input type="checkbox" checked=checked checkedChange(value) { checked = value }>let/checked=false
input type="checkbox" checked:=checked
input type="checkbox" checked=checked checkedChange(value) {
checked = value;
}let/checked=false
input type="checkbox" checked:=checked
input type="checkbox" checked=checked checkedChange(value) { checked = value }The added checkedValue= attribute also has a change handler.
<let/checked="foo">
<input type="radio" value="foo" checkedValue:=checked>let/checked="foo"
input type="radio" value="foo" checkedValue:=checked<select> (valueChange=)
Traditionally, the value of a <select> is controlled via the selected= attribute in its <option> tags. Marko adds an additional way to control the <select> using a new value= attribute, which is also controllable with a Change handler.
<let/selected="en">
<select value:=selected>
<option value="en">English</option>
<option value="pt-br">Portuguese (Brazil)</option>
<option value="it">Italian</option>
</select>let/selected="en"
select value:=selected
option value="en" -- English
option value="pt-br" -- Portuguese (Brazil)
option value="it" -- Italian<textarea> (valueChange=)
The <textarea> tag has a change handler for Marko's added value= attribute.
<let/text="">
<textarea value:=text/>let/text=""
textarea value:=text<details> (openChange=)
The <details> tag has a change handler for its open= attribute.
<let/open=false>
<details open:=open/>
<button onClick() {
open = false;
}>Collapse</button><let/open=false>
<details open:=open/>
<button onClick() { open = false }>Collapse</button>let/open=false
details open:=open
button onClick() {
open = false;
} -- Collapselet/open=false
details open:=open
button onClick() { open = false } -- Collapse<dialog> (openChange=)
The <dialog> tag has a change handler for its open= attribute.
<let/open=false>
<dialog open:=open>Hello!</dialog>
<button onClick() {
open = !open;
}>Toggle</button><let/open=false>
<dialog open:=open>Hello!</dialog>
<button onClick() { open = !open }>Toggle</button>let/open=false
dialog open:=open -- Hello!
button onClick() {
open = !open;
} -- Togglelet/open=false
dialog open:=open -- Hello!
button onClick() { open = !open } -- ToggleThe open attribute of the <dialog> tag can be used to control a non-modal dialog. However if you need a modal dialog, you should use the .showModal() method directly. Calling this method will not cause openChange to fire as the HTML <dialog> only fires an event on close.
Contributors
Helpful? You can thank these awesome people! You can also edit this doc if you see any issues or want to improve it.