---
title: "Infinite scroll"
description: "A Firelight list that loads its next page when its end scrolls into view, with an IntersectionController from Firelight.Observers, and a Load more button for keyboards and screen readers."
---

# Infinite scroll

Build a list that loads its next page when its end scrolls into view, with a Load more button that does the same for anyone who doesn't scroll.

- [MDN: Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)

Scroll to the bottom of the box, with the mouse or the arrow keys, and the next ten items arrive.
Or Tab to Load more and press Enter.

```fsharp
module Snippets.LoadMore

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

let private pageSize = 10
let private total = 50

// Stands in for a request for one page of results, such as /api/items?page=2.
let private fetchPage (page: int) =
    promise {
        do! Promise.sleep 400
        return [ for i in page * pageSize + 1 .. min total ((page + 1) * pageSize) -> $"Item {i}" ]
    }

[<AttachMembers>]
type LoadMoreList() as this =
    inherit LitElement()

    let endOfList = createRef<HTMLElement> ()

    // `target = null`: observe nothing until firstUpdated hands it the end of the list.
    let watcher =
        IntersectionController<bool>(
            this,
            IntersectionControllerConfig(
                target = null,
                callback =
                    IntersectionValueCallback(fun entries _ ->
                        let inView = entries |> Array.exists (fun e -> e.isIntersecting)

                        if inView then
                            this.LoadMore()

                        inView
                    )
            )
        )

    static member properties =
        PropertyDeclarations.create [
            "items", PropertyDeclaration<string list>(state = true)
            "loading", PropertyDeclaration<bool>(state = true)
        ]

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.5rem; justify-items: start; }
        .scroller { height: 12rem; width: 16rem; overflow-y: auto; border: 1px solid var(--border);
                    border-radius: 0.5rem; padding: 0.5rem 1rem; }
        ul { margin: 0; padding-left: 1.25rem; }
        li { padding: 0.25rem 0; }
        .scroller:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
        .end { height: 1px; }
        button { font: inherit; padding: 0.3rem 0.9rem; }
        p { margin: 0; }
        """

    member val items = [ for i in 1..pageSize -> $"Item {i}" ] with get, set
    member val loading = false with get, set

    member this.HasMore = this.items.Length < total

    member this.LoadMore() =
        if not this.loading && this.HasMore then
            this.loading <- true

            promise {
                let! more = fetchPage (this.items.Length / pageSize)
                this.items <- this.items @ more
                this.loading <- false
                let! _ = this.updateComplete

                // An observer reports changes. If the end is still in view, nothing changed, so
                // observe it afresh: the observer then reports where it is now. Not if the
                // component has left the page meanwhile, which stopped the observer.
                endOfList.value
                |> Option.iter (fun el ->
                    watcher.unobserve el

                    if this.HasMore && this.isConnected then
                        watcher.observe el
                )
            }
            |> Promise.start

    override _.firstUpdated _ =
        endOfList.value |> Option.iter watcher.observe

    override this.render() =
        let label =
            if not this.HasMore then $"All {total} loaded"
            elif this.loading then "Loading…"
            else "Load more"

        // The button stays, outside the box, so it keeps the focus as the list grows and ends.
        html
            $"""
        <div class="scroller" tabindex="0" role="region" aria-label="Items">
            <ul>{this.items |> List.map (fun item -> html $"<li>{item}</li>")}</ul>
            <div class="end" {ref endOfList}></div>
        </div>
        <button aria-disabled={not this.HasMore} @click={fun _ -> this.LoadMore()}>{label}</button>
        <p role="status">Showing {this.items.Length} of {total}.</p>"""

defineElement<LoadMoreList> "my-load-more"
```

```html
<my-load-more></my-load-more>
```

## How it works

An `IntersectionController` from [Firelight.Observers](https://roboz0r.github.io/Firelight/packages/observers/) wraps the browser's
`IntersectionObserver`: it starts observing when the component joins the page, stops when it
leaves, and asks the component to render when what it observes changes. Its callback turns the
observer's entries into a value, here whether the end of the list is in view, and calls `LoadMore`
when it is.

- **What to observe.** By default the controller observes the component itself. `target = null`
  starts it with nothing, and `firstUpdated` hands it the element after the list, through a
  [ref](https://roboz0r.github.io/Firelight/guides/templates/#directives), once it exists.
- **No root.** The observer's root is the window, but an element inside a scrolling box only counts
  as visible when it's scrolled into view in the box too. So the same code works for a box and
  for a whole page.
- **Observing again.** An observer reports changes. If the end of the list is still in view after
  a page arrives, as on a tall screen, nothing has changed and no report comes. `LoadMore` waits
  for the new items to render, then stops observing the end and starts again, which makes the
  observer report where it is now. It skips that if the component has left the page while the
  request was running, since leaving stopped the observer.
- **One request at a time.** The callback and the button both call `LoadMore`, which does nothing
  while `loading` is set.
- **The keyboard.** The box has `tabindex="0"`, so the arrow keys can scroll it, and a
  `role="region"` with a label, so a screen reader says what it is when it gets the focus.
- **The button.** Infinite scroll alone shuts out anyone who can't scroll the box, and a reader
  who tabs past the list never reaches its end. The button sits below the box, where new
  items don't push it out of sight, and it stays on the page to the end, with
  `aria-disabled` once everything has loaded. Disabling it, or removing it, would drop the focus.

The first page is in the component's initial state, so the [prerendered](https://roboz0r.github.io/Firelight/guides/prerendering/)
HTML shows it before any JavaScript runs.

Thousands of items, all loaded at once, are a different problem: render only the visible ones with
[Firelight.Virtualizer](https://roboz0r.github.io/Firelight/packages/virtualizer/).

## Related

- [Controllers](https://roboz0r.github.io/Firelight/guides/controllers/), for how the Observers controllers follow the lifecycle.
- [Fetch JSON](https://roboz0r.github.io/Firelight/cookbook/fetch-json/), for the request itself.
