Skip to content

Interrupting Scrolls

Re-triggering a scroll mid-flight is handled differently by each engine. Only Scroll.Sub redirects automatically; with Cmd and Task the new scroll runs in parallel with the old one, and gating that is on you.

Engine On re-trigger
Cmd Runs in parallel - gate it yourself (track an isScrolling flag, debounce, etc.).
Task Runs in parallel - same as Cmd
Sub Replaces the running scroll, picking up smoothly from the current position. Fires a Stopped event for the interrupted scroll first.

The example below shows the same two-button scroll in each engine - mash the buttons to feel the difference.

View Example

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

import Browser
import Html exposing (Html, button, div, 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.Cmd as Cmd exposing (ScrollBuilder)



-- MAIN


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



-- MODEL


type alias Model =
    { activeScrolls : Int }



-- UPDATE


type Msg
    = ScrollTo String
    | ScrollComplete


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

        ScrollComplete ->
            ( { model | activeScrolls = max 0 (model.activeScrolls - 1) }
            , Cmd.none
            )


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



-- VIEW


view : Model -> Html Msg
view model =
    div [ class "example-stage" ]
        [ div [ class "example-controls" ]
            [ button
                [ onClick (ScrollTo "top-target")
                , class "ui-action-button primary"
                ]
                [ text "⬆️ Scroll to Top" ]
            , button
                [ onClick (ScrollTo "bottom-target")
                , class "ui-action-button warning"
                ]
                [ text "⬇️ Scroll to Bottom" ]
            ]
        , statusBar model.activeScrolls
        , 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 : Int -> Html msg
statusBar active =
    let
        ( color, message ) =
            if active == 0 then
                ( "#94a3b8", "Idle — try clicking one button, then the other before it finishes" )

            else if active == 1 then
                ( "#3b82f6", "1 scroll running" )

            else
                ( "#ef4444"
                , String.fromInt active
                    ++ " scrolls running in parallel — they will compete"
                )
    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 ]


scrollContent : Html msg
scrollContent =
    div [ style "padding" "20px" ]
        [ targetBlock "top-target" "🟢 Top Target" "#22c55e"
        , spacer
        , filler "Scroll through me…"
        , spacer
        , filler "…keep scrolling…"
        , spacer
        , targetBlock "bottom-target" "🔴 Bottom Target" "#ef4444"
        ]


targetBlock : String -> String -> String -> Html msg
targetBlock 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" "clamp(16px, 3vmin, 24px)"
        , style "font-weight" "700"
        ]
        [ text label ]


filler : String -> Html msg
filler label =
    div
        [ style "padding" "16px"
        , style "background" "#f1f5f9"
        , style "border-radius" "8px"
        , style "color" "#475569"
        , style "text-align" "center"
        ]
        [ p [ style "margin" "0" ] [ text label ] ]


spacer : Html msg
spacer =
    div [ style "height" "300px" ] []
module Scroll.Task.Interrupting.Main exposing (main)

import Browser
import Html exposing (Html, button, div, 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.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 =
    { activeScrolls : Int }


init : ( Model, Cmd Msg )
init =
    ( { activeScrolls = 0 }
    , 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 | activeScrolls = model.activeScrolls + 1 }
            , Task.attempt ScrollResult <|
                Task.scroll <|
                    scrollToElement targetId
            )

        ScrollResult _ ->
            ( { model | activeScrolls = max 0 (model.activeScrolls - 1) }
            , Cmd.none
            )


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



-- VIEW


view : Model -> Html Msg
view model =
    div [ class "example-stage" ]
        [ div [ class "example-controls" ]
            [ button
                [ onClick (ScrollTo "top-target")
                , class "ui-action-button primary"
                ]
                [ text "⬆️ Scroll to Top" ]
            , button
                [ onClick (ScrollTo "bottom-target")
                , class "ui-action-button warning"
                ]
                [ text "⬇️ Scroll to Bottom" ]
            ]
        , statusBar model.activeScrolls
        , 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 : Int -> Html msg
statusBar active =
    let
        ( color, message ) =
            if active == 0 then
                ( "#94a3b8", "Idle — try clicking one button, then the other before it finishes" )

            else if active == 1 then
                ( "#3b82f6", "1 scroll running" )

            else
                ( "#ef4444"
                , String.fromInt active
                    ++ " scrolls running in parallel — they will compete"
                )
    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 ]


scrollContent : Html msg
scrollContent =
    div [ style "padding" "20px" ]
        [ targetBlock "top-target" "🟢 Top Target" "#22c55e"
        , spacer
        , filler "Scroll through me…"
        , spacer
        , filler "…keep scrolling…"
        , spacer
        , targetBlock "bottom-target" "🔴 Bottom Target" "#ef4444"
        ]


targetBlock : String -> String -> String -> Html msg
targetBlock 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" "clamp(16px, 3vmin, 24px)"
        , style "font-weight" "700"
        ]
        [ text label ]


filler : String -> Html msg
filler label =
    div
        [ style "padding" "16px"
        , style "background" "#f1f5f9"
        , style "border-radius" "8px"
        , style "color" "#475569"
        , style "text-align" "center"
        ]
        [ p [ style "margin" "0" ] [ text label ] ]


spacer : Html msg
spacer =
    div [ style "height" "300px" ] []
module Scroll.Sub.Interrupting.Main exposing (main)

import Browser
import Html exposing (Html, button, div, 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
    , status : ScrollStatus
    }


type ScrollStatus
    = Idle
    | Scrolling
    | Interrupted
    | 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 targetId ->
            let
                redirecting =
                    model.status == Scrolling

                ( newScrollState, scrollCmd ) =
                    Sub.scroll GotScrollMsg model.scrollState <|
                        scrollToElement targetId
            in
            ( { model
                | scrollState = newScrollState
                , status =
                    if redirecting then
                        Interrupted

                    else
                        Scrolling
              }
            , 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 status =
    case event of
        Sub.Ended _ ->
            Arrived

        Sub.Started _ ->
            if status == Interrupted then
                Interrupted

            else
                Scrolling

        _ ->
            status


scrollToElement : String -> ScrollBuilder -> ScrollBuilder
scrollToElement targetId =
    Scroll.forContainer "scroll-container"
        >> Scroll.toElement targetId
        >> Scroll.speed 120
        >> Scroll.easing Linear
        >> 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 (ScrollTo "top-target")
                , class "ui-action-button primary"
                ]
                [ text "⬆️ Scroll to Top" ]
            , button
                [ onClick (ScrollTo "bottom-target")
                , class "ui-action-button warning"
                ]
                [ text "⬇️ 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", "Idle — try clicking one button, then the other before it finishes" )

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

                Interrupted ->
                    ( "#a855f7", "Redirected mid-flight — smoothly continuing to new target" )

                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 "flex" "0 0 auto"
        ]
        [ text message ]


scrollContent : Html msg
scrollContent =
    div [ style "padding" "20px" ]
        [ targetBlock "top-target" "🟢 Top Target" "#22c55e"
        , spacer
        , filler "Scroll through me…"
        , spacer
        , filler "…keep scrolling…"
        , spacer
        , targetBlock "bottom-target" "🔴 Bottom Target" "#ef4444"
        ]


targetBlock : String -> String -> String -> Html msg
targetBlock 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" "clamp(16px, 3vmin, 24px)"
        , style "font-weight" "700"
        ]
        [ text label ]


filler : String -> Html msg
filler label =
    div
        [ style "padding" "16px"
        , style "background" "#f1f5f9"
        , style "border-radius" "8px"
        , style "color" "#475569"
        , style "text-align" "center"
        ]
        [ p [ style "margin" "0" ] [ text label ] ]


spacer : Html msg
spacer =
    div [ style "height" "300px" ] []

Next Steps

Choose the easing curve that shapes how each scroll accelerates and decelerates.

Easing →