Styling
Write a component's CSS in static member styles, with css $$"""...""". Lit applies it inside the component's shadow root, where it styles that component and nothing else, and the page's rules can't reach in. This guide covers the F# side, and the standard ways a page styles a component from outside.
A component's styles are CSS, written in an F# string. Lit turns each css value into one
stylesheet and adopts it into the shadow root of every instance, so a hundred instances share one
parsed sheet. The shadow root is a boundary: a button rule in the component styles only its own
buttons, and the page's button rule doesn't touch them. Get started shows it
running.
Component styles
open Fable.Core
open Fable.Core.JsInterop
open Firelight
open type Firelight.Lit
[<AttachMembers>]
type Card() =
inherit LitElement()
static member properties =
PropertyDeclarations.create [
"heading", PropertyDeclaration<string>()
"compact", PropertyDeclaration<bool>(reflect = true)
]
static member styles =
css
$$"""
:host { display: block; padding: 1rem; border: 1px solid #e8e1d8; border-radius: 0.5rem; }
:host([hidden]) { display: none; }
:host([compact]) { padding: 0.5rem; }
h3 { margin: 0 0 0.5rem; }
"""
member val heading = "" with get, set
member val compact = false with get, set
override this.render() =
html $"""<h3>{this.heading}</h3><slot></slot>"""
defineElement<Card> "my-card"
$$""" makes single braces plain CSS; the Templates guide
explains the delimiters. Lit reads styles once, when defineElement registers the class,
so styles are fixed for the class. What changes per element belongs in attributes, classes or
custom properties, below.
:host selects the element itself, from inside:
- A custom element is
display: inlineuntil you say otherwise, so give:hostadisplay. :host { display: block }beats the browser's own rule for thehiddenattribute, so<my-card hidden>stays visible.:host([hidden]) { display: none; }puts it back.:host([compact])and:host(.wide)style the element by its attributes and classes.compacthasreflect = true, so setting the property from F# adds the attribute too: see Reflect a property.- A page rule on the element, such as
my-card { padding: 0 }, beats the component's:hostrule. The component's:hoststyles are defaults; where it sits on the page is the page's call. The exception is!important: across the boundary, the component's!importantwins, here and for::slottedand::part.
<slot> shows the element's children, which stay in the page's DOM and take the page's styles.
::slotted(p) styles them from inside, but only the top-level children, and the page's rules
win.
What crosses the boundary
The shadow root keeps selectors out in both directions, but not everything stops there:
| From the page | Reaches inside? |
|---|---|
Inherited properties, such as color, font and line-height |
Yes, through the host element |
CSS custom properties, such as --accent |
Yes, like inherited properties |
Selectors, such as button or my-card .title |
No |
::part(name) rules |
Only for the elements the component names as parts |
So a component can expose two kinds of styling API: custom properties for values, and parts for whole elements.
Theming with custom properties
Read a value from a custom property, with a default for when the page sets none:
.bar { background: var(--progress-color, var(--accent, #c2410c)); }
The page, or any element above the component, sets --progress-color, and the component picks it
up. The chain falls back to a site-wide --accent, and then to a fixed colour. Name the component's
own properties after it, and list them where its users will look, since they are part of its API.
This site works the same way. site.css defines a palette on :root, with --accent, --border
and --muted, each with a light and a dark value: --accent: light-dark(#c2410c, #ff8a3d). The
theme button in the header picks one by switching the page's color-scheme, as in the
theme recipe. Every demo on these pages reads the palette, so the demos
follow the site's theme with no code of their own.
These three progress bars are one component, styled from the page's CSS, which is under the code.
A my-progress rule gives them their width. The second sets --progress-color in its style
attribute, and the third is restyled through its parts, covered next. Switch the theme with the
button in the header: the first and third follow this site's palette, and the second keeps its own
colour.
module Snippets.StylingParts
open Fable.Core
open Fable.Core.JsInterop
open Firelight
open type Firelight.Lit
/// <my-progress label="Upload" value="40"></my-progress>
/// Themed with --progress-color, and styled from outside through its label, track and bar parts.
[<AttachMembers>]
type Progress() =
inherit LitElement()
static member properties =
PropertyDeclarations.create [
"label", PropertyDeclaration<string>()
"value", PropertyDeclaration<float>()
]
static member styles =
css
$$"""
:host { display: block; }
:host([hidden]) { display: none; }
.label { font-size: 0.875rem; }
.track { height: 0.6rem; border-radius: 1rem; background: var(--border, #e8e1d8); overflow: hidden; }
.bar { height: 100%; background: var(--progress-color, var(--accent, #c2410c)); }
"""
member val label = "Progress" with get, set
member val value = 0.0 with get, set
override this.render() =
let width = StyleInfo.create [ "width", Some $"{this.value}%%" ]
html
$"""
<div class="label" part="label">{this.label}: {this.value}%%</div>
<div class="track" part="track" role="progressbar" aria-label={this.label}
aria-valuemin="0" aria-valuemax="100" aria-valuenow={this.value}>
<div class="bar" part="bar" style={styleMap width}></div>
</div>"""
defineElement<Progress> "my-progress"
Style parts from outside with ::part
part="bar" on an element in the template exposes it to the page as my-progress::part(bar). The
page's rule can set any property on that element, and it wins over the component's own rule for it.
It reaches only the part itself, not its children: ::part(track) .bar matches nothing.
A part inside a component that sits in another component's shadow DOM is two boundaries away, and
the page can't reach it. The outer component forwards it with exportparts, renaming it if it
likes:
open Fable.Core
open Firelight
open type Firelight.Lit
[<AttachMembers>]
type UploadPanel() =
inherit LitElement()
override _.render() =
html
$"""
<h3 part="heading">Uploads</h3>
<my-progress label="Photos" value="40" exportparts="bar: upload-bar"></my-progress>"""
defineElement<UploadPanel> "my-upload-panel"
The page then styles my-upload-panel::part(upload-bar). Parts it doesn't export stay out of
reach.
Use custom properties for values the page will want to change, such as colours and sizes, and parts when the page needs control of a whole element. Both are promises to whoever styles the component, so rename them with care.
Share styles between components
A css value is an ordinary F# value. Put shared rules in a module, and give styles an array:
open Fable.Core
open Firelight
open type Firelight.Lit
module SharedStyles =
let buttons =
css
$$"""
button { font: inherit; padding: 0.3rem 0.8rem; border-radius: 0.375rem; }
"""
[<AttachMembers>]
type Toolbar() =
inherit LitElement()
static member styles =
[| SharedStyles.buttons
css $$""":host { display: flex; gap: 0.5rem; }""" |]
override _.render() =
html $"""<button>Undo</button><button>Redo</button>"""
defineElement<Toolbar> "my-toolbar"
Lit makes one stylesheet from SharedStyles.buttons and adopts it in every component that lists
it. A css value can also go in another css value's hole, as in the
Templates guide.
An app-wide stylesheet, such as Tailwind's output, doesn't reach inside shadow roots from a
<link> in the page. Import it as text at build time and make it a css value:
open Fable.Core
open Firelight
open type Firelight.Lit
module AppStyles =
/// Our own build output, so unsafeCSS is safe here.
[<ImportDefault("./App.css?inline")>]
let private appCss: string = jsNative
let app = unsafeCSS appCss
[<AttachMembers>]
type Panel() =
inherit LitElement()
static member styles = [| AppStyles.app; css $$""":host { display: block; }""" |]
override _.render() = html $"""<div class="p-4 rounded-lg">...</div>"""
defineElement<Panel> "my-panel"
?inline is Vite's: it runs the file through the CSS build and gives the module the result as a
string. unsafeCSS wraps it as CSS without checking it, which is
right for your own build output and nothing else.
You can also give styles a native CSSStyleSheet made with replaceSync, as
Styling components in the
repository does, which also covers keeping @font-face rules in the page. An F# array holds one
type, so mix sheets and css values with cssResultGroup { sheet; css $$"""...""" }. A sheet
can't be made at module load in Lit SSR, which has no CSSStyleSheet, so prefer unsafeCSS in
components you prerender.
Values from F#: unsafeCSS
A hole in css takes only another css value or a number. That keeps arbitrary text out of a
stylesheet: a string in a hole throws when that css runs, and in a Debug build the message
names the hole. For text you trust, such as a
colour from your build's configuration, unsafeCSS turns a string into CSS:
open Firelight
open type Firelight.Lit
/// From our own build configuration, never from users.
let brandColor = "#d9480f"
let brand = css $$""":host { --brand: {{unsafeCSS brandColor}}; }"""
Never pass it text that someone else controls. CSS can't run script, but it can make requests:
a url(...) loads from any server, and an attribute selector such as input[value^="a"] can test
the values in the page's attributes, such as a pre-filled field, and report each guess to that
server, from your page. For values that come from users, or that change at run time, set a custom
property with styleMap instead:
open Firelight
open type Firelight.Lit
let progress (color: string) (value: float) =
let theme = StyleInfo.create [ "--progress-color", Some color ]
html $"""<my-progress value={value} style={styleMap theme}></my-progress>"""
styleMap sets one property's value, so the text can't add selectors or rules. Still check it
against what you expect, such as a colour, since a url(...) in a value makes a request too.
Change styles at run time
Styles are fixed for the class, but which rules apply isn't. Three ways to change the look of one element:
- Toggle a class or set an inline style from the template with
classMapandstyleMap: see Directives. - Reflect a property to an attribute and select it with
:host([...]): see Reflect a property. - Set a custom property, from the template or from the page.
Light DOM components
A component that inherits LightDomElement instead of LitElement renders into the element itself,
with no shadow root. The page's CSS styles it like any other markup, and ids, <label for> and
#anchor links work across it. In exchange there's no scoping: styles, :host, ::part and
<slot> don't apply. Use it for content that should look like the rest of the page, such as
rendered Markdown, and shadow DOM for widgets that should look the same everywhere.
Common mistakes
The compiler catches one:
| You wrote | The compiler says | Write instead |
|---|---|---|
[| sharedCss; sheet |] with a CSSStyleSheet |
All elements of an array must be implicitly convertible to the type of the first element, which here is 'CSSResult'. This element has type 'CSSStyleSheet'. | cssResultGroup { sharedCss; sheet } |
The rest compile, because styles is an ordinary static member and a css hole takes obj:
| You wrote | What happens | Write instead |
|---|---|---|
static member style |
No styles, and no error | static member styles |
static member styles = ":host { ... }" |
Throws: Failed to set the 'adoptedStyleSheets' property on 'ShadowRoot' | css $$""":host { ... }""" |
{{color}} in styles, with a string |
Throws "Value passed to 'css' function must be a 'css' function result" (in Debug, "css: the hole after … holds the string …"), and the element is never defined | unsafeCSS for trusted text, or a custom property |
:host { display: block; } alone |
hidden no longer hides the element |
Add :host([hidden]) { display: none; } |
Page CSS such as my-card h3 { } |
Matches nothing inside | A custom property or a part |
A <link> to the app's CSS in index.html |
Styles the page, not the components | A constructed sheet, as in Share styles |
A module mutable read in styles |
Read once, so later changes are ignored | A custom property, classMap or styleMap |