Skip to content

Start Here

What is a Scroll Animation?

When the user clicks "Back to top" and the page glides up smoothly instead of jumping, that's a scroll animation. The browser already knows how to jump an element to a new scroll position - this library knows how to animate it there.

Elm Motion lets you animate the scroll position of:

  • the whole document (the page itself), or
  • any scrollable container in your view (a sidebar, a film strip, a spreadsheet).

You describe what to scroll to using a shared builder pipeline, then pick the engine that gives you the level of control you need.

The Three Scroll Engines

Engine One-liner
Scroll.Cmd Fire-and-forget. The simplest possible setup.
Scroll.Task Like Cmd, but returns a Task so you get typed success/failure results.
Scroll.Sub Stateful. Subscribes for frame updates. Lets you pause, resume, stop, query position, react to progress events, and interrupt scrolls mid-flight.

All three share the same Scroll.Builder pipeline, so the way you describe a scroll never changes. Only the way you run it does.

📖 See Scroll Engines Overview for a side-by-side comparison.

Coding Style

The library codebase and all the examples use function composition (>>) extensively.

New to function composition (>>)?

If you are more used to Elm's pipeline operator (|>), here's how they compare:

-- Using pipelines (|>)
scrollToElement : String -> ScrollBuilder -> ScrollBuilder
scrollToElement targetId scrollBuilder =
    scrollBuilder
        |> Scroll.forContainer "scroll-container"
        |> Scroll.toElement targetId
        |> Scroll.speed 250
        |> Scroll.easing BounceOut
        |> Scroll.build

-- Using function composition (>>)
scrollToElement : String -> ScrollBuilder -> ScrollBuilder
scrollToElement targetId =
    Scroll.forContainer "scroll-container"
        >> Scroll.toElement targetId
        >> Scroll.speed 250
        >> Scroll.easing BounceOut
        >> Scroll.build

Both produce identical results. Because these builders are all functions of type ScrollBuilder -> ScrollBuilder, they compose naturally with >>. This codebase prefers the composition style because it keeps builder definitions concise and usually reads more cleanly than threading an explicit scrollBuilder through a pipeline.

The composition style works because each builder step is itself a partially-applied function of type ScrollBuilder -> ScrollBuilder - every argument except the builder has been supplied. >> then chains those partially-applied functions end-to-end into one larger function with the same ScrollBuilder -> ScrollBuilder shape.

Examples

The examples below show the same scroll built with each of the three engines, so you can see how the engine choice affects the surrounding code without changing what the scroll does.

Responsive by default

All examples in this documentation are responsive - they adapt smoothly when the viewport is resized.

1. Vertical Scrolling

The classic "jump to a section" scroll. A vertical container with three named sections and a button per section that smoothly scrolls to it. The same example is built three times - once per engine - so the only thing that changes between tabs is the engine wiring.

View Example

View Source Code
module Scroll.Cmd.FirstScroll.Main exposing (main)

import Browser
import Html exposing (Html, button, div, text)
import Html.Attributes exposing (class, id, style)
import Html.Events exposing (onClick)
import Motion.Easing as Easing exposing (Easing(..))
import Scroll.Builder as Scroll
import Scroll.Engine.Cmd as Cmd exposing (ScrollBuilder)



-- MAIN


main : Program () Model Msg
main =
    Browser.element
        { init = \_ -> init
        , view = view
        , update = update
        , subscriptions = always Sub.none
        }



-- MODEL


type alias Model =
    { status : ScrollStatus }


type ScrollStatus
    = Idle
    | Scrolling
    | Arrived


init : ( Model, Cmd Msg )
init =
    ( { status = Idle }, Cmd.none )



-- UPDATE


type Msg
    = ScrollTo String
    | ScrollComplete


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ScrollTo targetId ->
            ( { model | status = Scrolling }
            , Cmd.scroll ScrollComplete <|
                scrollToElement targetId
            )

        ScrollComplete ->
            ( { model | status = Arrived }, Cmd.none )





scrollToElement : String -> ScrollBuilder -> ScrollBuilder
scrollToElement targetId =
    Scroll.forContainer "scroll-container"
        >> Scroll.toElement targetId
        >> Scroll.speed 250
        >> Scroll.easing BounceOut
        >> Scroll.build



-- VIEW


view : Model -> Html Msg
view model =
    div [ class "example-stage" ]
        [ div [ class "example-controls" ]
            [ styledButton (ScrollTo "top-element") "Scroll to Top"
            , styledButton (ScrollTo "middle-element") "Scroll to Middle"
            , styledButton (ScrollTo "bottom-element") "Scroll to Bottom"
            ]
        , statusBar model.status
          div
            [ id "scroll-container"
            , style "width" "100%"
            , style "flex" "1 1 auto"
            , style "min-height" "0"
            , style "box-sizing" "border-box"
            , style "overflow-y" "auto"
            , style "border" "2px solid #333"
            , style "border-radius" "8px"
            ]
            [ scrollContent ]

        ]


statusBar : ScrollStatus -> Html msg
statusBar status =
    let
        ( color, message ) =
            case status of
                Idle ->
                    ( "#94a3b8", "Click a button to scroll" )

                Scrolling ->
                    ( "#f59e0b", "Scrolling..." )

                Arrived ->
                    ( "#22c55e", "✓ Scroll complete" )
    in
    div
        [ style "padding" "6px 14px"
        , style "border-radius" "6px"
        , style "background-color" color
        , style "color" "white"
        , style "font-size" "clamp(11px, 1.8vmin, 14px)"
        , style "font-weight" "500"
        , style "flex" "0 0 auto"
        ]
        [ text message ]


styledButton : Msg -> String -> Html Msg
styledButton msg label =
    button
        [ onClick msg
        , class "ui-action-button"
        , style "background-color" "#6366f1"
        ]
        [ text label ]


scrollContent : Html Msg
scrollContent =
    div
        [ style "padding" "20px" ]
        [ targetElement "top-element" "Top Section" "#4CAF50"
        , spacer
        , targetElement "middle-element" "Middle Section" "#2196F3"
        , spacer
        , targetElement "bottom-element" "Bottom Section" "#9C27B0"
        ]


targetElement : String -> String -> String -> Html Msg
targetElement elementId label color =
    div
        [ id elementId
        , style "padding" "40px"
        , style "background-color" color
        , style "color" "white"
        , style "border-radius" "8px"
        , style "text-align" "center"
        , style "font-size" "24px"
        ]
        [ text label ]


spacer : Html Msg
spacer =
    div [ style "height" "400px" ] []
module Scroll.Task.FirstScroll.Main exposing (main)

import Browser
import Html exposing (Html, button, div, text)
import Html.Attributes exposing (class, id, style)
import Html.Events exposing (onClick)
import Motion.Easing as Easing exposing (Easing(..))
import Scroll.Builder as Scroll
import Scroll.Engine.Task as Task exposing (ScrollBuilder)
import Task



-- MAIN


main : Program () Model Msg
main =
    Browser.element
        { init = \_ -> init
        , view = view
        , update = update
        , subscriptions = always Sub.none
        }



-- MODEL


type alias Model =
    { status : ScrollStatus }


type ScrollStatus
    = Idle
    | Scrolling
    | Arrived
    | Failed String


init : ( Model, Cmd Msg )
init =
    ( { status = Idle }, Cmd.none )



-- UPDATE


type Msg
    = ScrollTo String
    | ScrollResult (Result Task.ScrollError (List Task.ScrollOk))


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ScrollTo targetId ->
            ( { model | status = Scrolling }
            , Task.attempt ScrollResult <|
                Task.scroll <|
                    scrollToElement targetId
            )

        ScrollResult (Ok _) ->
            ( { model | status = Arrived }, Cmd.none )

        ScrollResult (Err (Task.ScrollError err)) ->
            let
                containerLabel =
                    case err.container of
                        Task.Document ->
                            "document"

                        Task.Container id ->
                            id
            in
            ( { model | status = Failed ("Scroll failed for container: " ++ containerLabel) }
            , Cmd.none
            )





scrollToElement : String -> ScrollBuilder -> ScrollBuilder
scrollToElement targetId =
    Scroll.forContainer "scroll-container"
        >> Scroll.toElement targetId
        >> Scroll.speed 250
        >> Scroll.easing BounceOut
        >> Scroll.build



-- VIEW


view : Model -> Html Msg
view model =
    div [ class "example-stage" ]
        [ div [ class "example-controls" ]
            [ styledButton (ScrollTo "top-element") "Scroll to Top"
            , styledButton (ScrollTo "middle-element") "Scroll to Middle"
            , styledButton (ScrollTo "bottom-element") "Scroll to Bottom"
            ]
        , statusBar model.status
          div
            [ id "scroll-container"
            , style "width" "100%"
            , style "flex" "1 1 auto"
            , style "min-height" "0"
            , style "box-sizing" "border-box"
            , style "overflow-y" "auto"
            , style "border" "2px solid #333"
            , style "border-radius" "8px"
            ]
            [ scrollContent ]

        ]


statusBar : ScrollStatus -> Html msg
statusBar status =
    let
        ( color, message ) =
            case status of
                Idle ->
                    ( "#94a3b8", "Click a button to scroll" )

                Scrolling ->
                    ( "#f59e0b", "Scrolling..." )

                Arrived ->
                    ( "#22c55e", "✓ Scroll complete" )

                Failed err ->
                    ( "#ef4444", "✗ " ++ err )
    in
    div
        [ style "padding" "6px 14px"
        , style "border-radius" "6px"
        , style "background-color" color
        , style "color" "white"
        , style "font-size" "clamp(11px, 1.8vmin, 14px)"
        , style "font-weight" "500"
        , style "flex" "0 0 auto"
        ]
        [ text message ]


styledButton : Msg -> String -> Html Msg
styledButton msg label =
    button
        [ onClick msg
        , class "ui-action-button"
        , style "background-color" "#6366f1"
        ]
        [ text label ]


scrollContent : Html Msg
scrollContent =
    div
        [ style "padding" "20px" ]
        [ targetElement "top-element" "Top Section" "#4CAF50"
        , spacer
        , targetElement "middle-element" "Middle Section" "#2196F3"
        , spacer
        , targetElement "bottom-element" "Bottom Section" "#9C27B0"
        ]


targetElement : String -> String -> String -> Html Msg
targetElement elementId label color =
    div
        [ id elementId
        , style "padding" "40px"
        , style "background-color" color
        , style "color" "white"
        , style "border-radius" "8px"
        , style "text-align" "center"
        , style "font-size" "24px"
        ]
        [ text label ]


spacer : Html Msg
spacer =
    div [ style "height" "400px" ] []
module Scroll.Sub.FirstScroll.Main exposing (main)

import Browser
import Html exposing (Html, button, div, text)
import Html.Attributes exposing (class, id, style)
import Html.Events exposing (onClick)
import Motion.Easing as Easing exposing (Easing(..))
import Scroll.Builder as Scroll
import Scroll.Engine.Sub as Sub exposing (ScrollBuilder)



-- MAIN


main : Program () Model Msg
main =
    Browser.element
        { init = \_ -> init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }



-- MODEL


type alias Model =
    { scrollState : Sub.ScrollState
    , status : ScrollStatus
    }


type ScrollStatus
    = Idle
    | Scrolling
    | Progress { x : Float, y : Float } Float
    | Completed Sub.Container
    | Failed String


init : ( Model, Cmd Msg )
init =
    ( { scrollState = Sub.init
      , status = Idle
      }
    , Cmd.none
    )



-- UPDATE


type Msg
    = ScrollTo String
    | GotScrollMsg Sub.ScrollMsg


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ScrollTo targetId ->
            let
                ( newScrollState, scrollCmd ) =
                    Sub.scroll GotScrollMsg model.scrollState <|
                        scrollToElement targetId
            in
            ( { model | scrollState = newScrollState }, scrollCmd )

        GotScrollMsg scrollMsg ->
            let
                ( newScrollState, events, scrollCmd ) =
                    Sub.update GotScrollMsg scrollMsg model.scrollState
            in
            ( handleEvents { model | scrollState = newScrollState } events
            , scrollCmd
            )





handleEvents : Model -> List Sub.ScrollEvent -> Model
handleEvents =
    List.foldl handleEvent


handleEvent : Sub.ScrollEvent -> Model -> Model
handleEvent event model =
    { model
        | status =
            case event of
                Sub.Started _ ->
                    Scrolling

                Sub.Ended container ->
                    Completed container

                Sub.Progress _ xy progress ->
                    Progress xy progress

                _ ->
                    model.status
    }





scrollToElement : String -> ScrollBuilder -> ScrollBuilder
scrollToElement targetId =
    Scroll.forContainer "scroll-container"
        >> Scroll.toElement targetId
        >> Scroll.speed 250
        >> Scroll.easing BounceOut
        >> Scroll.build



-- SUBSCRIPTIONS


subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.subscriptions GotScrollMsg model.scrollState



-- VIEW


view : Model -> Html Msg
view model =
    div [ class "example-stage" ]
        [ div [ class "example-controls" ]
            [ styledButton (ScrollTo "top-element") "Scroll to Top"
            , styledButton (ScrollTo "middle-element") "Scroll to Middle"
            , styledButton (ScrollTo "bottom-element") "Scroll to Bottom"
            ]
        , statusBar model.status
          div
            [ id "scroll-container"
            , style "width" "100%"
            , style "flex" "1 1 auto"
            , style "min-height" "0"
            , style "box-sizing" "border-box"
            , style "overflow-y" "auto"
            , style "border" "2px solid #333"
            , style "border-radius" "8px"
            ]
            [ scrollContent ]

        ]


statusBar : ScrollStatus -> Html msg
statusBar status =
    let
        ( color, message ) =
            case status of
                Idle ->
                    ( "#94a3b8", "Click a button to scroll" )

                Scrolling ->
                    ( "#f59e0b", "Scrolling..." )

                Completed container ->
                    ( "#22c55e", "✓ Scroll complete for " ++ containerLabel container )

                Progress _ progress ->
                    ( "#3b82f6", "Progress... " ++ String.fromInt (round (progress * 100)) ++ "%" )

                Failed err ->
                    ( "#ef4444", "✗ " ++ err )
    in
    div
        [ style "padding" "6px 14px"
        , style "border-radius" "6px"
        , style "background-color" color
        , style "color" "white"
        , style "font-size" "clamp(11px, 1.8vmin, 14px)"
        , style "font-weight" "500"
        , style "flex" "0 0 auto"
        ]
        [ text message ]


containerLabel : Sub.Container -> String
containerLabel container =
    case container of
        Sub.Document ->
            "document"

        Sub.Container containerId ->
            containerId


styledButton : Msg -> String -> Html Msg
styledButton msg label =
    button
        [ onClick msg
        , class "ui-action-button"
        , style "background-color" "#6366f1"
        ]
        [ text label ]


scrollContent : Html Msg
scrollContent =
    div
        [ style "padding" "20px" ]
        [ targetElement "top-element" "Top Section" "#4CAF50"
        , spacer
        , targetElement "middle-element" "Middle Section" "#2196F3"
        , spacer
        , targetElement "bottom-element" "Bottom Section" "#9C27B0"
        ]


targetElement : String -> String -> String -> Html Msg
targetElement elementId label color =
    div
        [ id elementId
        , style "padding" "40px"
        , style "background-color" color
        , style "color" "white"
        , style "border-radius" "8px"
        , style "text-align" "center"
        , style "font-size" "24px"
        ]
        [ text label ]


spacer : Html Msg
spacer =
    div [ style "height" "400px" ] []
Breaking It Down

Every scroll example follows the same workflow. The number of steps you actually wire up depends on the engine:

  • Cmd - the minimal flow: Build and Trigger.
  • Task - Build, Trigger and optionally React (to handle Ok / Err).
  • Sub - Build, Trigger, Initialize (to hold ScrollState), Subscribe (for frame updates), and optionally React (to update from events).

Below, each step is shown side by side per engine. Notice how only the wiring changes - the builder definition is identical across all three.

1. Build

Scrolls are defined as reusable functions that transform a ScrollBuilder:

View Source Code
scrollToElement : String -> ScrollBuilder -> ScrollBuilder
scrollToElement targetId =
    Scroll.forContainer "scroll-container"
        >> Scroll.toElement targetId
        >> Scroll.speed 250
        >> Scroll.easing BounceOut
        >> Scroll.build
scrollToElement : String -> ScrollBuilder -> ScrollBuilder
scrollToElement targetId =
    Scroll.forContainer "scroll-container"
        >> Scroll.toElement targetId
        >> Scroll.speed 250
        >> Scroll.easing BounceOut
        >> Scroll.build
scrollToElement : String -> ScrollBuilder -> ScrollBuilder
scrollToElement targetId =
    Scroll.forContainer "scroll-container"
        >> Scroll.toElement targetId
        >> Scroll.speed 250
        >> Scroll.easing BounceOut
        >> Scroll.build

Notice how all three engines use the exact same builder pipeline.

2. Initialize

Only the Sub engine keeps state in the model:

View Source Code
type alias Model =
    { scrollState : Sub.ScrollState
    , status : ScrollStatus
    }


type ScrollStatus
    = Idle
    | Scrolling
    | Progress { x : Float, y : Float } Float
    | Completed Sub.Container
    | Failed String


init : ( Model, Cmd Msg )
init =
    ( { scrollState = Sub.init
      , status = Idle
      }
    , Cmd.none
    )

3. Subscribe

Only the Sub engine needs subscriptions:

View Source Code
subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.subscriptions GotScrollMsg model.scrollState

4. Trigger

Each engine starts the same scroll definition a little differently:

View Source Code
        ScrollTo targetId ->
            ( { model | status = Scrolling }
            , Cmd.scroll ScrollComplete <|
                scrollToElement targetId
            )
        ScrollTo targetId ->
            ( { model | status = Scrolling }
            , Task.attempt ScrollResult <|
                Task.scroll <|
                    scrollToElement targetId
            )
        ScrollTo targetId ->
            let
                ( newScrollState, scrollCmd ) =
                    Sub.scroll GotScrollMsg model.scrollState <|
                        scrollToElement targetId
            in
            ( { model | scrollState = newScrollState }, scrollCmd )

5. React

Task returns a Result, while Sub returns frame-by-frame events that keep the model in sync:

View Source Code
        ScrollResult (Ok _) ->
            ( { model | status = Arrived }, Cmd.none )

        ScrollResult (Err (Task.ScrollError err)) ->
            let
                containerLabel =
                    case err.container of
                        Task.Document ->
                            "document"

                        Task.Container id ->
                            id
            in
            ( { model | status = Failed ("Scroll failed for container: " ++ containerLabel) }
            , Cmd.none
            )
        GotScrollMsg scrollMsg ->
            let
                ( newScrollState, events, scrollCmd ) =
                    Sub.update GotScrollMsg scrollMsg model.scrollState
            in
            ( handleEvents { model | scrollState = newScrollState } events
            , scrollCmd
            )

Task is useful when you want success/failure handling. Sub is useful when you want live progress, events, or later control over the running scroll.


2. Horizontal Scrolling

A horizontally-scrolling image gallery. The buttons jump between named cards, and the builder uses onXAxis so the gallery never drifts vertically. Shown in all three engines.

View Example

View Source Code
module Scroll.Cmd.HorizontalGallery.Main exposing (main)

import Browser
import Html exposing (Html, button, div, text)
import Html.Attributes exposing (class, id, style)
import Html.Events exposing (onClick)
import Motion.Easing as Easing exposing (Easing(..))
import Scroll.Builder as Scroll
import Scroll.Engine.Cmd as Cmd exposing (ScrollBuilder)



-- MAIN


main : Program () Model Msg
main =
    Browser.element
        { init = \_ -> ( {}, Cmd.none )
        , view = view
        , update = update
        , subscriptions = always Sub.none
        }



-- MODEL


type alias Model =
    {}



-- UPDATE


type Msg
    = ScrollTo String
    | ScrollComplete


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ScrollTo cardId ->
            ( model
            , Cmd.scroll ScrollComplete <|
                scrollToCard cardId
            )

        ScrollComplete ->
            ( model, Cmd.none )





scrollToCard : String -> ScrollBuilder -> ScrollBuilder
scrollToCard cardId =
    Scroll.forContainer "gallery"
        >> Scroll.toElement cardId
        >> Scroll.onXAxis
        >> Scroll.speed 500
        >> Scroll.easing EaseInOut
        >> Scroll.build



-- VIEW


photos : List { id : String, label : String, color : String, emoji : String }
photos =
    [ { id = "photo-mountains", label = "Mountains", color = "#4a6f8a", emoji = "🏔️" }
    , { id = "photo-ocean", label = "Ocean", color = "#1a7a6e", emoji = "🌊" }
    , { id = "photo-desert", label = "Desert", color = "#c47b3a", emoji = "🏜️" }
    , { id = "photo-forest", label = "Forest", color = "#3a7a45", emoji = "🌲" }
    , { id = "photo-arctic", label = "Arctic", color = "#5b7fa6", emoji = "🧊" }
    , { id = "photo-volcano", label = "Volcano", color = "#8b3a3a", emoji = "🌋" }
    , { id = "photo-savanna", label = "Savanna", color = "#8b7a3a", emoji = "🦁" }
    , { id = "photo-reef", label = "Reef", color = "#2a7a8b", emoji = "🐠" }
    ]


view : Model -> Html Msg
view _ =
    div [ class "example-stage" ]
        [ buttonRow
        , filmStrip
        ]


buttonRow : Html Msg
buttonRow =
    div [ class "example-controls" ]
        (List.map navButton photos)


navButton : { id : String, label : String, color : String, emoji : String } -> Html Msg
navButton photo =
    button
        [ onClick (ScrollTo photo.id)
        , class "ui-action-button"
        , style "background-color" photo.color
        ]
        [ text (photo.emoji ++ " " ++ photo.label) ]


filmStrip : Html Msg
filmStrip =
    div
        [ id "gallery"
        , style "display" "flex"
        , style "overflow-x" "auto"
        , style "overflow-y" "hidden"
        , style "gap" "12px"
        , style "padding" "12px"
        , style "border" "2px solid #333"
        , style "border-radius" "8px"
        , style "width" "100%"
        , style "flex" "1 1 auto"
        , style "min-height" "0"
        , style "box-sizing" "border-box"
        ]
        (List.map photoCard photos)





photoCard : { id : String, label : String, color : String, emoji : String } -> Html Msg
photoCard photo =
    div
        [ id photo.id
        , style "min-width" "clamp(140px, 35vmin, 220px)"
        , style "height" "100%"
        , style "background-color" photo.color
        , style "border-radius" "8px"
        , style "display" "flex"
        , style "flex-direction" "column"
        , style "align-items" "center"
        , style "justify-content" "center"
        , style "color" "white"
        , style "flex-shrink" "0"
        ]
        [ div [ style "font-size" "clamp(36px, 9vmin, 64px)" ] [ text photo.emoji ]
        , div
            [ style "font-size" "clamp(13px, 2.2vmin, 18px)"
            , style "font-weight" "700"
            , style "margin-top" "12px"
            , style "letter-spacing" "0.5px"
            ]
            [ text photo.label ]
        ]
module Scroll.Task.HorizontalGallery.Main exposing (main)

import Browser
import Html exposing (Html, button, div, text)
import Html.Attributes exposing (class, id, style)
import Html.Events exposing (onClick)
import Motion.Easing as Easing exposing (Easing(..))
import Scroll.Builder as Scroll
import Scroll.Engine.Task as Task exposing (ScrollBuilder)
import Task



-- MAIN


main : Program () Model Msg
main =
    Browser.element
        { init = \_ -> init
        , view = view
        , update = update
        , subscriptions = always Sub.none
        }



-- MODEL


type alias Model =
    { status : ScrollStatus }


type ScrollStatus
    = Idle
    | Scrolling
    | Arrived
    | Failed String


init : ( Model, Cmd Msg )
init =
    ( { status = Idle }
    , Cmd.none
    )



-- UPDATE


type Msg
    = ScrollTo String
    | ScrollResult (Result Task.ScrollError (List Task.ScrollOk))


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ScrollTo cardId ->
            ( { model | status = Scrolling }
            , Task.attempt ScrollResult <|
                Task.scroll <|
                    scrollToCard cardId
            )

        ScrollResult (Ok _) ->
            ( { model | status = Arrived }, Cmd.none )

        ScrollResult (Err (Task.ScrollError err)) ->
            let
                containerLabel =
                    case err.container of
                        Task.Document ->
                            "document"

                        Task.Container id ->
                            id
            in
            ( { model | status = Failed ("Could not scroll: " ++ containerLabel) }, Cmd.none )





scrollToCard : String -> ScrollBuilder -> ScrollBuilder
scrollToCard cardId =
    Scroll.forContainer "gallery"
        >> Scroll.toElement cardId
        >> Scroll.onXAxis
        >> Scroll.speed 500
        >> Scroll.easing EaseInOut
        >> Scroll.build



-- VIEW


photos : List { id : String, label : String, color : String, emoji : String }
photos =
    [ { id = "photo-mountains", label = "Mountains", color = "#4a6f8a", emoji = "🏔️" }
    , { id = "photo-ocean", label = "Ocean", color = "#1a7a6e", emoji = "🌊" }
    , { id = "photo-desert", label = "Desert", color = "#c47b3a", emoji = "🏜️" }
    , { id = "photo-forest", label = "Forest", color = "#3a7a45", emoji = "🌲" }
    , { id = "photo-arctic", label = "Arctic", color = "#5b7fa6", emoji = "🧊" }
    , { id = "photo-volcano", label = "Volcano", color = "#8b3a3a", emoji = "🌋" }
    , { id = "photo-savanna", label = "Savanna", color = "#8b7a3a", emoji = "🦁" }
    , { id = "photo-reef", label = "Reef", color = "#2a7a8b", emoji = "🐠" }
    ]


view : Model -> Html Msg
view model =
    div [ class "example-stage" ]
        [ buttonRow
        , statusBar model.status
        , filmStrip
        ]


statusBar : ScrollStatus -> Html msg
statusBar status =
    let
        ( color, message ) =
            case status of
                Idle ->
                    ( "#94a3b8", "Click a photo to navigate" )

                Scrolling ->
                    ( "#f59e0b", "Scrolling..." )

                Arrived ->
                    ( "#22c55e", "✓ Arrived" )

                Failed err ->
                    ( "#ef4444", "✗ " ++ err )
    in
    div
        [ style "padding" "6px 14px"
        , style "border-radius" "6px"
        , style "background-color" color
        , style "color" "white"
        , style "font-size" "clamp(11px, 1.8vmin, 14px)"
        , style "font-weight" "500"
        , style "flex" "0 0 auto"
        ]
        [ text message ]


buttonRow : Html Msg
buttonRow =
    div [ class "example-controls" ]
        (List.map navButton photos)


navButton : { id : String, label : String, color : String, emoji : String } -> Html Msg
navButton photo =
    button
        [ onClick (ScrollTo photo.id)
        , class "ui-action-button"
        , style "background-color" photo.color
        ]
        [ text (photo.emoji ++ " " ++ photo.label) ]


filmStrip : Html Msg
filmStrip =
    div
        [ id "gallery"
        , style "display" "flex"
        , style "overflow-x" "auto"
        , style "overflow-y" "hidden"
        , style "gap" "12px"
        , style "padding" "12px"
        , style "border" "2px solid #333"
        , style "border-radius" "8px"
        , style "width" "100%"
        , style "flex" "1 1 auto"
        , style "min-height" "0"
        , style "box-sizing" "border-box"
        ]
        (List.map photoCard photos)





photoCard : { id : String, label : String, color : String, emoji : String } -> Html Msg
photoCard photo =
    div
        [ id photo.id
        , style "min-width" "clamp(140px, 35vmin, 220px)"
        , style "height" "100%"
        , style "background-color" photo.color
        , style "border-radius" "8px"
        , style "display" "flex"
        , style "flex-direction" "column"
        , style "align-items" "center"
        , style "justify-content" "center"
        , style "color" "white"
        , style "flex-shrink" "0"
        ]
        [ div [ style "font-size" "clamp(36px, 9vmin, 64px)" ] [ text photo.emoji ]
        , div
            [ style "font-size" "clamp(13px, 2.2vmin, 18px)"
            , style "font-weight" "700"
            , style "margin-top" "12px"
            , style "letter-spacing" "0.5px"
            ]
            [ text photo.label ]
        ]
module Scroll.Sub.HorizontalGallery.Main exposing (main)

import Browser
import Html exposing (Html, button, div, text)
import Html.Attributes exposing (class, id, style)
import Html.Events exposing (onClick)
import Motion.Easing as Easing exposing (Easing(..))
import Scroll.Builder as Scroll
import Scroll.Engine.Sub as Sub exposing (ScrollBuilder)



-- MAIN


main : Program () Model Msg
main =
    Browser.element
        { init = \_ -> init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }



-- MODEL


type alias Model =
    { scrollState : Sub.ScrollState
    , status : ScrollStatus
    }


type ScrollStatus
    = Idle
    | Scrolling Float Float
    | Arrived


init : ( Model, Cmd Msg )
init =
    ( { scrollState = Sub.init
      , status = Idle
      }
    , Cmd.none
    )



-- UPDATE


type Msg
    = ScrollTo String
    | GotScrollMsg Sub.ScrollMsg


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ScrollTo cardId ->
            let
                ( newScrollState, scrollCmd ) =
                    Sub.scroll GotScrollMsg model.scrollState <|
                        scrollToCard cardId
            in
            ( { model | scrollState = newScrollState }, scrollCmd )

        GotScrollMsg scrollMsg ->
            let
                ( newScrollState, events, scrollCmd ) =
                    Sub.update GotScrollMsg scrollMsg model.scrollState
            in
            ( { model
                | scrollState = newScrollState
                , status = List.foldl applyEvent model.status events
              }
            , scrollCmd
            )





applyEvent : Sub.ScrollEvent -> ScrollStatus -> ScrollStatus
applyEvent event _ =
    case event of
        Sub.Progress _ pos progress ->
            Scrolling pos.x progress

        Sub.Ended _ ->
            Arrived

        _ ->
            Idle





scrollToCard : String -> ScrollBuilder -> ScrollBuilder
scrollToCard cardId =
    Scroll.forContainer "gallery"
        >> Scroll.toElement cardId
        >> Scroll.onXAxis
        >> Scroll.speed 500
        >> Scroll.easing EaseInOut
        >> Scroll.build



-- SUBSCRIPTIONS


subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.subscriptions GotScrollMsg model.scrollState



-- VIEW


photos : List { id : String, label : String, color : String, emoji : String }
photos =
    [ { id = "photo-mountains", label = "Mountains", color = "#4a6f8a", emoji = "🏔️" }
    , { id = "photo-ocean", label = "Ocean", color = "#1a7a6e", emoji = "🌊" }
    , { id = "photo-desert", label = "Desert", color = "#c47b3a", emoji = "🏜️" }
    , { id = "photo-forest", label = "Forest", color = "#3a7a45", emoji = "🌲" }
    , { id = "photo-arctic", label = "Arctic", color = "#5b7fa6", emoji = "🧊" }
    , { id = "photo-volcano", label = "Volcano", color = "#8b3a3a", emoji = "🌋" }
    , { id = "photo-savanna", label = "Savanna", color = "#8b7a3a", emoji = "🦁" }
    , { id = "photo-reef", label = "Reef", color = "#2a7a8b", emoji = "🐠" }
    ]


view : Model -> Html Msg
view model =
    div [ class "example-stage" ]
        [ buttonRow
        , statusBar model.status
        , filmStrip
        ]


statusBar : ScrollStatus -> Html msg
statusBar status =
    let
        ( color, message ) =
            case status of
                Idle ->
                    ( "#94a3b8", "Click a photo to navigate" )

                Scrolling xPos progress ->
                    ( "#3b82f6"
                    , "x = "
                        ++ String.fromInt (round xPos)
                        ++ "px  ("
                        ++ String.fromInt (round (progress * 100))
                        ++ "%)"
                    )

                Arrived ->
                    ( "#22c55e", "✓ Arrived" )
    in
    div
        [ style "padding" "6px 14px"
        , style "border-radius" "6px"
        , style "background-color" color
        , style "color" "white"
        , style "font-size" "clamp(11px, 1.8vmin, 14px)"
        , style "font-weight" "500"
        , style "font-family" "monospace"
        , style "flex" "0 0 auto"
        ]
        [ text message ]


buttonRow : Html Msg
buttonRow =
    div [ class "example-controls" ]
        (List.map navButton photos)


navButton : { id : String, label : String, color : String, emoji : String } -> Html Msg
navButton photo =
    button
        [ onClick (ScrollTo photo.id)
        , class "ui-action-button"
        , style "background-color" photo.color
        ]
        [ text (photo.emoji ++ " " ++ photo.label) ]


filmStrip : Html Msg
filmStrip =
    div
        [ id "gallery"
        , style "display" "flex"
        , style "overflow-x" "auto"
        , style "overflow-y" "hidden"
        , style "gap" "12px"
        , style "padding" "12px"
        , style "border" "2px solid #333"
        , style "border-radius" "8px"
        , style "width" "100%"
        , style "flex" "1 1 auto"
        , style "min-height" "0"
        , style "box-sizing" "border-box"
        ]
        (List.map photoCard photos)





photoCard : { id : String, label : String, color : String, emoji : String } -> Html Msg
photoCard photo =
    div
        [ id photo.id
        , style "min-width" "clamp(140px, 35vmin, 220px)"
        , style "height" "100%"
        , style "background-color" photo.color
        , style "border-radius" "8px"
        , style "display" "flex"
        , style "flex-direction" "column"
        , style "align-items" "center"
        , style "justify-content" "center"
        , style "color" "white"
        , style "flex-shrink" "0"
        ]
        [ div [ style "font-size" "clamp(36px, 9vmin, 64px)" ] [ text photo.emoji ]
        , div
            [ style "font-size" "clamp(13px, 2.2vmin, 18px)"
            , style "font-weight" "700"
            , style "margin-top" "12px"
            , style "letter-spacing" "0.5px"
            ]
            [ text photo.label ]
        ]
Breaking It Down

Same six-step workflow as the vertical scrolling example. The only meaningful difference is in the builder - onXAxis tells the engine to scroll horizontally only.

1. Build

Horizontal scrolling uses the same builder pattern, with onXAxis making the intended axis explicit:

View Source Code
scrollToCard : String -> ScrollBuilder -> ScrollBuilder
scrollToCard cardId =
    Scroll.forContainer "gallery"
        >> Scroll.toElement cardId
        >> Scroll.onXAxis
        >> Scroll.speed 500
        >> Scroll.easing EaseInOut
        >> Scroll.build
scrollToCard : String -> ScrollBuilder -> ScrollBuilder
scrollToCard cardId =
    Scroll.forContainer "gallery"
        >> Scroll.toElement cardId
        >> Scroll.onXAxis
        >> Scroll.speed 500
        >> Scroll.easing EaseInOut
        >> Scroll.build
scrollToCard : String -> ScrollBuilder -> ScrollBuilder
scrollToCard cardId =
    Scroll.forContainer "gallery"
        >> Scroll.toElement cardId
        >> Scroll.onXAxis
        >> Scroll.speed 500
        >> Scroll.easing EaseInOut
        >> Scroll.build

2. Initialize

Only the Sub engine keeps state in the model:

View Source Code
type alias Model =
    { scrollState : Sub.ScrollState
    , status : ScrollStatus
    }


type ScrollStatus
    = Idle
    | Scrolling Float Float
    | Arrived


init : ( Model, Cmd Msg )
init =
    ( { scrollState = Sub.init
      , status = Idle
      }
    , Cmd.none
    )

3. Subscribe

Only the Sub engine needs subscriptions:

View Source Code
subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.subscriptions GotScrollMsg model.scrollState

4. Trigger

Each engine starts the same scroll definition a little differently:

View Source Code
        ScrollTo cardId ->
            ( model
            , Cmd.scroll ScrollComplete <|
                scrollToCard cardId
            )
        ScrollTo cardId ->
            ( { model | status = Scrolling }
            , Task.attempt ScrollResult <|
                Task.scroll <|
                    scrollToCard cardId
            )
        ScrollTo cardId ->
            let
                ( newScrollState, scrollCmd ) =
                    Sub.scroll GotScrollMsg model.scrollState <|
                        scrollToCard cardId
            in
            ( { model | scrollState = newScrollState }, scrollCmd )

5. React

Task reports success or failure when the scroll finishes, while Sub keeps the status bar updated with live X position and progress:

View Source Code
        ScrollResult (Ok _) ->
            ( { model | status = Arrived }, Cmd.none )

        ScrollResult (Err (Task.ScrollError err)) ->
            let
                containerLabel =
                    case err.container of
                        Task.Document ->
                            "document"

                        Task.Container id ->
                            id
            in
            ( { model | status = Failed ("Could not scroll: " ++ containerLabel) }, Cmd.none )
        GotScrollMsg scrollMsg ->
            let
                ( newScrollState, events, scrollCmd ) =
                    Sub.update GotScrollMsg scrollMsg model.scrollState
            in
            ( { model
                | scrollState = newScrollState
                , status = List.foldl applyEvent model.status events
              }
            , scrollCmd
            )

3. Spreadsheet Navigation

A spreadsheet-style grid with sticky row and column headers. Region buttons jump to named cells using both axes at once, and withOffsetXY keeps the cell clear of the sticky headers when the scroll settles. Shown in all three engines.

View Example

View Source Code
module Scroll.Cmd.Spreadsheet.Main exposing (main)

import Browser
import Html exposing (Html, button, div, text)
import Html.Attributes exposing (class, id, style)
import Html.Events exposing (onClick)
import Motion.Easing as Easing exposing (Easing(..))
import Scroll.Builder as Scroll
import Scroll.Engine.Cmd as Cmd exposing (ScrollBuilder)



-- MAIN


main : Program () Model Msg
main =
    Browser.element
        { init = \_ -> ( { status = Idle }, Cmd.none )
        , view = view
        , update = update
        , subscriptions = always Sub.none
        }



-- MODEL


type ScrollStatus
    = Idle
    | Scrolling
    | Arrived


type alias Model =
    { status : ScrollStatus }



-- UPDATE


type Msg
    = NavigateTo String
    | ScrollComplete


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NavigateTo regionId ->
            ( { model | status = Scrolling }
            , Cmd.scroll ScrollComplete <|
                scrollToRegion regionId
            )

        ScrollComplete ->
            ( { model | status = Arrived }, Cmd.none )





scrollToRegion : String -> ScrollBuilder -> ScrollBuilder
scrollToRegion regionId =
    Scroll.forContainer "spreadsheet"
        >> Scroll.toElement regionId
        >> Scroll.withOffsetXY 48 32
        >> Scroll.speed 400
        >> Scroll.easing EaseInOut
        >> Scroll.build



-- SPREADSHEET DATA


type alias Region =
    { id : String
    , label : String
    , col : Int
    , row : Int
    , color : String
    , emoji : String
    }


regions : List Region
regions =
    [ { id = "region-revenue", label = "Revenue", col = 4, row = 5, color = "#3b6fa0", emoji = "💰" }
    , { id = "region-expenses", label = "Expenses", col = 10, row = 13, color = "#a03b3b", emoji = "📉" }
    , { id = "region-forecast", label = "Forecast", col = 7, row = 22, color = "#3b8050", emoji = "📈" }
    , { id = "region-summary", label = "Summary", col = 13, row = 30, color = "#7a6b30", emoji = "📊" }
    , { id = "region-growth", label = "YoY Growth", col = 16, row = 35, color = "#6b3a8b", emoji = "🚀" }
    ]


numCols : Int
numCols =
    16


numRows : Int
numRows =
    35


colLabel : Int -> String
colLabel i =
    String.fromChar (Char.fromCode (64 + i))


findRegion : Int -> Int -> Maybe Region
findRegion col row =
    List.head (List.filter (\r -> r.col == col && r.row == row) regions)



-- VIEW


view : Model -> Html Msg
view model =
    div [ class "example-stage" ]
        [ navButtons
        , statusBar model.status
        , spreadsheet
        ]


statusBar : ScrollStatus -> Html msg
statusBar status =
    let
        ( color, message ) =
            case status of
                Idle ->
                    ( "#94a3b8", "Click a region to navigate" )

                Scrolling ->
                    ( "#3b82f6", "Scrolling..." )

                Arrived ->
                    ( "#22c55e", "✓ Arrived" )
    in
    div
        [ style "padding" "6px 14px"
        , style "border-radius" "6px"
        , style "background-color" color
        , style "color" "white"
        , style "font-size" "clamp(11px, 1.8vmin, 14px)"
        , style "font-weight" "500"
        , style "font-family" "monospace"
        , style "flex" "0 0 auto"
        ]
        [ text message ]


navButtons : Html Msg
navButtons =
    div [ class "example-controls" ]
        (List.map regionButton regions)


regionButton : Region -> Html Msg
regionButton region =
    button
        [ onClick (NavigateTo region.id)
        , class "ui-action-button"
        , style "background-color" region.color
        ]
        [ text (region.emoji ++ " " ++ region.label) ]


spreadsheet : Html Msg
spreadsheet =
    div
        [ id "spreadsheet"
        , style "overflow" "auto"
        , style "width" "100%"
        , style "flex" "1 1 auto"
        , style "min-height" "0"
        , style "box-sizing" "border-box"
        , style "border" "1px solid #ccc"
        , style "border-radius" "8px"
        ]
        [ div
            [ style "display" "grid"
            , style "grid-template-columns"
                ("48px " ++ String.join " " (List.repeat numCols "96px"))
            , style "width" "max-content"
            ]
            (headerRow ++ List.concatMap dataRow (List.range 1 numRows))
        ]





headerRow : List (Html Msg)
headerRow =
    cornerCell :: List.map colHeaderCell (List.range 1 numCols)


cornerCell : Html Msg
cornerCell =
    div
        [ style "position" "sticky"
        , style "top" "0"
        , style "left" "0"
        , style "z-index" "3"
        , style "background-color" "#e8e8e8"
        , style "border-right" "2px solid #aaa"
        , style "border-bottom" "2px solid #aaa"
        , style "height" "32px"
        ]
        []


colHeaderCell : Int -> Html Msg
colHeaderCell col =
    div
        [ style "position" "sticky"
        , style "top" "0"
        , style "z-index" "2"
        , style "background-color" "#e8e8e8"
        , style "border-right" "1px solid #ccc"
        , style "border-bottom" "2px solid #aaa"
        , style "height" "32px"
        , style "display" "flex"
        , style "align-items" "center"
        , style "justify-content" "center"
        , style "font-weight" "700"
        , style "font-size" "13px"
        , style "color" "#555"
        ]
        [ text (colLabel col) ]


dataRow : Int -> List (Html Msg)
dataRow row =
    rowNumCell row :: List.map (dataCell row) (List.range 1 numCols)


rowNumCell : Int -> Html Msg
rowNumCell row =
    div
        [ style "position" "sticky"
        , style "left" "0"
        , style "z-index" "1"
        , style "background-color" "#e8e8e8"
        , style "border-right" "2px solid #aaa"
        , style "border-bottom" "1px solid #ccc"
        , style "height" "32px"
        , style "display" "flex"
        , style "align-items" "center"
        , style "justify-content" "center"
        , style "font-weight" "600"
        , style "font-size" "12px"
        , style "color" "#555"
        ]
        [ text (String.fromInt row) ]


dataCell : Int -> Int -> Html Msg
dataCell row col =
    case findRegion col row of
        Just region ->
            div
                [ id region.id
                , style "background-color" region.color
                , style "color" "white"
                , style "border-right" "1px solid rgba(255,255,255,0.3)"
                , style "border-bottom" "1px solid rgba(255,255,255,0.3)"
                , style "height" "32px"
                , style "display" "flex"
                , style "align-items" "center"
                , style "justify-content" "center"
                , style "font-size" "11px"
                , style "font-weight" "700"
                , style "white-space" "nowrap"
                , style "padding" "0 6px"
                ]
                [ text (region.emoji ++ " " ++ region.label) ]

        Nothing ->
            div
                [ style "border-right" "1px solid #e8e8e8"
                , style "border-bottom" "1px solid #e8e8e8"
                , style "height" "32px"
                , style "background-color"
                    (if modBy 2 row == 0 then
                        "#fafafa"

                     else
                        "white"
                    )
                ]
                []
module Scroll.Task.Spreadsheet.Main exposing (main)

import Browser
import Html exposing (Html, button, div, text)
import Html.Attributes exposing (class, id, style)
import Html.Events exposing (onClick)
import Motion.Easing as Easing exposing (Easing(..))
import Scroll.Builder as Scroll
import Scroll.Engine.Task as Task exposing (ScrollBuilder)
import Task



-- MAIN


main : Program () Model Msg
main =
    Browser.element
        { init = \_ -> init
        , view = view
        , update = update
        , subscriptions = always Sub.none
        }



-- MODEL


type alias Model =
    { status : ScrollStatus }


type ScrollStatus
    = Idle
    | Scrolling
    | Arrived
    | Failed String


init : ( Model, Cmd Msg )
init =
    ( { status = Idle }
    , Cmd.none
    )



-- UPDATE


type Msg
    = NavigateTo String
    | ScrollResult (Result Task.ScrollError (List Task.ScrollOk))


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NavigateTo regionId ->
            ( { model | status = Scrolling }
            , Task.attempt ScrollResult <|
                Task.scroll <|
                    scrollToRegion regionId
            )

        ScrollResult (Ok _) ->
            ( { model | status = Arrived }, Cmd.none )

        ScrollResult (Err (Task.ScrollError err)) ->
            let
                containerLabel =
                    case err.container of
                        Task.Document ->
                            "document"

                        Task.Container id ->
                            id
            in
            ( { model | status = Failed ("Could not scroll: " ++ containerLabel) }, Cmd.none )





scrollToRegion : String -> ScrollBuilder -> ScrollBuilder
scrollToRegion regionId =
    Scroll.forContainer "spreadsheet"
        >> Scroll.toElement regionId
        >> Scroll.withOffsetXY 48 32
        >> Scroll.speed 400
        >> Scroll.easing EaseInOut
        >> Scroll.build



-- SPREADSHEET DATA


type alias Region =
    { id : String
    , label : String
    , col : Int
    , row : Int
    , color : String
    , emoji : String
    }


regions : List Region
regions =
    [ { id = "region-revenue", label = "Revenue", col = 4, row = 5, color = "#3b6fa0", emoji = "💰" }
    , { id = "region-expenses", label = "Expenses", col = 10, row = 13, color = "#a03b3b", emoji = "📉" }
    , { id = "region-forecast", label = "Forecast", col = 7, row = 22, color = "#3b8050", emoji = "📈" }
    , { id = "region-summary", label = "Summary", col = 13, row = 30, color = "#7a6b30", emoji = "📊" }
    , { id = "region-growth", label = "YoY Growth", col = 16, row = 35, color = "#6b3a8b", emoji = "🚀" }
    ]


numCols : Int
numCols =
    16


numRows : Int
numRows =
    35


colLabel : Int -> String
colLabel i =
    String.fromChar (Char.fromCode (64 + i))


findRegion : Int -> Int -> Maybe Region
findRegion col row =
    List.head (List.filter (\r -> r.col == col && r.row == row) regions)



-- VIEW


view : Model -> Html Msg
view model =
    div [ class "example-stage" ]
        [ navButtons
        , statusBar model.status
        , spreadsheet
        ]


statusBar : ScrollStatus -> Html msg
statusBar status =
    let
        ( color, message ) =
            case status of
                Idle ->
                    ( "#94a3b8", "Click a region to navigate" )

                Scrolling ->
                    ( "#3b82f6", "Scrolling..." )

                Arrived ->
                    ( "#22c55e", "✓ Arrived" )

                Failed err ->
                    ( "#ef4444", "✗ " ++ err )
    in
    div
        [ style "padding" "6px 14px"
        , style "border-radius" "6px"
        , style "background-color" color
        , style "color" "white"
        , style "font-size" "clamp(11px, 1.8vmin, 14px)"
        , style "font-weight" "500"
        , style "font-family" "monospace"
        , style "flex" "0 0 auto"
        ]
        [ text message ]


navButtons : Html Msg
navButtons =
    div [ class "example-controls" ]
        (List.map regionButton regions)


regionButton : Region -> Html Msg
regionButton region =
    button
        [ onClick (NavigateTo region.id)
        , class "ui-action-button"
        , style "background-color" region.color
        ]
        [ text (region.emoji ++ " " ++ region.label) ]


spreadsheet : Html Msg
spreadsheet =
    div
        [ id "spreadsheet"
        , style "overflow" "auto"
        , style "width" "100%"
        , style "flex" "1 1 auto"
        , style "min-height" "0"
        , style "box-sizing" "border-box"
        , style "border" "1px solid #ccc"
        , style "border-radius" "8px"
        ]
        [ div
            [ style "display" "grid"
            , style "grid-template-columns"
                ("48px " ++ String.join " " (List.repeat numCols "96px"))
            , style "width" "max-content"
            ]
            (headerRow ++ List.concatMap dataRow (List.range 1 numRows))
        ]





headerRow : List (Html Msg)
headerRow =
    cornerCell :: List.map colHeaderCell (List.range 1 numCols)


cornerCell : Html Msg
cornerCell =
    div
        [ style "position" "sticky"
        , style "top" "0"
        , style "left" "0"
        , style "z-index" "3"
        , style "background-color" "#e8e8e8"
        , style "border-right" "2px solid #aaa"
        , style "border-bottom" "2px solid #aaa"
        , style "height" "32px"
        ]
        []


colHeaderCell : Int -> Html Msg
colHeaderCell col =
    div
        [ style "position" "sticky"
        , style "top" "0"
        , style "z-index" "2"
        , style "background-color" "#e8e8e8"
        , style "border-right" "1px solid #ccc"
        , style "border-bottom" "2px solid #aaa"
        , style "height" "32px"
        , style "display" "flex"
        , style "align-items" "center"
        , style "justify-content" "center"
        , style "font-weight" "700"
        , style "font-size" "13px"
        , style "color" "#555"
        ]
        [ text (colLabel col) ]


dataRow : Int -> List (Html Msg)
dataRow row =
    rowNumCell row :: List.map (dataCell row) (List.range 1 numCols)


rowNumCell : Int -> Html Msg
rowNumCell row =
    div
        [ style "position" "sticky"
        , style "left" "0"
        , style "z-index" "1"
        , style "background-color" "#e8e8e8"
        , style "border-right" "2px solid #aaa"
        , style "border-bottom" "1px solid #ccc"
        , style "height" "32px"
        , style "display" "flex"
        , style "align-items" "center"
        , style "justify-content" "center"
        , style "font-weight" "600"
        , style "font-size" "12px"
        , style "color" "#555"
        ]
        [ text (String.fromInt row) ]


dataCell : Int -> Int -> Html Msg
dataCell row col =
    case findRegion col row of
        Just region ->
            div
                [ id region.id
                , style "background-color" region.color
                , style "color" "white"
                , style "border-right" "1px solid rgba(255,255,255,0.3)"
                , style "border-bottom" "1px solid rgba(255,255,255,0.3)"
                , style "height" "32px"
                , style "display" "flex"
                , style "align-items" "center"
                , style "justify-content" "center"
                , style "font-size" "11px"
                , style "font-weight" "700"
                , style "white-space" "nowrap"
                , style "padding" "0 6px"
                ]
                [ text (region.emoji ++ " " ++ region.label) ]

        Nothing ->
            div
                [ style "border-right" "1px solid #e8e8e8"
                , style "border-bottom" "1px solid #e8e8e8"
                , style "height" "32px"
                , style "background-color"
                    (if modBy 2 row == 0 then
                        "#fafafa"

                     else
                        "white"
                    )
                ]
                []
module Scroll.Sub.Spreadsheet.Main exposing (main)

import Browser
import Html exposing (Html, button, div, text)
import Html.Attributes exposing (class, id, style)
import Html.Events exposing (onClick)
import Motion.Easing as Easing exposing (Easing(..))
import Scroll.Builder as Scroll
import Scroll.Engine.Sub as Sub exposing (ScrollBuilder)



-- MAIN


main : Program () Model Msg
main =
    Browser.element
        { init = \_ -> init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }



-- MODEL


type alias Model =
    { scrollState : Sub.ScrollState
    , status : ScrollStatus
    }


type ScrollStatus
    = Idle
    | Scrolling { x : Float, y : Float } Float
    | Arrived


init : ( Model, Cmd Msg )
init =
    ( { scrollState = Sub.init
      , status = Idle
      }
    , Cmd.none
    )



-- UPDATE


type Msg
    = NavigateTo String
    | GotScrollMsg Sub.ScrollMsg


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NavigateTo regionId ->
            let
                ( newScrollState, scrollCmd ) =
                    Sub.scroll GotScrollMsg model.scrollState <|
                        scrollToRegion regionId
            in
            ( { model | scrollState = newScrollState }, scrollCmd )

        GotScrollMsg scrollMsg ->
            let
                ( newScrollState, events, scrollCmd ) =
                    Sub.update GotScrollMsg scrollMsg model.scrollState
            in
            ( { model
                | scrollState = newScrollState
                , status = List.foldl applyEvent model.status events
              }
            , scrollCmd
            )





applyEvent : Sub.ScrollEvent -> ScrollStatus -> ScrollStatus
applyEvent event _ =
    case event of
        Sub.Progress _ pos progress ->
            Scrolling pos progress

        Sub.Ended _ ->
            Arrived

        _ ->
            Idle





scrollToRegion : String -> ScrollBuilder -> ScrollBuilder
scrollToRegion regionId =
    Scroll.forContainer "spreadsheet"
        >> Scroll.toElement regionId
        >> Scroll.withOffsetXY 48 32
        >> Scroll.speed 400
        >> Scroll.easing EaseInOut
        >> Scroll.build



-- SUBSCRIPTIONS


subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.subscriptions GotScrollMsg model.scrollState



-- SPREADSHEET DATA


type alias Region =
    { id : String
    , label : String
    , col : Int
    , row : Int
    , color : String
    , emoji : String
    }


regions : List Region
regions =
    [ { id = "region-revenue", label = "Revenue", col = 4, row = 5, color = "#3b6fa0", emoji = "💰" }
    , { id = "region-expenses", label = "Expenses", col = 10, row = 13, color = "#a03b3b", emoji = "📉" }
    , { id = "region-forecast", label = "Forecast", col = 7, row = 22, color = "#3b8050", emoji = "📈" }
    , { id = "region-summary", label = "Summary", col = 13, row = 30, color = "#7a6b30", emoji = "📊" }
    , { id = "region-growth", label = "YoY Growth", col = 16, row = 35, color = "#6b3a8b", emoji = "🚀" }
    ]


numCols : Int
numCols =
    16


numRows : Int
numRows =
    35


colLabel : Int -> String
colLabel i =
    String.fromChar (Char.fromCode (64 + i))


findRegion : Int -> Int -> Maybe Region
findRegion col row =
    List.head (List.filter (\r -> r.col == col && r.row == row) regions)



-- VIEW


view : Model -> Html Msg
view model =
    div [ class "example-stage" ]
        [ navButtons
        , statusBar model.status
        , spreadsheet
        ]


statusBar : ScrollStatus -> Html msg
statusBar status =
    let
        ( color, message ) =
            case status of
                Idle ->
                    ( "#94a3b8", "Click a region to navigate" )

                Scrolling pos progress ->
                    ( "#3b82f6"
                    , "x = "
                        ++ String.fromInt (round pos.x)
                        ++ "px  y = "
                        ++ String.fromInt (round pos.y)
                        ++ "px  ("
                        ++ String.fromInt (round (progress * 100))
                        ++ "%)"
                    )

                Arrived ->
                    ( "#22c55e", "✓ Arrived" )
    in
    div
        [ style "padding" "6px 14px"
        , style "border-radius" "6px"
        , style "background-color" color
        , style "color" "white"
        , style "font-size" "clamp(11px, 1.8vmin, 14px)"
        , style "font-weight" "500"
        , style "font-family" "monospace"
        , style "flex" "0 0 auto"
        ]
        [ text message ]


navButtons : Html Msg
navButtons =
    div [ class "example-controls" ]
        (List.map regionButton regions)


regionButton : Region -> Html Msg
regionButton region =
    button
        [ onClick (NavigateTo region.id)
        , class "ui-action-button"
        , style "background-color" region.color
        ]
        [ text (region.emoji ++ " " ++ region.label) ]


spreadsheet : Html Msg
spreadsheet =
    div
        [ id "spreadsheet"
        , style "overflow" "auto"
        , style "width" "100%"
        , style "flex" "1 1 auto"
        , style "min-height" "0"
        , style "box-sizing" "border-box"
        , style "border" "1px solid #ccc"
        , style "border-radius" "8px"
        ]
        [ div
            [ style "display" "grid"
            , style "grid-template-columns"
                ("48px " ++ String.join " " (List.repeat numCols "96px"))
            , style "width" "max-content"
            ]
            (headerRow ++ List.concatMap dataRow (List.range 1 numRows))
        ]





headerRow : List (Html Msg)
headerRow =
    cornerCell :: List.map colHeaderCell (List.range 1 numCols)


cornerCell : Html Msg
cornerCell =
    div
        [ style "position" "sticky"
        , style "top" "0"
        , style "left" "0"
        , style "z-index" "3"
        , style "background-color" "#e8e8e8"
        , style "border-right" "2px solid #aaa"
        , style "border-bottom" "2px solid #aaa"
        , style "height" "32px"
        ]
        []


colHeaderCell : Int -> Html Msg
colHeaderCell col =
    div
        [ style "position" "sticky"
        , style "top" "0"
        , style "z-index" "2"
        , style "background-color" "#e8e8e8"
        , style "border-right" "1px solid #ccc"
        , style "border-bottom" "2px solid #aaa"
        , style "height" "32px"
        , style "display" "flex"
        , style "align-items" "center"
        , style "justify-content" "center"
        , style "font-weight" "700"
        , style "font-size" "13px"
        , style "color" "#555"
        ]
        [ text (colLabel col) ]


dataRow : Int -> List (Html Msg)
dataRow row =
    rowNumCell row :: List.map (dataCell row) (List.range 1 numCols)


rowNumCell : Int -> Html Msg
rowNumCell row =
    div
        [ style "position" "sticky"
        , style "left" "0"
        , style "z-index" "1"
        , style "background-color" "#e8e8e8"
        , style "border-right" "2px solid #aaa"
        , style "border-bottom" "1px solid #ccc"
        , style "height" "32px"
        , style "display" "flex"
        , style "align-items" "center"
        , style "justify-content" "center"
        , style "font-weight" "600"
        , style "font-size" "12px"
        , style "color" "#555"
        ]
        [ text (String.fromInt row) ]


dataCell : Int -> Int -> Html Msg
dataCell row col =
    case findRegion col row of
        Just region ->
            div
                [ id region.id
                , style "background-color" region.color
                , style "color" "white"
                , style "border-right" "1px solid rgba(255,255,255,0.3)"
                , style "border-bottom" "1px solid rgba(255,255,255,0.3)"
                , style "height" "32px"
                , style "display" "flex"
                , style "align-items" "center"
                , style "justify-content" "center"
                , style "font-size" "11px"
                , style "font-weight" "700"
                , style "white-space" "nowrap"
                , style "padding" "0 6px"
                ]
                [ text (region.emoji ++ " " ++ region.label) ]

        Nothing ->
            div
                [ style "border-right" "1px solid #e8e8e8"
                , style "border-bottom" "1px solid #e8e8e8"
                , style "height" "32px"
                , style "background-color"
                    (if modBy 2 row == 0 then
                        "#fafafa"

                     else
                        "white"
                    )
                ]
                []
Breaking It Down

Same six-step workflow as the vertical scrolling example. The two new ingredients are both in the builder: toElement naturally scrolls both axes, and withOffsetXY shifts the final position so the cell isn't hidden behind the sticky headers.

1. Build

toElement scrolls both axes by default, and withOffsetXY leaves room for the sticky headers:

View Source Code
scrollToRegion : String -> ScrollBuilder -> ScrollBuilder
scrollToRegion regionId =
    Scroll.forContainer "spreadsheet"
        >> Scroll.toElement regionId
        >> Scroll.withOffsetXY 48 32
        >> Scroll.speed 400
        >> Scroll.easing EaseInOut
        >> Scroll.build
scrollToRegion : String -> ScrollBuilder -> ScrollBuilder
scrollToRegion regionId =
    Scroll.forContainer "spreadsheet"
        >> Scroll.toElement regionId
        >> Scroll.withOffsetXY 48 32
        >> Scroll.speed 400
        >> Scroll.easing EaseInOut
        >> Scroll.build
scrollToRegion : String -> ScrollBuilder -> ScrollBuilder
scrollToRegion regionId =
    Scroll.forContainer "spreadsheet"
        >> Scroll.toElement regionId
        >> Scroll.withOffsetXY 48 32
        >> Scroll.speed 400
        >> Scroll.easing EaseInOut
        >> Scroll.build

2. Initialize

Only the Sub engine keeps state in the model:

View Source Code
type alias Model =
    { scrollState : Sub.ScrollState
    , status : ScrollStatus
    }


type ScrollStatus
    = Idle
    | Scrolling { x : Float, y : Float } Float
    | Arrived


init : ( Model, Cmd Msg )
init =
    ( { scrollState = Sub.init
      , status = Idle
      }
    , Cmd.none
    )

3. Subscribe

Only the Sub engine needs subscriptions:

View Source Code
subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.subscriptions GotScrollMsg model.scrollState

4. Trigger

Each engine starts the same scroll definition a little differently:

View Source Code
        NavigateTo regionId ->
            ( { model | status = Scrolling }
            , Cmd.scroll ScrollComplete <|
                scrollToRegion regionId
            )
        NavigateTo regionId ->
            ( { model | status = Scrolling }
            , Task.attempt ScrollResult <|
                Task.scroll <|
                    scrollToRegion regionId
            )
        NavigateTo regionId ->
            let
                ( newScrollState, scrollCmd ) =
                    Sub.scroll GotScrollMsg model.scrollState <|
                        scrollToRegion regionId
            in
            ( { model | scrollState = newScrollState }, scrollCmd )

5. React

Task gives you completion or failure at the end of the scroll, while Sub reports live x and y coordinates plus overall progress:

View Source Code
        ScrollResult (Ok _) ->
            ( { model | status = Arrived }, Cmd.none )

        ScrollResult (Err (Task.ScrollError err)) ->
            let
                containerLabel =
                    case err.container of
                        Task.Document ->
                            "document"

                        Task.Container id ->
                            id
            in
            ( { model | status = Failed ("Could not scroll: " ++ containerLabel) }, Cmd.none )
        GotScrollMsg scrollMsg ->
            let
                ( newScrollState, events, scrollCmd ) =
                    Sub.update GotScrollMsg scrollMsg model.scrollState
            in
            ( { model
                | scrollState = newScrollState
                , status = List.foldl applyEvent model.status events
              }
            , scrollCmd
            )

Next Steps

Now that you've seen what a scroll animation looks like, dig into the builder - the small composable API every scroll is described with.

The Scroll Builder →