Manual HTML Attributes

Attributes

elements man html/attributes Read as markdown

Typed attributes on a template constructor, reactive expression blocks, and the two-way bindings Elements installs on form controls.

Attributes go in () on the opening tag. Each attribute is public (the default) or private. Optional attributes use ?. Attributes can have initializers that reference other attributes, and declaration order does not matter. private attributes cannot be provided by callers, but they can be mutated inside the template scope.

<html (
  title: string,
  active: boolean = true,
  greeting: string = `Hello ${title}`,
  private toggle: boolean = false
)>
  <h1>{greeting}</h1>
</html>

Attributes are fully typed and integrated with the codebase's tooling: rename, find-references, go-to-definition, and type checking work across templates and TypeScript alike.

Attributes are reactive automatically, including across template boundaries:

<TaskList (tasks: Task[])>
  <Task e:for={task of tasks} {task}/>
  done: {tasks.filter(t => t.done).length}
</TaskList>

<Task (task: Task)>
  <input value={task.desc}>
  <input type="checkbox" checked={task.done}>
</Task>

Toggling the checkbox in <Task> updates the count in <TaskList> immediately.

Attributes live on the root tag only. An attribute list on a non-root element (e.g. <div (form: Form)>) is invalid.

Expressions

{ ... } is a TypeScript expression. Allowed as element children and attribute values.

<h1>{title}</h1>
<a href={url}>{label}</a>
<p>{users.length === 0 ? "empty" : `${users.length} users`}</p>

For raw HTML output without escaping, use raw():

<div>{raw(post.bodyHtml)}</div>

Attributes

Attribute values follow normal HTML rules. String literals work. Expression blocks work.

<div class="card">
<div class={className}>
<input type="text" required>

class accepts an array. Falsy entries drop. The rest join with spaces. Useful for stringing together static and dynamic classes.

<div class={["card", "card-" + theme, active && "active"]}>

style accepts an object.

<div style={{ background: "#fff", "font-family": "courier" }}>

Identity shortcut: <Task {title}> is <Task title={title}>. Spread: <Task {...props}>.

Form Bindings

value, checked, and group create two-way bindings. Works on <input>, <textarea>, <select>, and contenteditable elements. group is the exception: it binds a set of checkbox or radio inputs to one variable, and does not apply to <select>, which binds with value.

<input value={form.name}>
<input type="checkbox" checked={form.subscribed}>
<input type="number" value={form.age}>
<textarea value={form.bio}/>

<select value={form.country}>
  <option value="us">us</option>
  <option value="ca">ca</option>
</select>

<label><input type="checkbox" group={form.contacts} value="email"> email</label>
<label><input type="checkbox" group={form.contacts} value="phone"> phone</label>

<div contenteditable value={form.bio}/>

contenteditable must use value=, not text content. <div contenteditable>{body}</div> renders once and stays static. <div contenteditable value={body}> is reactive.

Read and write data, not the DOM. Calling el.value = "..." or otherwise mutating a bound input fights the runtime.

File Inputs

<input type="file"> uses the same value= binding, typed as File[]. When the user picks files, the runtime reads their bytes and assigns the bound variable. Browsers can't programmatically set file inputs, so this binding is effectively one-way (from picker → variable); assigning [] clears the input.

import { File } from "@elements/app";

/** @rpc */
function upload(files: File[]) {
  for (const f of files) {
    sql`insert into uploads (name, content_type, data) values (${f.name}, ${f.contentType}, ${f.data})`;
  }
}
<html (private files: File[] = [])>
  <form onsubmit={() => upload(files)}>
    <input type="file" multiple value={files}>
    <button type="submit">upload</button>
  </form>
</html>

File.read() is asynchronous (the browser has to read bytes off disk). The form's submit handler automatically awaits any in-flight reads before running, so the files array is always populated by the time upload(files) is called. No state machine needed for the simple case.

File is a wire-format class (name, size, contentType, data: Uint8Array, lastModified) that serializes across @rpc. The data field maps directly to a Postgres bytea column.

Progress / preview UI: drop out of the binding into an explicit handler. When you need the file bytes available before submit (e.g. to show a thumbnail or a "reading…" spinner), use File.read() directly inside an onchange handler. The sync-style transform makes the state machine read top-to-bottom:

<html (private files: File[] = [], private state: "idle" | "reading" | "ready" | "uploading" | "done" = "idle")>
  <form onsubmit={() => { state = "uploading"; upload(files); state = "done"; }}>
    <input type="file" multiple onchange={(e) => {
      state = "reading";
      files = File.read(e.target);
      state = "ready";
    }}>

    <span e:if={state === "reading"}>reading…</span>
    <ul e:if={state === "ready" || state === "uploading"}>
      <li e:for={f of files}>{f.name} ({f.size} bytes)</li>
    </ul>
    <button type="submit" disabled={state === "reading" || state === "uploading"}>upload</button>
  </form>
</html>

The two patterns interop with the same File.read() primitive: the value= binding just calls it for you on change.