Skip to content

Scroll Sub Engine

This page is a practical guide to using the Sub engine. Read Scroll Engines Overview when you want side-by-side comparisons and tradeoffs.

Scroll.Sub is the full-featured scroll engine. Instead of pre-calculating a scroll and firing it as a Cmd, it stores ScrollState in your model and updates it on every animation frame via a subscription.

That extra wiring buys you a lot:

  • pause, resume, stop, reset, restart at any time,
  • redirect the scroll to a new target mid-flight,
  • read the current scroll position any time,
  • react to Started, Ended, Progress, Paused, Resumed, Stopped, Restarted events.

If you don't need any of those, Cmd or Task are simpler.

Example

A vertical scroll with full state and event handling.

View Example

View Source Code
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" ] []

Quick Walkthrough

There are four moving parts to wire up: a piece of state in your model, a subscription, an update handler, and the trigger.

1. Initialize

Store a ScrollState in your model and seed it with Sub.init:

View Source Code
import Scroll.Engine.Sub as Sub


type alias Model =
    { scrollState : Sub.ScrollState }


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

2. Subscribe

Wire the engine into subscriptions. The subscription is dormant when no scrolls are running and only activates while something is in flight - so there's no runtime cost from leaving it permanently wired in:

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

3. Trigger

Sub.scroll takes your Msg, the current state, and a builder. It returns ( ScrollState, Cmd msg ):

View Source Code
import Scroll.Builder as Scroll
import Motion.Easing exposing (Easing(..))


type Msg
    = ScrollTo String
    | ScrollMsg Sub.ScrollMsg


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ScrollTo targetId ->
            let
                ( newState, cmd ) =
                    Sub.scroll ScrollMsg model.scrollState <|
                        Scroll.forContainer "scroll-container"
                            >> Scroll.toElement targetId
                            >> Scroll.speed 400
                            >> Scroll.easing BounceOut
                            >> Scroll.build
            in
            ( { model | scrollState = newState }, cmd )

If a scroll for the same container is already running, this replaces it - the new scroll picks up from the current position.

4. React

Forward engine messages into Sub.update. It returns the new state, a list of events that happened on this frame, and any Cmd the engine needs to issue:

View Source Code
        ScrollMsg scrollMsg ->
            let
                ( newState, events, cmd ) =
                    Sub.update ScrollMsg scrollMsg model.scrollState
            in
            ( { model | scrollState = newState }
            , cmd
            )

If you want to react to events, fold over the list - see Events below.


In Detail

Events

Sub.update returns a List ScrollEvent. Multiple events can fire on the same frame.

Every event carries a Container so you know which scroll surface (Sub.Document or Sub.Container "id") it belongs to.

Event Payload Fires when...
Started Container A scroll begins.
Ended Container A scroll completes naturally.
Progress Container, { x, y }, Float Every frame while running. The Float is 0.0–1.0 progress.
Stopped Container A scroll was stopped before completing.
Paused Container A scroll was paused.
Resumed Container A paused scroll resumed.
Restarted Container A scroll was reset and replayed.
Handling events
ScrollMsg scrollMsg ->
    let
        ( newState, events, cmd ) =
            Sub.update ScrollMsg scrollMsg model.scrollState
    in
    ( List.foldl handleEvent { model | scrollState = newState } events
    , cmd
    )


handleEvent : Sub.ScrollEvent -> Model -> Model
handleEvent event model =
    case event of
        Sub.Progress _ _ progress ->
            { model | percent = round (progress * 100) }

        Sub.Ended _ ->
            { model | status = "Arrived" }

        _ ->
            model

Live Progress

Because Progress fires every frame with the current { x, y } position and a 0.0–1.0 progress value, you can drive scrollbars, percentage readouts, and parallax effects directly from scroll state:

View Source Code
Sub.Progress _ position progress ->
    { model
        | scrollX = position.x
        , scrollY = position.y
        , percent = round (progress * 100)
    }

Controls

Each control takes a Container so you can target a specific scroll.

Function Behaviour
stop Jump to the target position and finish
pause Freeze at the current position
resume Continue from where pause froze
reset Jump to the start position and finish
restart Reset to start, then play again

stop, reset, and restart issue commands, so they return ( ScrollState, Cmd msg ):

View Source Code
StopScroll ->
    let
        ( newState, cmd ) =
            Sub.stop Sub.Document ScrollMsg model.scrollState
    in
    ( { model | scrollState = newState }, cmd )

pause and resume are state-only - they return just ScrollState:

View Source Code
PauseScroll ->
    ( { model | scrollState = Sub.pause (Sub.Container "sidebar") model.scrollState }
    , Cmd.none
    )

Each control emits a matching event on the next frame, so you can react in your event handler:

Control Event
stop Stopped
pause Paused
resume Resumed
reset Stopped
restart Restarted
View Example

View Source Code
module Scroll.Sub.ControllingScrolls.Main exposing (main)

import Browser
import Html exposing (Html, button, div, h1, p, 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 }



-- INIT


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



-- UPDATE


type Msg
    = ScrollAnimate
    | Stop
    | Pause
    | Resume
    | Reset
    | Restart
    | GotScrollMsg Sub.ScrollMsg
    | NoOp


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NoOp ->
            ( model, Cmd.none )

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

        ScrollAnimate ->
            let
                ( newScrollState, scrollCmd ) =
                    Sub.scroll GotScrollMsg model.scrollState scrollAnimation
            in
            ( { model | scrollState = newScrollState }
            , scrollCmd
            )

        Stop ->
            let
                ( newScrollState, scrollCmd ) =
                    Sub.stop scrollContainer GotScrollMsg model.scrollState
            in
            ( { model | scrollState = newScrollState }
            , scrollCmd
            )

        Pause ->
            ( { model | scrollState = Sub.pause scrollContainer model.scrollState }
            , Cmd.none
            )

        Resume ->
            ( { model | scrollState = Sub.resume scrollContainer model.scrollState }
            , Cmd.none
            )

        Reset ->
            let
                ( newScrollState, scrollCmd ) =
                    Sub.reset scrollContainer GotScrollMsg model.scrollState
            in
            ( { model | scrollState = newScrollState }
            , scrollCmd
            )

        Restart ->
            let
                ( newScrollState, scrollCmd ) =
                    Sub.restart scrollContainer GotScrollMsg model.scrollState
            in
            ( { model | scrollState = newScrollState }
            , scrollCmd
            )



-- ANIMATION


containerId : String
containerId =
    "scroll-container"


scrollContainer : Sub.Container
scrollContainer =
    Sub.Container containerId


targetId : String
targetId =
    "scroll-target"


scrollAnimation : ScrollBuilder -> ScrollBuilder
scrollAnimation =
    Scroll.forContainer containerId
        >> Scroll.toElement targetId
        >> Scroll.speed 200
        >> 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" ]
            [ button [ onClick ScrollAnimate, class "ui-action-button primary" ] [ text "📜 Scroll" ]
            , button [ onClick Stop, class "ui-action-button warning" ] [ text "âšī¸ Stop" ]
            , button [ onClick Pause, class "ui-action-button success" ] [ text "â¸ī¸ Pause" ]
            , button [ onClick Resume, class "ui-action-button success" ] [ text "â–ļī¸ Resume" ]
            , button [ onClick Reset, class "ui-action-button purple" ] [ text "âŽī¸ Reset" ]
            , button [ onClick Restart, class "ui-action-button purple" ] [ text "🔄 Restart" ]
            ]
        , scrollableContainer
        ]


scrollableContainer : Html msg
scrollableContainer =
    div
        [ id containerId
        , style "width" "100%"
        , style "flex" "1 1 auto"
        , style "min-height" "0"
        , style "box-sizing" "border-box"
        , style "border" "2px solid #cbd5e1"
        , style "border-radius" "12px"
        , style "background" "white"
        , style "box-shadow" "0 4px 20px rgba(0, 0, 0, 0.1)"
        , style "overflow-y" "auto"
        ]
        [ contentSections ]


contentSections : Html msg
contentSections =
    div
        [ style "display" "flex"
        , style "flex-direction" "column"
        , style "gap" "20px"
        , style "padding" "20px"
        ]
        (List.concat
            [ [ contentSection "📍 Start" "This is the beginning of the scrollable content." "#3b82f6" ]
            , List.indexedMap
                (\i _ ->
                    contentSection
                        ("Section " ++ String.fromInt (i + 1))
                        "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
                        "#475569"
                )
                (List.repeat 5 ())
            , [ targetSection ]
            , List.indexedMap
                (\i _ ->
                    contentSection
                        ("Section " ++ String.fromInt (i + 7))
                        "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris."
                        "#475569"
                )
                (List.repeat 3 ())
            , [ contentSection "📍 End" "This is the end of the scrollable content." "#3b82f6" ]
            ]
        )


contentSection : String -> String -> String -> Html msg
contentSection title description color =
    div
        [ style "padding" "12px 16px"
        , style "background" "#f8fafc"
        , style "border-radius" "8px"
        ]
        [ div
            [ style "font-weight" "bold"
            , style "font-size" "16px"
            , style "color" color
            , style "margin-bottom" "8px"
            ]
            [ text title ]
        , p
            [ style "font-size" "14px"
            , style "color" "#475569"
            , style "margin" "0"
            ]
            [ text description ]
        ]


targetSection : Html msg
targetSection =
    div
        [ id targetId
        , style "padding" "12px 16px"
        , style "background" "#fff3e0"
        , style "border-radius" "8px"
        , style "border" "2px solid #ff9800"
        ]
        [ div
            [ style "font-weight" "bold"
            , style "font-size" "18px"
            , style "color" "#e65100"
            , style "margin-bottom" "8px"
            ]
            [ text "đŸŽ¯ Target Section" ]
        , p
            [ style "font-size" "14px"
            , style "color" "#475569"
            , style "margin" "0"
            ]
            [ text "This is the scroll target. The scroll animation will bring this section into view." ]
        ]

Querying State

You can ask the engine what's happening at any moment - useful for showing UI ("Scrolling..."), conditionally enabling controls, or making decisions before triggering the next scroll.

View Source Code
-- Is any scroll currently running?
Sub.anyRunning model.scrollState
    -- Maybe Bool

-- Is a specific container scrolling?
Sub.isRunning Sub.Document model.scrollState
    -- Maybe Bool

-- Current scroll position
Sub.getPosition Sub.Document model.scrollState
    -- Maybe { x : Float, y : Float }

-- Single-axis variants
Sub.getPositionX Sub.Document model.scrollState
Sub.getPositionY Sub.Document model.scrollState

All queries return Maybe because the container in question might never have been scrolled.

Multiple Concurrent Scrolls

You can have several scrolls running at once inside a single ScrollState - for example a sidebar and a main panel scrolling independently. Each container is tracked separately, fires its own events, and can be controlled and queried on its own.

Mid-Flight Redirection

Trigger Sub.scroll for the same container while a scroll is in flight, and the engine replaces it - smoothly carrying on from the current position to the new target instead of fighting with the old animation.

📖 See Interrupting Scrolls for a live side-by-side demonstration of all three engines.

Timing

Sub advances each frame with a real animation-frame delta-time, so the configured duration / speed stays close to actual perceived time, even on high-refresh-rate displays.

If you need timing precision, this is the engine to pick.

Check your display's refresh rate

Easing

Defaults to Linear. Any easing from Motion.Easing works via Scroll.easing.

📖 See Easing for the full list and live previews.

When to Choose This Engine

Choose Sub when you need any of:

  • pause / resume / stop / reset / restart,
  • mid-flight redirection,
  • live progress events (scrollbars, percentage readouts, parallax),
  • queries for current scroll position or "is a scroll running?",
  • precise timing that doesn't drift with frame rate.

For everything else, Cmd or Task are simpler.

API Quick Reference

Types

Type Description
ScrollState Lives in your model
ScrollMsg Internal message handled by update and subscriptions
ScrollEvent Started / Ended / Progress / Stopped / Paused / Resumed / Restarted
Container Document or Container "element-id"

Initialize

Function Type Description
init ScrollState Empty initial state

Trigger

Function Type Description
scroll (ScrollMsg -> msg) -> ScrollState -> (ScrollBuilder -> ScrollBuilder) -> ( ScrollState, Cmd msg ) Start or redirect a scroll

Update / Subscribe

Function Type Description
update (ScrollMsg -> msg) -> ScrollMsg -> ScrollState -> ( ScrollState, List ScrollEvent, Cmd msg ) Advance state and emit events
subscriptions (ScrollMsg -> msg) -> ScrollState -> Sub msg Animation-frame subscription

Timing

Function Type Description
delay Int -> ScrollBuilder -> ScrollBuilder Wait before scrolling (ms)
duration Int -> ScrollBuilder -> ScrollBuilder Total scroll time (ms)
speed Float -> ScrollBuilder -> ScrollBuilder Scroll rate (px/sec)

Easing

Function Type Description
easing Easing -> ScrollBuilder -> ScrollBuilder Set the easing curve

Controls

Function Type Description
stop Container -> (ScrollMsg -> msg) -> ScrollState -> ( ScrollState, Cmd msg ) Jump to target and finish
pause Container -> ScrollState -> ScrollState Freeze at current position
resume Container -> ScrollState -> ScrollState Continue a paused scroll
reset Container -> (ScrollMsg -> msg) -> ScrollState -> ( ScrollState, Cmd msg ) Jump to start and finish
restart Container -> (ScrollMsg -> msg) -> ScrollState -> ( ScrollState, Cmd msg ) Reset, then replay

Queries

Function Type Description
anyRunning ScrollState -> Maybe Bool Any scroll running?
isRunning Container -> ScrollState -> Maybe Bool This container scrolling?
getPosition Container -> ScrollState -> Maybe { x : Float, y : Float } Current position
getPositionX Container -> ScrollState -> Maybe Float Current X
getPositionY Container -> ScrollState -> Maybe Float Current Y

Note: The family of getPosition* functions return values stored in the engine. If the user manually scrolls after-the-fact, these functions will not return the current scroll position.

For complete API details, see the Scroll.Engine.Sub documentation.

Next Steps

Now that you know the engines, learn how the same scroll behaves differently when interrupted mid-flight.

Interrupting Scrolls →