Guides

Templates

Firelight templates are plain HTML written inside F# interpolated strings. Passing them to Lit's html tag function enables efficient rendering and surgical DOM updates. The bindings are Lit's too, so what you know about HTML and Lit carries over. This guide covers the parts that are F#.

A template is the value html returns: a description of some DOM that Lit renders, then updates when the values in it change. A component returns one from render, and so can any function. Most of a user interface can be ordinary F# functions that take data and return templates. Only the parts that need their own state or lifecycle have to be components.

Templates are interpolated strings

open Firelight
open type Firelight.Lit

let greeting (name: string) =
    html $"""<p class="greeting">Hello, {name}.</p>"""

open type Firelight.Lit brings html, css, nothing and the directives into scope.

Lit's html is a JavaScript tagged template, which keeps a template's fixed text apart from the values in its holes, such as {name} above. An F# interpolated string passed to html is a FormattableString, which makes the same split, and Fable compiles it to a tagged template. Lit sees exactly what it would see from JavaScript: it parses each template once, and on later renders it updates only the values that changed.

The split also makes templates safe by default. Values are never pasted into the HTML. A string in a hole becomes text or an attribute value, so a <script> tag in someone's name is shown, not run. For the same reason you can't build a template with + or sprintf: html only accepts an interpolated string.

Lit doesn't parse the markup itself. The first time a template renders, Lit joins its fixed text with a marker in place of each hole and hands the result to the browser's own HTML parser, through a <template> element. Lit clones the parsed DOM wherever the template renders, and the markers tell it which nodes and attributes each hole updates. So a hole only works where the parser leaves a marker Lit can find. A hole works:

A hole doesn't work:

Where a hole goes also decides what it sets, which the next section covers.

A hole takes any F# expression: a property, a function call, an if, another template. Triple quotes let the HTML use " around attribute values.

In $""", a brace opens a hole, and a literal brace has to be doubled. CSS is full of braces, so styles use $$""" instead. With two dollar signs, single braces are plain text and a hole takes two: {{gap}}.

open Firelight
open type Firelight.Lit

let gap = css $"0.75rem"

let styles =
    css
        $$"""
    :host { display: flex; gap: {{gap}}; }
    button { font: inherit; }
    """

css only accepts other css values and numbers in its holes. svg $"""...""" works like html, for fragments that go inside an <svg> element.

Binding values

Where a hole sits decides what it sets. These are Lit's rules; F# only changes the delimiters, {value} where JavaScript has ${value}.

Binding Sets Example
{value} between tags Text, a template, a list of them, or nothing <p>Hello, {name}</p>
attr={value} An attribute, as a string <meter value={used}>
.prop={value} A DOM property, of any type <input .value={text}>
?attr={flag} A boolean attribute: present when true, removed when false <button ?disabled={busy}>
@event={handler} An event listener <button @click={fun _ -> save ()}>
<tag {directive}> An element directive, such as ref <input {ref field}>

Quotes around an attribute's value are optional. Use them to put fixed text next to a hole: class="bar {extra}".

This example uses each kind of binding except the last:

module Snippets.TemplateBindings

open Fable.Core
open Firelight
open type Firelight.Lit

[<AttachMembers>]
type NameField() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [ "name", PropertyDeclaration<string>(state = true) ]

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.75rem; justify-items: start; }
        input, button { font: inherit; padding: 0.3rem 0.6rem; }
        p { margin: 0; }
        """

    member val name = "" with get, set

    override this.render() =
        html
            $"""
        <label>
            Name
            <input maxlength="20" .value={this.name}
                @input={Ev.value (fun name -> this.name <- name)}>
        </label>
        <meter max="20" value={this.name.Length} aria-label="Characters used"></meter>
        <button ?disabled={this.name = ""} @click={fun _ -> this.name <- ""}>Clear</button>
        <p>Hello, {if this.name = "" then "whoever you are" else this.name}.</p>"""

defineElement<NameField> "my-name-field"

The text box binds .value, the property, rather than the value attribute. The attribute is only the box's starting value: once you've typed in it, changing the attribute doesn't change what it shows, so Clear would empty name and leave your text where it was. Bind attributes for what you would write in HTML, and properties for live state and for values that aren't strings, such as a list passed to a child component.

A lambda in a hole is a new function on every render. That's fine: Lit keeps one listener on the element and calls the newest function, so a handler always sees the current values. For listener options such as passive or once, bind a LitEventListener(handler, passive = true) instead of a function.

Typed event handlers

A hole's type is obj, so F# can't tell what e is in @keydown={fun e -> ...}, and you would annotate it: fun (e: KeyboardEvent) -> .... The Ev module gives the type instead. Ev.keyboard (fun e -> ...) makes e a KeyboardEvent, with completion for e.key. It returns the handler it's given, so the JavaScript is just the lambda.

open Firelight
open type Firelight.Lit

let search (query: string) (setQuery: string -> unit) =
    html
        $"""
    <input type="search" .value={query}
        @input={Ev.value setQuery}
        @keydown={Ev.keyboard (fun e -> if e.key = "Escape" then setQuery "")}>"""

Ev.value hands your function the field's new value, and Ev.checked' a checkbox's checked (with a quote, as checked is reserved in F#). Both read the element the binding is on, so they replace the (e.target :?> HTMLInputElement).value downcast for an <input>, <select> or <textarea>. There are also Ev.mouse, Ev.pointer, Ev.focus, Ev.input, Ev.wheel, Ev.drag, Ev.touch, Ev.submit, and Ev.event for any other event.

Ev.custom<'T> types a CustomEvent whose detail is a 'T, such as one a component raises with Event.customEvent. Its detail is a 'T option. Here a text box raises tag-added when you press Enter, and the list adds the tag:

module Snippets.TemplateEvents

open Fable.Core
open Browser.Types
open Firelight
open type Firelight.Lit

/// A text box that raises `tag-added`, with the tag as its detail, when Enter is pressed.
[<AttachMembers>]
type TagInput() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [ "text", PropertyDeclaration<string>(state = true) ]

    static member styles = css $$"""input { font: inherit; padding: 0.3rem 0.6rem; }"""

    member val text = "" with get, set

    member this.Add() =
        let tag = this.text.Trim()

        if tag <> "" then
            this.dispatch (Event.customEvent ("tag-added", tag))
            this.text <- ""

    override this.render() =
        // Ev.keyboard makes `e` a KeyboardEvent, so `e.key` needs no annotation.
        let addOnEnter =
            Ev.keyboard (fun e ->
                if e.key = "Enter" then
                    this.Add()
            )

        html
            $"""
        <input aria-label="New tag" placeholder="Add a tag" .value={this.text}
            @input={Ev.value (fun text -> this.text <- text)} @keydown={addOnEnter}>"""

[<AttachMembers>]
type TagList() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [ "tags", PropertyDeclaration<string list>(state = true) ]

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.75rem; justify-items: start; }
        ul { margin: 0; padding-left: 1.25rem; }
        """

    member val tags = [ "fsharp"; "lit" ] with get, set

    override this.render() =
        // The tag-added event's detail is the tag: a string option, as an event may have no detail.
        let addTag =
            Ev.custom<string>(fun e -> e.detail |> Option.iter (fun tag -> this.tags <- this.tags @ [ tag ]))

        html
            $"""
        <my-tag-input @tag-added={addTag}></my-tag-input>
        <ul>{this.tags |> List.map (fun tag -> html $"<li>{tag}</li>")}</ul>"""

defineElement<TagInput> "my-tag-input"
defineElement<TagList> "my-tag-list"

Nothing checks that the function matches the event: @click={Ev.keyboard (fun e -> ...)} compiles, because the event's name is fixed text that only Lit reads. Nor does anything check that a custom event's detail is the type you name.

Conditionals and lists

Templates have no syntax of their own for conditions or loops. if, match and the list functions already do the job, and a template is a value like any other.

module Snippets.TemplateLists

open Fable.Core
open Firelight
open type Firelight.Lit

// A template is a function: it takes what it shows and returns html.
let private itemView (remove: int -> unit) (index: int) (fruit: string) =
    html $"""<li>{fruit} <button @click={fun _ -> remove index}>Remove</button></li>"""

[<AttachMembers>]
type Basket() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [ "fruits", PropertyDeclaration<string list>(state = true) ]

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.75rem; justify-items: start; }
        button { font: inherit; padding: 0.2rem 0.6rem; }
        ul { margin: 0; padding-left: 1.25rem; }
        p { margin: 0; }
        """

    member val fruits = [ "Apple"; "Pear" ] with get, set

    member this.Remove(index: int) =
        this.fruits <- List.removeAt index this.fruits

    member this.Add(fruit: string) = this.fruits <- this.fruits @ [ fruit ]

    override this.render() =
        let contents =
            match this.fruits with
            | [] -> html $"<p>The basket is empty.</p>"
            | fruits -> html $"<ul>{fruits |> List.mapi (itemView this.Remove)}</ul>"

        let emptyButton =
            if not this.fruits.IsEmpty then
                html $"""<button @click={fun _ -> this.fruits <- []}>Empty the basket</button>"""
            else
                nothing

        html
            $"""
        <div>
            <button @click={fun _ -> this.Add "Apple"}>Add an apple</button>
            <button @click={fun _ -> this.Add "Pear"}>Add a pear</button>
        </div>
        {contents}
        {emptyButton}"""

defineElement<Basket> "my-basket"

Each branch of the match returns a template. nothing is Lit's marker for "render nothing here", and it fits wherever a template does, so emptyButton needs no type annotation.

A hole renders any F# list, array or seq, so List.map is all a list needs. A list expression such as [ for fruit in fruits -> html $"<li>{fruit}</li>" ] works too.

Keyed lists with repeat

Lit updates a list by position. The first item's template updates the first element, and so on; only the end of the list grows or shrinks. That's fast, and right whenever an element shows nothing but your data. It goes wrong when an element holds state of its own, such as text typed into an unbound box, focus or a running animation. Remove the first item, and each element is reused for the item that came after it, while its state stays where it was.

repeat gives each item a key and moves its element with it. Type a note next to Bob in both lists, then remove the first person:

module Snippets.TemplateRepeat

open Fable.Core
open Firelight
open type Firelight.Lit

let private everyone = [ "Ann"; "Bob"; "Cara" ]

// The note box isn't bound to anything: what you type lives only in the DOM.
let private row (name: string) =
    html $"""<li><label>{name} <input size="8"></label></li>"""

[<AttachMembers>]
type RepeatComparison() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [ "people", PropertyDeclaration<string list>(state = true) ]

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 1rem; }
        .lists { display: flex; flex-wrap: wrap; gap: 2rem; }
        p { margin: 0 0 0.4rem; font-weight: 600; }
        ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.4rem; }
        button, input { font: inherit; }
        """

    member val people = everyone with get, set

    override this.render() =
        html
            $"""
        <div>
            <button ?disabled={this.people.IsEmpty} @click={fun _ -> this.people <- List.tail this.people}>
                Remove the first person
            </button>
            <button @click={fun _ -> this.people <- everyone}>Reset</button>
        </div>
        <div class="lists">
            <div>
                <p>List.map</p>
                <ul>{this.people |> List.map row}</ul>
            </div>
            <div>
                <p>repeat</p>
                <ul>{repeat (this.people, (fun name _ -> name), (fun name _ -> row name))}</ul>
            </div>
        </div>"""

defineElement<RepeatComparison> "my-repeat-comparison"

Use repeat when items are inserted, removed or reordered and their elements hold state, or are components. Otherwise List.map is simpler and does less work. The key must be unique and stay with the item, like an id. An item's position isn't a key.

repeat takes its key and template functions in two forms: of the item and its index, fun item index -> ..., as in Lit, or of the item alone. A method fits either form if its arguments match: repeat (people, (fun p -> p.Id), this.PersonView) for member this.PersonView(p: Person). A method that takes the item and the index as a tuple, member this.Row(p, i), doesn't convert to either; wrap it: fun p i -> this.Row(p, i).

Directives

Directives are functions that take over how a binding is applied. They are static members of Lit, so open type Firelight.Lit brings them into scope, and Fable imports only the ones you use. These are the ones you'll reach for most:

Directive Use it to Example
classMap Switch classes on and off class={classMap (ClassInfo.create [ "done", isDone ])}
styleMap Set inline styles; None removes one style={styleMap (StyleInfo.create [ "width", Some "40%" ])}
ref Reach the rendered element <input {ref field}> with let field = createRef<HTMLInputElement> ()
ifDefined Leave out an attribute when its value is None href={ifDefined link}
live Compare with the element's current value, not the last one rendered .value={live text}
repeat Key a list, so elements move with their items Keyed lists
keyed Replace an element, rather than update it, when a key changes keyed (user.Id, profile user)
guard Skip re-rendering until a dependency changes guard ([| rows; sortColumn |], fun () -> table rows sortColumn)
cache Keep the DOM of templates you switch between, such as tabs cache (tabView tab)
unsafeHTML Render a trusted string as HTML unsafeHTML trustedHtml

Firelight also binds join, until, asyncAppend, asyncReplace, templateContent and unsafeSVG, and Lit's when, choose, map and range, with when as when' because when is an F# keyword. In F#, if, match, List.map and [ 0 .. n - 1 ] do the same jobs as those four and read better. For data that loads asynchronously, Firelight.Task is usually a better fit than until.

This volume control uses the three most common: classMap stripes the bar when it's loud, styleMap sets its width, and ref lets Reset put the focus back on the slider.

module Snippets.TemplateDirectives

open Fable.Core
open Browser.Types
open Firelight
open type Firelight.Lit

[<AttachMembers>]
type VolumeControl() =
    inherit LitElement()

    let slider = createRef<HTMLInputElement> ()

    static member properties =
        PropertyDeclarations.create [ "level", PropertyDeclaration<int>(state = true) ]

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.75rem; justify-items: start; }
        .track { width: 14rem; height: 0.75rem; border: 1px solid var(--border); border-radius: 1rem; overflow: hidden; }
        .bar { height: 100%; background: var(--accent); }
        .bar.loud { background: repeating-linear-gradient(45deg, var(--accent) 0 6px, var(--fg) 6px 12px); }
        button { font: inherit; padding: 0.2rem 0.6rem; }
        """

    member val level = 50 with get, set

    member this.Reset() =
        this.level <- 50
        // The ref's value is the rendered <input>, once there is one.
        slider.value |> Option.iter (fun input -> input.focus ())

    override this.render() =
        let bar = ClassInfo.create [ "loud", this.level > 80 ]
        let width = StyleInfo.create [ "width", Some(string this.level + "%") ]

        html
            $"""
        <label>
            Volume
            <input type="range" min="0" max="100" {ref slider} .value={string this.level}
                @input={Ev.value (fun level -> this.level <- int level)}>
        </label>
        <div class="track"><div class="bar {classMap bar}" style={styleMap width}></div></div>
        <button @click={fun _ -> this.Reset()}>Reset</button>"""

defineElement<VolumeControl> "my-volume-control"

classMap and styleMap must be the only hole in their attribute, though fixed text can sit beside them, as bar does. Each classMap key is a single class name. A key with a space in it, such as "bg-red text-white", renders the first time, then throws once its value changes.

A ref's value is None until the element has rendered, so read it in event handlers or after an update, not in render. Use it for what a template can't say: focus, measuring, or calling a method such as a dialog's showModal.

An F# option in an attribute hole doesn't remove the attribute. None becomes JavaScript undefined, which Lit writes as an empty attribute. ifDefined removes it instead:

open Firelight
open type Firelight.Lit

let link (label: string) (href: string option) =
    html $"""<a href={ifDefined href}>{label}</a>"""

live is for a property the user can change. Lit only sets a binding when its value differs from the last render. If the user has typed into a box but your state still holds the value you last rendered, rendering it again sets nothing, and the typing stays. live compares with what the box holds now.

A directive that fills an element's content, such as keyed, cache, repeat, guard or until, returns a ChildDirectiveResult, a kind of ChildRenderable. So render, or a template function, can return one without a template around it:

open Fable.Core
open Firelight
open type Firelight.Lit

type Profile = { Id: int; Name: string }

[<AttachMembers>]
type ProfileEditor() =
    inherit LitElement()

    member val profile: Profile option = None with get, set

    override this.render() =
        match this.profile with
        | None -> nothing
        // A new id gets a new input, so nothing typed for the last profile carries over.
        | Some p -> keyed (p.Id, html $"""<input aria-label="Name" value={p.Name}>""")

The directives that only work inside a tag, ref, classMap, styleMap and live, return a plain DirectiveResult, which render doesn't accept.

unsafeHTML

Some HTML arrives as a string, such as Markdown converted to HTML at build time. unsafeHTML renders it as HTML rather than as text.

open Firelight
open type Firelight.Lit

/// `articleHtml` comes from our own Markdown build, never from users.
let article (articleHtml: string) =
    html $"""<article>{unsafeHTML articleHtml}</article>"""

Never pass it text that someone else controls. The string becomes part of your page, so markup such as <img src=x onerror="..."> runs that person's script with your page's permissions. If the HTML comes from users, clean it with a sanitizer built for the job, such as DOMPurify, or show it as text in an ordinary hole.

Static values

Because the browser parses a template's fixed text, a tag name or an attribute name has to be part of that text, not a hole. When one really must vary, such as the heading level of a card that appears at different depths, use StaticHTML:

open Firelight
open type Firelight.Lit

type Level =
    | H2
    | H3

let card (level: Level) (title: string) (body: string) =
    let heading =
        match level with
        | H2 -> StaticHTML.literal $"h2"
        | H3 -> StaticHTML.literal $"h3"

    StaticHTML.html $"""<article><{heading}>{title}</{heading}><p>{body}</p></article>"""

StaticHTML.html is the html from lit/static-html.js; the plain html doesn't accept static values. It returns an HTMLTemplateResult, as html does, so the two mix freely. Call it by its full name, since open type StaticHTML would hide Lit's html.

Static values are part of the template, so each different value makes a separate template. When the value changes, Lit throws away the old DOM and builds new DOM, rather than updating it. With only a few cases, a match that returns a whole template for each does the same job without static values.

Choose StaticHTML.literal values from a fixed set, as card does. StaticHTML.unsafeStatic turns any string into template text, so, like unsafeHTML, it must never see untrusted input.

Common mistakes

The compiler catches some template mistakes, though its message doesn't always point at the cause:

You wrote The compiler says Write instead
html """<p>Hi</p>""" The type 'string' is not compatible with the type 'System.FormattableString' html $"""<p>Hi</p>"""
css $"""p { margin: 0; }""" Unexpected symbol ';' in expression, or another parse error css $$"""p { margin: 0; }"""
html $"""<p style="width: 50%">""" Invalid interpolated string. Bad format specifier 50%% in $""". In $$""", % is plain text
if empty then nothing else html $"..." All branches of an 'if' expression must return values implicitly convertible to the type of the first branch, which here is 'nothing' The template first: if not empty then html $"..." else nothing
ref 0 No overloads match for method 'ref' Operators.ref 0: Lit's ref hides F#'s

All branches of an if or match must fit one type, which F# takes from the first branch. nothing fits where a template is expected, so a template first needs nothing extra. When nothing comes first, annotate the expression as ChildRenderable (the interface for anything Lit can render):

let button: ChildRenderable =
    if isEmpty then nothing else html $"<button>...</button>"

Others compile, because a hole's type is obj and it accepts any value:

You wrote What happens Write instead
@click={this.Save()} Save runs on every render, and clicking does nothing @click={fun _ -> this.Save()}
@click={fun (e: KeyboardEvent) -> ...} The handler gets a click event, and e.key is undefined The event's real type
{price:N2} Debug builds, such as npm run dev, throw. Release builds drop the format {price.ToString "N2"}
{name} in $$""" Shows the text {name} {{name}}
title={maybeTitle} with an option None leaves an empty title attribute title={ifDefined maybeTitle}
this.items.Add item, on a ResizeArray No re-render An F# list and this.items <- this.items @ [ item ]

The last row needs a word. Lit re-renders when a reactive property gets a new value, and it decides by comparing the old and new values by identity. F# lists, records and maps are immutable, so a change always produces a new value and Lit always notices. A ResizeArray, an array or a mutable field changed in place is still the same object, so nothing happens. Assign a new value, or call this.requestUpdate() after the change.

The same goes for a mutable local in a template function. The function has already returned its template, and changing the variable later doesn't call it again. State that affects what's shown belongs in a reactive property or an Elmish model.