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
- The .NET 10 SDK or later, for F# and the Fable compiler.
- Node.js 22.12 or later (20.19 or later on Node 20), for Lit and the Vite dev server.
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:
ClickCounterinheritsLitElement, Lit's base class for components.[<AttachMembers>]tells Fable to compile its members onto the JavaScript class, where Lit looks for them.propertiesdeclarescountas a reactive property: setting it re-renders the component.member val count = 0holds its value, starting at 0.stylesis the component's CSS. There's more on styles below.renderreturns the component's HTML, written as an F# interpolated string.{this.count}inserts a value, and@click={...}makes an F# function the click handler. Whencountchanges, Lit updates only the text that shows it.defineElementregisters the class as the<click-counter>element, which is howindex.htmlcan use it.
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
- The Templates guide: the F# side of the
htmlyou've been writing. - Firelight, the core package, and the other packages: Elmish, context, signals, routing and more, each with a live example.
- The demos: a todo app, a Kanban board and client-side routing.
- The GettingStarted sample on GitHub covers each core concept one module at a time: rendering, properties, events, styles, controllers, context and Elmish.
- Lit's documentation. What it says about components, templates and styles applies to Firelight too.