Get started

Your first component

From an empty folder to a web component written in F# and running in your browser. Then four small changes to it: an attribute, an event, styles and a list.

A Firelight component is a standard custom element. You write it in F#, the Fable compiler turns it into JavaScript, and Lit runs it in the browser. A dotnet new template sets all of that up.

What you need

Nothing runs on .NET in the finished app. What you ship is HTML and JavaScript.

Create an app

Install the template, then create an app called MyApp from it:

dotnet new install Firelight.Templates
dotnet new firelight -n MyApp
cd MyApp

Then install the Fable compiler and the npm packages, and start the dev server:

dotnet tool restore
npm install
npm run dev

npm run dev compiles your F# to JavaScript, then starts Vite, which prints a local address (http://localhost:5173/ unless that port is taken). Open it: you'll see the heading "MyApp" and a button that counts your clicks. Leave it running. When you save an F# file, Fable recompiles it and the page reloads.

What's in it

File What it's for
App.fs The component, in F#.
MyApp.fsproj The F# project: its source files, and the Firelight package. Fable reads it to know what to compile.
index.html The page. It uses the component as <click-counter> and loads build/App.js, which Fable compiles from App.fs.
package.json The npm side: Lit, Vite, and the dev, build and preview scripts.
vite.config.js Settings for Vite, which serves the page while you work and bundles it for production.
.config/dotnet-tools.json The version of the Fable compiler, which dotnet tool restore installs.

There's also a README.md and a .gitignore. Fable writes its JavaScript to build/, and the production build goes to dist/.

The component

App.fs defines one component: a button that counts clicks. Here it is, running.

module MyApp.App

open Fable.Core
open Firelight
open type Firelight.Lit

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

    // Changing a reactive property re-renders the component.
    static member properties = PropertyDeclarations.create [ "count", PropertyDeclaration<int>() ]

    // Styles live in the component's shadow DOM, so they apply only to this component.
    static member styles =
        css
            $$"""
        button { font: inherit; padding: 0.5rem 1rem; border-radius: 0.5rem; cursor: pointer; }
        """

    member val count = 0 with get, set

    override this.render() =
        html
            $"""
        <button @click={fun _ -> this.count <- this.count + 1}>
            Clicked {this.count} times
        </button>"""

defineElement<ClickCounter> "click-counter"

From the top:

css strings start with $$""", so that single braces are CSS; to insert an F# value there, write {{value}}. html strings start with $""", so {...} inserts a value.

The steps below each change App.fs and show the whole file, running. Two lines differ from yours: all the steps are compiled together and run on this one page, so each needs its own module name (the first line) and tag (the last). Keep module MyApp.App and "click-counter" in your copy.

Set it from an attribute

HTML elements are configured with attributes, and components can be too. Lit links each reactive property to an attribute of the same name, so a label property can be set from the page. Add it to properties, give it a default with member val, and show it in the template:

module MyApp.Label

open Fable.Core
open Firelight
open type Firelight.Lit

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

    // Changing a reactive property re-renders the component.
    static member properties =
        PropertyDeclarations.create [
            "count", PropertyDeclaration<int>()
            "label", PropertyDeclaration<string>()
        ]

    // Styles live in the component's shadow DOM, so they apply only to this component.
    static member styles =
        css
            $$"""
        button { font: inherit; padding: 0.5rem 1rem; border-radius: 0.5rem; cursor: pointer; }
        """

    member val count = 0 with get, set
    member val label = "Clicked" with get, set

    override this.render() =
        html
            $"""
        <button @click={fun _ -> this.count <- this.count + 1}>
            {this.label} {this.count} times
        </button>"""

defineElement<ClickCounter> "click-counter-label"

Then set it in index.html:

<click-counter label="Liked"></click-counter>

Without the attribute, label keeps its default, as in the second counter above. Each element has its own state, so the two count separately.

Attribute values are text. For a number or a Boolean, declare the property's type so Lit converts the attribute, as the star rating does for its value.

Tell the page when it changes

Properties and attributes carry data into a component. To send data out, a component raises a DOM event, just as built-in elements do. Anything on the page can listen, with no reference to your F# code. Here, each click raises a count-changed event with the new count as its detail:

module MyApp.Events

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

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

    // Changing a reactive property re-renders the component.
    static member properties =
        PropertyDeclarations.create [
            "count", PropertyDeclaration<int>()
            "label", PropertyDeclaration<string>()
        ]

    // Styles live in the component's shadow DOM, so they apply only to this component.
    static member styles =
        css
            $$"""
        button { font: inherit; padding: 0.5rem 1rem; border-radius: 0.5rem; cursor: pointer; }
        """

    member val count = 0 with get, set
    member val label = "Clicked" with get, set

    member this.Increment() =
        this.count <- this.count + 1
        // The event bubbles out of the shadow DOM, so the page can listen for it.
        this.dispatch (Event.customEvent ("count-changed", this.count))

    override this.render() =
        html
            $"""
        <button @click={fun _ -> this.Increment()}>
            {this.label} {this.count} times
        </button>"""

defineElement<ClickCounter> "click-counter-events"

The page hasn't heard from the counter yet.

The click handler now calls Increment, which updates count and then dispatches the event. Event.customEvent (from open Browser.Types) creates an event that bubbles and crosses the shadow DOM boundary, so listeners outside the component hear it.

The message under the counter comes from plain JavaScript on this page. To do the same, add this to index.html, below the counter:

<p id="message">The page hasn't heard from the counter yet.</p>
<script type="module">
  const message = document.getElementById("message");
  document.querySelector("click-counter").addEventListener("count-changed", (e) => {
    message.textContent = `The page heard count-changed: ${e.detail}.`;
  });
</script>

An F# component that renders the counter listens in its template, with an @count-changed binding. Ev.custom<int> tells F# that the event's detail is an int; it arrives as an int option, since an event may have none:

html $"""<click-counter @count-changed={Ev.custom<int> (fun e -> this.heard <- e.detail)}></click-counter>"""

Style it

A component's styles apply inside its shadow DOM, and only there. Its rules don't reach the rest of the page, and the page's rules don't reach in, so the component looks the same wherever it's used. Inherited properties such as font and color do pass through, and so do CSS custom properties, which is how a page can theme a component.

This version gives the button a colour, read from a --counter-color custom property with rebeccapurple as the default:

module MyApp.Styles

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

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

    // Changing a reactive property re-renders the component.
    static member properties =
        PropertyDeclarations.create [
            "count", PropertyDeclaration<int>()
            "label", PropertyDeclaration<string>()
        ]

    // Styles live in the component's shadow DOM, so they apply only to this component.
    static member styles =
        css
            $$"""
        button {
            font: inherit; padding: 0.5rem 1rem; border: none; border-radius: 2rem; cursor: pointer;
            color: white; background: var(--counter-color, rebeccapurple);
        }
        """

    member val count = 0 with get, set
    member val label = "Clicked" with get, set

    member this.Increment() =
        this.count <- this.count + 1
        // The event bubbles out of the shadow DOM, so the page can listen for it.
        this.dispatch (Event.customEvent ("count-changed", this.count))

    override this.render() =
        html
            $"""
        <button @click={fun _ -> this.Increment()}>
            {this.label} {this.count} times
        </button>"""

defineElement<ClickCounter> "click-counter-styles"

The component's button rule styles only its own button. The third button belongs to the page, so the rule doesn't touch it. The second counter sets --counter-color in its style attribute, and that does reach the button inside. Try both in index.html:

<click-counter label="Starred" style="--counter-color: #0b6e4f"></click-counter>
<button>A button on the page</button>

Render a list

A template can contain a list of templates, so repeated markup comes from F# data and the list functions you already use. Here, a row of buttons comes from a list of step sizes:

module MyApp.List

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

let steps = [ 1; 10; 100 ]

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

    // Changing a reactive property re-renders the component.
    static member properties =
        PropertyDeclarations.create [
            "count", PropertyDeclaration<int>()
            "label", PropertyDeclaration<string>()
        ]

    // Styles live in the component's shadow DOM, so they apply only to this component.
    static member styles =
        css
            $$"""
        :host { display: inline-flex; align-items: center; gap: 0.5rem; }
        button {
            font: inherit; padding: 0.5rem 1rem; border: none; border-radius: 2rem; cursor: pointer;
            color: white; background: var(--counter-color, rebeccapurple);
        }
        """

    member val count = 0 with get, set
    member val label = "Clicked" with get, set

    member this.Add(step: int) =
        this.count <- this.count + step
        // The event bubbles out of the shadow DOM, so the page can listen for it.
        this.dispatch (Event.customEvent ("count-changed", this.count))

    override this.render() =
        let stepButton step =
            html $"""<button @click={fun _ -> this.Add step}>+{step}</button>"""

        html
            $"""
        <span>{this.label} {this.count} times</span>
        {steps |> List.map stepButton}"""

defineElement<ClickCounter> "click-counter-list"

steps is a plain F# list. stepButton makes a button template for one step, List.map turns the list of steps into a list of buttons, and Lit renders them in order. Increment becomes Add, which takes the step. The new :host rule styles the <click-counter> element itself, laying out the label and buttons in a row.

For a list whose items are added, removed or reordered, Lit's repeat directive keeps each item's DOM with it.

Build for production

npm run build

This compiles App.fs and bundles the app into dist/: static files you can host anywhere. npm run preview serves them locally, to check the build before you publish it.

Where next