---
title: "Animating list changes"
description: "Animate a Firelight list with Firelight.Motion: new items fade in, the rest slide into place, with one AnimateController for shared timing and reduced motion."
---

# Animating list changes

Build a list whose new items fade in and whose other items slide to their new places as items come and go, and that keeps still for anyone who prefers less motion.

- [Lit Labs: @lit-labs/motion](https://github.com/lit/lit/tree/main/packages/labs/motion)

Add a few people, then remove one from the middle: the people below it slide up. If your system
is set to reduce motion, the list changes without animating.

```fsharp
module Snippets.AnimatedList

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

// Fable.Browser.Dom has no matchMedia, so declare the part this needs.
type MediaQueryList =
    inherit EventTarget
    abstract matches: bool

[<Emit("window.matchMedia($0)")>]
let private matchMedia (query: string) : MediaQueryList = jsNative

let private names = [| "Ada"; "Grace"; "Alan"; "Barbara"; "Edsger"; "Frances"; "Tony"; "Radia" |]

type Person = { Id: int; Name: string }

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

    let mutable nextId = 3
    let mutable stopListening = ignore

    // Options for every animate() in this component, and a switch to turn them all off.
    let motion =
        AnimateController(
            this,
            AnimateControllerOptions(
                defaultOptions =
                    MotionOptions(keyframeOptions = MotionKeyframeOptions(duration = !^250.0, easing = "ease-out"))
            )
        )

    // A new item fades in, and the rest slide to their new places.
    let itemMotion = MotionOptions(``in`` = Motion.fadeIn, skipInitial = true)

    static member properties =
        PropertyDeclarations.create [ "people", PropertyDeclaration<Person list>(state = true) ]

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.75rem; justify-items: start; }
        ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.35rem; width: 14rem; }
        li { display: flex; justify-content: space-between; align-items: center; padding: 0.3rem 0.5rem 0.3rem 0.75rem;
             border: 1px solid var(--border); border-radius: 0.5rem; background: var(--bg); }
        button { font: inherit; padding: 0.2rem 0.7rem; }
        """

    member val people = [ for i in 0..2 -> { Id = i; Name = names[i] } ] with get, set

    member this.Add() =
        let person = { Id = nextId; Name = names[nextId % names.Length] }
        nextId <- nextId + 1
        // At the top, so the others visibly move down to make room.
        this.people <- person :: this.people

    /// Removing the item takes its button, and the focus, with it: move the focus to the next
    /// item's button, or to Add someone.
    member this.Remove(id: int) =
        match this.people |> List.tryFindIndex (fun p -> p.Id = id) with
        | None -> ()
        | Some index ->
            this.people <- this.people |> List.filter (fun p -> p.Id <> id)

            let next =
                match List.tryItem (min index (this.people.Length - 1)) this.people with
                | Some p -> $"#remove-{p.Id}"
                | None -> "#add"

            promise {
                let! _ = this.updateComplete
                this.query<HTMLElement> next |> Option.iter _.focus()
            }
            |> Promise.start

    // Follow the reduced-motion setting, including changes while the page is open.
    override this.connectedCallback() =
        base.connectedCallback ()
        let query = matchMedia "(prefers-reduced-motion: reduce)"

        let apply () =
            motion.disabled <- query.matches

            if query.matches then
                motion.finish ()

        apply ()
        stopListening <- Ev.listen query "change" (Ev.event (fun _ -> apply ()))

    override this.disconnectedCallback() =
        base.disconnectedCallback ()
        stopListening ()

    member this.PersonView(person: Person) =
        html
            $"""
        <li {Motion.animate itemMotion}>
            {person.Name}
            <button id="remove-{person.Id}" aria-label="Remove {person.Name}" @click={fun _ -> this.Remove person.Id}>×</button>
        </li>"""

    override this.render() =
        html
            $"""
        <button id="add" @click={fun _ -> this.Add()}>Add someone</button>
        <ul>{repeat (this.people, (fun p -> p.Id), this.PersonView)}</ul>"""

defineElement<AnimatedList> "my-animated-list"
```

```html
<my-animated-list></my-animated-list>
```

## How it works

`Motion.animate` from [Firelight.Motion](https://roboz0r.github.io/Firelight/packages/motion/) goes on each `<li>`, as an element
directive. At each render it measures where the element was and where it is now, and animates
between the two, so items slide when one above them is added or removed. The
[Motion package page](https://roboz0r.github.io/Firelight/packages/motion/) shows that part on its own; this recipe adds the items
that come and go.

- **In, but not out.** `in = Motion.fadeIn` plays when an element first appears. Motion also has
  `out`, for an element being removed: it puts the element back while it fades. Back in the list,
  it still takes up its place, so the items below it wait for the fade, then jump. Let a removed
  item go at once, and the others slide up into its place.
- **Not on the first render.** `skipInitial = true` stops the items that are already there from
  fading in when the page loads, including the [prerendered](https://roboz0r.github.io/Firelight/guides/prerendering/) ones, which
  are on screen before the component's code runs.
- **Keyed.** [`repeat`](https://roboz0r.github.io/Firelight/guides/templates/#keyed-lists-with-repeat) keeps each person's element
  with them. With `List.map`, the elements would stay put and swap their text, and there would be
  nothing to slide.
- **One controller.** `AnimateController` holds the options every `animate` in the component
  shares, here a 250 ms ease-out, so each item only says what's different about it.
- **Reduced motion.** `connectedCallback` reads the `prefers-reduced-motion` media query, sets the
  controller's `disabled`, which turns off every animation in the component, and listens for the
  setting to change while the page is open. Some people get motion sickness from movement on
  screen, and the list works the same without it.
- **The focus.** Removing an item removes its button, which had the focus, so `Remove` moves the
  focus to the next item's button, or to Add someone.

[Fable](https://fable.io/)'s browser bindings have no `matchMedia`, so the module declares it. The
[Controllers guide](https://roboz0r.github.io/Firelight/guides/controllers/#a-controller-that-reads-the-browser) wraps the same media
query in a controller that any component can reuse.

## Related

- [Drag to reorder](https://roboz0r.github.io/Firelight/cookbook/drag-reorder/), a list worth animating.
- [Firelight.Motion](https://roboz0r.github.io/Firelight/packages/motion/), the package.
