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,Restartedevents.
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
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
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
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
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
pause and resume are state-only - they return just ScrollState:
View Source Code
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.