---
title: "A card with slots"
description: "A Firelight card component that takes its heading, body and footer from the page through named slots, with fallback content, ::slotted styles, and a footer that hides itself when it's empty."
---

# A card with slots

Build a card that takes its heading, body and footer from the page through named slots, and hides its footer when there's nothing in it.

- [Lit docs: Slots](https://lit.dev/docs/components/shadow-dom/#slots)

The first card fills all three slots. The second gives only a body: its heading falls back to
"Untitled", and its footer is hidden.

```fsharp
module Snippets.SlotCard

open Fable.Core
open Firelight
open type Firelight.Lit

/// A card with a heading, a body and an optional footer, all supplied by the page.
[<AttachMembers>]
type SlotCard() =
    inherit LitElement()

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

    static member styles =
        css
            $$"""
        :host { display: block; align-self: start; width: 16rem; border: 1px solid var(--border);
                border-radius: var(--radius); background: var(--bg); }
        header, .body, footer { padding: 0.75rem 1rem; }
        /* The heading looks the same from the slot as from the fallback: the slotted one inherits. */
        header { border-bottom: 1px solid var(--border); font-size: 1.1rem; font-weight: 600; }
        ::slotted([slot="heading"]) { margin: 0; font: inherit; }
        /* The body's elements lose their own margins; the grid's gap spaces them instead. */
        .body { display: grid; gap: 0.5rem; }
        .body ::slotted(*) { margin: 0; }
        footer { display: flex; gap: 0.5rem; justify-content: end; border-top: 1px solid var(--border); }
        footer[hidden] { display: none; }
        ::slotted(button) { font: inherit; color: var(--fg); background: var(--surface); padding: 0.3rem 0.9rem;
                            border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; }
        ::slotted(button:hover) { border-color: var(--muted); }
        ::slotted(button:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
        """

    member val hasFooter = false with get, set

    member this.CheckFooter(slot: HTMLSlotElement) =
        this.hasFooter <- slot.assignedElements().Length > 0

    // slotchange reports changes, but a prerendered card's elements were in their slots before
    // this code ran, so check once as well. After updateComplete, not in firstUpdated itself:
    // setting hasFooter during an update schedules another, which Lit warns about.
    override this.firstUpdated _ =
        promise {
            let! _ = this.updateComplete
            this.query<HTMLSlotElement> "slot[name=footer]" |> Option.iter this.CheckFooter
        }
        |> Promise.start

    override this.render() =
        html
            $"""
        <header><slot name="heading">Untitled</slot></header>
        <div class="body"><slot></slot></div>
        <footer ?hidden={not this.hasFooter}>
            <slot name="footer" @slotchange={Ev.slot this.CheckFooter}></slot>
        </footer>"""

defineElement<SlotCard> "my-slot-card"
```

```html
<my-slot-card>
  <h3 slot="heading">Weekly report</h3>
  <p>Sales are up 4% on last week.</p>
  <button slot="footer">Open</button>
</my-slot-card>
<my-slot-card>
  <p>No heading and no footer.</p>
</my-slot-card>
```

## How it works

A `<slot>` in a component's template shows the elements the page put inside the component's tag.
A child with `slot="heading"` goes to `<slot name="heading">`, and the rest go to the slot with no
name. The children stay in the page's DOM: the page's styles and listeners apply to them, and the
page picks the elements, such as which heading level fits where the card sits.

- **Fallback content.** What's inside a `<slot>` shows when nothing is assigned to it, as
  "Untitled" does.
- **Styling slotted elements.** `::slotted(...)` styles the page's elements from inside the
  component. The slotted heading takes `font: inherit`, so it looks the same as the "Untitled"
  fallback, which the header styles; `.body ::slotted(*)` removes the body elements' margins,
  and the body's grid gap spaces them instead; and `::slotted(button)` gives the footer's button
  a border and a background. These rules reach only the top-level children, and the page's own
  rules win.
- **The page's colours.** Custom properties such as `--border` and `--surface` inherit into the
  shadow root, so the card and its button follow the page's light and dark themes.
- **An empty footer.** A slot can't hide the element around it, so the component checks the slot
  with `assignedElements` and sets `hasFooter`. `?hidden` hides the footer, border and all,
  while it's empty.

`Ev.slot` hands `CheckFooter` the `<slot>` that `@slotchange` is bound on, as an
`HTMLSlotElement`, so `assignedElements` needs no cast. `slotchange` reports changes, but in a
prerendered card the page's elements were in their slots before the component's code ran, and no
event reports them. So `firstUpdated` checks once as well, finding the same slot with
`this.query<HTMLSlotElement> "slot[name=footer]"`. It waits for `updateComplete` in a
`promise { }` first: setting `hasFooter` during the first update would schedule a second one
straight away, which Lit warns about.

The cards are [prerendered](https://roboz0r.github.io/Firelight/guides/prerendering/), and the page's elements are already in the
page, so the first card shows its heading and body before any JavaScript runs. Its footer appears
only once `firstUpdated` has looked, because `render` at build time can't see which slots the page
fills, so the card grows and moves what's below it. CSS can't make that check yet: the
`:has-slotted` pseudo-class would hide an empty footer without script, but only Firefox supports
it. If most of your cards have a footer, start `hasFooter` as `true`: the usual card then doesn't
move, and a card without a footer shrinks instead.

## Related

- [Styling: Component styles](https://roboz0r.github.io/Firelight/guides/styling/#component-styles), for `::slotted` and `:host`.
- [From Lit: @queryAssignedElements](https://roboz0r.github.io/Firelight/from-lit/decorators/#queryassignedelements), for counting
  slotted elements.
