Guides

Using web component libraries

Write a library's tags in a template, such as <wa-switch>, and bind their properties and events as you would for any element. A web component library's tags are ordinary custom elements, so they need no wrapper and no F# bindings. This guide uses Web Awesome; the others work the same way.

A library built on web components ships custom elements: classes that register a tag with the browser. Once a tag is registered, the browser creates the library's element wherever the tag appears, in plain HTML or in a Lit template. A Firelight template is HTML, so the library works there without any glue.

Libraries

Library Built on A switch It reports changes with
Web Awesome Lit <wa-switch> change
Shoelace Lit <sl-switch> sl-change
Fluent UI FAST <fluent-switch> change
Carbon Lit <cds-toggle> cds-toggle-changed

Web Awesome is the successor to Shoelace, from the same team. The libraries differ in their tag prefixes, their event names and how they load their theme, and each documents its components' properties, attributes, events, slots, parts and custom properties. This guide shows how each of those maps to a Firelight template.

Loading and registering

Install the package, then import each component you use. The import runs the module that registers the tag, so it's an import for its side effect:

npm install @awesome.me/webawesome
open Fable.Core.JsInterop

importSideEffects "@awesome.me/webawesome/dist/components/switch/switch.js"

Import components in the module whose templates use them, so they're registered before those templates render. Your bundler only includes the components you import.

The other libraries register a tag the same way, with their own paths:

The components draw their colours, spacing and fonts from the library's design tokens, which are CSS custom properties. Load the library's stylesheet once, for the whole document. Custom properties inherit into shadow roots, so every component on the page, inside yours or not, picks the tokens up. With Vite, import the stylesheet from your entry module:

open Fable.Core.JsInterop

importSideEffects "@awesome.me/webawesome/dist/styles/themes/default.css"

That file holds the default theme's tokens. It also sets color, font-family and color-scheme on the root element, inside a cascade layer, so any rule of your own for those wins. The tokens are the light theme's; the wa-dark class switches an element and everything in it to the dark ones, so a dark mode puts it on <html>. dist/styles/webawesome.css adds utility classes and styles for native elements: it sets the page's background, the body's font, the margins of headings, paragraphs and lists, and more. Use it for a page built entirely with Web Awesome, and the theme alone for a page with styles of its own.

Until its module has run, a tag is an unknown element: the browser shows its text content without the library's styling. Hide such elements until they're registered with wa-switch:not(:defined) { visibility: hidden; } if the flash matters.

Properties and attributes

Bind a library element exactly as you would a native one. The rules in Binding values apply unchanged: an attribute for what you would write in HTML, a property for live state and for values that aren't strings.

The library's reference lists both names, and they don't always mean the same thing. On <wa-switch>, the checked attribute sets defaultChecked, the state the switch returns to when its form is reset. The checked property is whether it's on now. Native checkboxes work the same way, and so do Web Awesome's other form controls and Fluent UI's.

Both switches below follow the same F# value. The first binds the attribute, the second the property. Turn the first switch on, which also turns the second on, then press Turn both off:

module Snippets.LibrarySwitches

open Fable.Core
open Fable.Core.JsInterop
open Firelight
open type Firelight.Lit

// Registers <wa-switch>. Only the components you import are bundled.
importSideEffects "@awesome.me/webawesome/dist/components/switch/switch.js"

// Web Awesome's design tokens. An app loads them once for the whole document; this page doesn't,
// so the demo adopts them in its own shadow root, inside the wrapper with the theme's class.
[<ImportDefault("@awesome.me/webawesome/dist/styles/themes/default.css?inline")>]
let private themeCss: string = jsNative

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

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

    static member styles =
        [|
            unsafeCSS themeCss
            css
                $$"""
            .wa-theme-default {
                display: grid; gap: 0.75rem; justify-items: start;
                color: inherit; background: none; color-scheme: inherit;
            }
            /* The switch's colours come from this site's palette, which follows its theme
               button. An app that loads Web Awesome's theme for the whole document adds the
               wa-dark class to <html> for dark mode instead. */
            wa-switch {
                --width: 2.75rem;
                --wa-form-control-activated-color: var(--accent);
                --wa-form-control-background-color: var(--bg);
                --wa-form-control-border-color: var(--muted);
                --wa-color-surface-default: var(--accent-fg);
                --wa-form-control-label-color: currentColor;
                --wa-form-control-value-color: currentColor;
            }
            wa-switch::part(thumb) { box-shadow: 0 1px 3px rgb(0 0 0 / 0.35); }
            p { margin: 0; }
            button { font: inherit; padding: 0.3rem 0.6rem; }
            """
        |]

    member val on = false with get, set

    override this.render() =
        html
            $"""
        <div class="wa-theme-default">
            <wa-switch ?checked={this.on} @change={Ev.checked' (fun on -> this.on <- on)}>
                Bound to the attribute
            </wa-switch>
            <wa-switch .checked={this.on} @change={Ev.checked' (fun on -> this.on <- on)}>
                Bound to the property
            </wa-switch>
            <p>The F# value is <strong>{if this.on then "on" else "off"}</strong>.</p>
            <button @click={fun _ -> this.on <- false}>Turn both off</button>
        </div>"""

defineElement<SwitchBindings> "my-switch-bindings"

The second switch turns off. The first stays on: it now has no checked attribute, but once the user has toggled a switch, its default no longer changes what it shows. Bind .checked, as the second does, to control a switch from your state.

Some properties have no attribute at all, because their values can't be strings. Bind them with a dot. A slider's valueFormatter, for instance, is a function, written as an F# lambda:

open Firelight
open type Firelight.Lit

let volume (level: float) =
    html $"""<wa-slider label="Volume" .value={level} .valueFormatter={fun (v: float) -> string v + " %"}></wa-slider>"""

Events

A library's events are DOM events, so @name listens for them. Web Awesome's form controls raise the native change and input events, and its own events have a wa- prefix, such as wa-show and wa-after-hide on a dialog. Shoelace prefixes them all with sl-.

The element that raised a form control's event holds its new state, as with a native input. Ev.checked' and Ev.value read checked and value from the element the listener is on, so they work on library controls that have those properties:

open Firelight
open type Firelight.Lit

let notifications (on: bool) (setOn: bool -> unit) =
    html $"""<wa-switch .checked={on} @change={Ev.checked' setOn}>Notifications</wa-switch>"""

Ev.value types the value as a string, so use it only where the property is one. A <wa-slider>'s value is a number: Ev.valueAs<float> reads it as one, as in Typing elements.

Some events carry their data in detail. A dialog's wa-hide says which element closed it, in detail.source. Declare the detail's shape as an interface, and handle the event with Ev.custom<'T>:

open Browser.Types
open Firelight
open type Firelight.Lit

type HideDetail =
    abstract source: Element

let dialog (onHide: Element -> unit) =
    html
        $"""
    <wa-dialog label="Settings"
        @wa-hide={Ev.custom<HideDetail> (fun e -> e.detail |> Option.iter (fun d -> onHide d.source))}>
    </wa-dialog>"""

The Events guide covers handlers in full.

Styling

A library component renders inside its own shadow root, so your selectors can't reach its inner elements. It offers two ways in, custom properties and parts, and its reference lists both. The theme's design tokens are custom properties too, read by every component:

Hook Use it to Example
Custom properties Set values the component reads wa-switch { --width: 2.75rem; }
Design tokens Change the theme for everything inside an element --wa-form-control-activated-color: var(--accent);
Parts Style an inner element the component exposes wa-switch::part(thumb) { box-shadow: none; }

The demo above uses all three: a wider switch, the site's own colours (its ember for "on"), and a shadow on the thumb. Because its tokens come from this site's palette, the demo follows the site's theme button, light or dark. They go in your component's styles, or in the page's stylesheet for elements outside any component.

A part's rules apply only to the element the component marked with part. Selectors can't go further in: wa-switch::part(control) span isn't a valid selector, so the browser drops the rule.

Typing elements

To read or call members of a library element from F#, through a ref or an event's target, declare an interface with the members you use:

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

/// The members of <wa-dialog> this module uses.
[<AllowNullLiteral>]
type WaDialog =
    inherit HTMLElement
    abstract ``open``: bool with get, set

let dialog = createRef<WaDialog> ()

let showDialog () =
    dialog.value |> Option.iter (fun d -> d.``open`` <- true)

let view () =
    html $"""<wa-dialog {ref dialog} label="Saved">Your changes are saved.</wa-dialog>"""

// <wa-slider>'s value is a number.
let volume (level: float) (setLevel: float -> unit) =
    html
        $"""
    <wa-slider label="Volume" .value={level} @input={Ev.valueAs<float> setLevel}>
    </wa-slider>"""

Ev.valueAs<float> reads value from the element the listener is on, currentTarget, as Ev.value does, and hands it over as a float. It converts nothing: it's only right because the slider's value really is a number.

An interface over a JavaScript object costs nothing at run time: Fable reads and writes the members directly. Declare only what you use; the interface doesn't have to describe the whole element. It is optional, too. Templates don't need it, since a template's tags and bindings are text that only Lit reads, and F# doesn't check them against any type.

open is an F# keyword and checked is reserved, so members with those names need double backticks. A cast to an interface, such as e.target :?> WaDialog, isn't checked at run time: the element is whatever the event's target is.

Prerendering

Web Awesome supports Lit SSR, so its components can be rendered at build time along with yours. A few of its attributes matter only then, such as with-label on a component whose label goes in a slot: it tells the prerendered markup to include the label before the component hydrates. Check a library's documentation before prerendering its components, since being built on Lit doesn't make a component safe to render in Node.

The module that prerenders must load in Node, too. The demo on this page imports the theme's CSS file with ?inline, which Vite understands and Node doesn't, so this site's build loads its demos through Vite's SSR module runner, as vite dev does. A build that imports them with Node's own import() can't load such a module: there, keep CSS imports in the module that starts the app, out of the modules you prerender. The Prerendering guide covers what Lit SSR runs.

Common mistakes

The compiler catches some:

You wrote The compiler says Write instead
abstract checked: bool with get, set The identifier 'checked' is reserved for future use by F# abstract ``checked``: bool with get, set
@change={fun e -> setOn e.target...} The type of this expression could not be inferred before accessing its members @change={Ev.checked' setOn}

The rest compile, and fail quietly in the browser:

You wrote What happens Write instead
No import for a tag you use The tag renders as its plain text, with no error importSideEffects for each component
checked={on}, without ? or . false becomes the text "false", and the attribute's presence turns the default on .checked={on}
?checked={on} to control the switch It stops following on once the user has toggled it .checked={on}
@input={Ev.value setVolume} on <wa-slider> setVolume gets a number, though F# calls it a string Ev.valueAs<float> setVolume
No theme stylesheet Components lose their colours and spacing Load it once for the document
wa-switch .thumb { ... } Matches nothing inside the shadow root wa-switch::part(thumb)
A <form> around your component, and library controls inside its shadow root The controls aren't part of the form, and their values aren't submitted The <form> in the same template as its controls
The library loaded twice, from a CDN and from npm customElements.define throws: the tag is already defined One copy, from npm