Skip to content

ScrollTimeline Engine

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

The ScrollTimeline Engine is a lightweight engine that uses the Browsers native ScrollTimeline API. It ties animation progress to the scroll position of a scrollable element. As the user scrolls, the animation progresses — no AnimState required. update and subscriptions are optional, and only needed if you want to react to lifecycle events.

Example

Scroll the page, and the progress bar will animate in response.

View Example

View Source Code
port module Animation.ScrollTimeline.Main exposing (main)

import Anim.Builder exposing (AnimBuilder)
import Anim.Engine.ScrollTimeline as ScrollTimeline exposing (Container(..))
import Anim.Extra.Color as Color exposing (Color)
import Anim.Property.CustomColor as CustomColor exposing (ColorProperty(..))
import Anim.Property.Scale as Scale
import Browser
import Html exposing (Html, div, h2, p, span, text)
import Html.Attributes exposing (class, id, style)
import Json.Encode as Encode
import Motion.Easing as Easing exposing (Easing(..))



-- PORTS


port motionCmd : Encode.Value -> Cmd msg



-- MAIN


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



-- ANIMATION


progressBarAnim : String
progressBarAnim =
    "scrollProgress"





scrollProgress : ScrollTimeline.EngineBuilder -> ScrollTimeline.EngineBuilder
scrollProgress =
    ScrollTimeline.for progressBarAnim
        >> Scale.begin
        >> Scale.fromX 0
        >> Scale.toX 1
        >> Scale.end
        >> CustomColor.begin BackgroundColor
        >> CustomColor.from Color.red
        >> CustomColor.to Color.green
        >> CustomColor.end



-- INIT


init : ( (), Cmd msg )
init =
    ( ()
    , ScrollTimeline.animate motionCmd Document scrollProgress
    )



-- VIEW


view : () -> Html msg
view _ =
    div
        [ style "font-family" "system-ui, sans-serif"
        , style "color" "#1f2937"
        , style "background" "#ffffff"
        ]
        [ -- Fixed progress bar at top of page
          div
            [ style "position" "fixed"
            , style "top" "0"
            , style "left" "0"
            , style "width" "100%"
            , style "height" "5px"
            , style "background" "#e5e7eb"
            , style "z-index" "100"
            ]
            [ div
                (ScrollTimeline.attributes progressBarAnim
                    ++ [ style "width" "100%"
                       , style "height" "100%"
                       , style "transform-origin" "left center"
                       , style "transform" "scaleX(0)"
                       ]
                )
                []
            ]
        , div
            [ style "text-align" "center"
            , style "padding" "clamp(48px, 10vh, 80px) clamp(20px, 5vw, 40px) clamp(28px, 6vh, 40px)"
            , style "background" "linear-gradient(135deg, #ede9fe, #ddd6fe)"
            ]
            [ h2
                [ style "font-size" "clamp(1.6rem, 4vw + 0.8rem, 2.5rem)"
                , style "font-weight" "700"
                , style "margin" "0 0 16px"
                , style "color" "#4c1d95"
                ]
                [ text "Scroll Timeline" ]
            , p
                [ style "font-size" "clamp(0.95rem, 1vw + 0.6rem, 1.1rem)"
                , style "color" "#6d28d9"
                , style "margin" "0"
                ]
                [ text "Scroll down and watch the bar fill as the page moves from top to bottom." ]
            ]

        -- Scrollable content cards
        , div
            [ style "max-width" "700px"
            , style "margin" "0 auto"
            , style "padding" "clamp(36px, 8vh, 60px) clamp(16px, 5vw, 40px)"
            , style "display" "flex"
            , style "flex-direction" "column"
            , style "gap" "clamp(28px, 6vh, 60px)"
            ]
            (List.map contentCard cards)
        ]


type alias CardData =
    { label : String
    , color : String
    , title : String
    , body : String
    }


cards : List CardData
cards =
    [ { label = "01"
      , color = "#6366f1"
      , title = "Top to bottom"
      , body = "The timeline starts at 0% at the top of the page and reaches 100% at the bottom."
      }
    , { label = "02"
      , color = "#8b5cf6"
      , title = "One scroll, two effects"
      , body = "The same timeline drives both size and color, so one scroll gesture controls the whole bar."
      }
    , { label = "03"
      , color = "#a78bfa"
      , title = "Read progress at a glance"
      , body = "Short red bar means early in the page, long green bar means you are near the end."
      }
    , { label = "04"
      , color = "#7c3aed"
      , title = "Simple trigger"
      , body = "Call ScrollTimeline.animate once in init, then the browser keeps everything in sync while you scroll."
      }
    , { label = "05"
      , color = "#5b21b6"
      , title = "Easy to reuse"
      , body = "Attach ScrollTimeline.attributes to any element and map scroll progress to the properties you want."
      }
    ]


contentCard : CardData -> Html msg
contentCard card =
    div
        [ style "display" "flex"
        , style "gap" "clamp(14px, 3vw, 24px)"
        , style "align-items" "flex-start"
        , style "padding" "clamp(20px, 4vw, 32px)"
        , style "background" "white"
        , style "border-radius" "16px"
        , style "box-shadow" "0 4px 24px rgba(99,102,241,0.08)"
        ]
        [ span
            [ style "font-size" "clamp(1.4rem, 3vw + 0.4rem, 2rem)"
            , style "font-weight" "800"
            , style "color" card.color
            , style "flex-shrink" "0"
            , style "line-height" "1"
            , style "padding-top" "4px"
            ]
            [ text card.label ]
        , div []
            [ h2
                [ style "font-size" "clamp(1.05rem, 1.5vw + 0.5rem, 1.3rem)"
                , style "font-weight" "700"
                , style "margin" "0 0 10px"
                , style "color" "#111827"
                ]
                [ text card.title ]
            , p
                [ style "font-size" "clamp(0.9rem, 1vw + 0.5rem, 1rem)"
                , style "line-height" "1.7"
                , style "color" "#6b7280"
                , style "margin" "0"
                ]
                [ text card.body ]
            ]
        ]

Quick Walkthrough

Here's a general workflow to get up an running quickly.

1. Build

View Source Code
import Anim.Property.Opacity as Opacity


scrollAnimation : ScrollTimeline.AnimBuilder eng -> ScrollTimeline.AnimBuilder eng
scrollAnimation =
    Opacity.begin
        >> Opacity.from 0
        >> Opacity.to 1
        >> Opacity.end

2. Render

Render attributes on the element being animated.

View Source Code
view : Html Msg
view =
    div (ScrollTimeline.attributes "progress") [ text "Progress" ]

3. Trigger with animate

Call animate to send a fire-and-forget scroll-driven animation command.

View Source Code
port module Main exposing (main)

import Anim.Engine.ScrollTimeline as ScrollTimeline
import Json.Encode


port motionCmd : Json.Encode.Value -> Cmd msg


startScrollAnimation : Cmd Msg
startScrollAnimation =
    ScrollTimeline.animate motionCmd ScrollTimeline.Document <|
        ScrollTimeline.for "progress"
            >> scrollAnimation

4. Optional React

Subscribe only when you need lifecycle events in Elm. See Subscriptions and Update for full event handling.

View Source Code
import Json.Decode


port motionMsg : (Json.Decode.Value -> msg) -> Sub msg


type Msg
    = GotScrollMsg ScrollTimeline.AnimMsg


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GotScrollMsg animMsg ->
            case ScrollTimeline.update animMsg of
                Just (ScrollTimeline.Ended _) ->
                    ( model, Cmd.none )

                _ ->
                    ( model, Cmd.none )


subscriptions : Model -> Sub Msg
subscriptions _ =
    ScrollTimeline.subscriptions GotScrollMsg motionMsg

In Detail

Trigger

This engine uses the same JavaScript companion as the WAAPI engine, but only the outgoing port is needed, the incoming port is optional.

📖 See WAAPI JavaScript for CDN and NPM install instructions.

Browser support

ScrollTimeline is part of the CSS Scroll-Driven Animations spec. Check caniuse.com for current browser support. The @phollyer/elm-motion companion automatically loads the scroll-timeline-polyfill when the native API is not available.

Fire-and-forget. Returns a Cmd msg with no state to store.

View Source Code
port module Main exposing (main)

import Json.Encode

port motionCmd : Json.Encode.Value -> Cmd msg

ScrollTimeline.animate motionCmd (Container "carousel") scrollAnimation

📖 See Triggering Animations for more info.

Update

Use update to process incoming messages and return a Maybe AnimEvent.

View Source Code
GotScrollMsg animMsg ->
    case ScrollTimeline.update animMsg of
        Just (ScrollTimeline.Ended animGroup) ->
            handleAnimationEnd animGroup model

        Just (ScrollTimeline.Iteration animGroup count) ->
            handleIteration animGroup count model

        _ ->
            ( model, Cmd.none )

Events

The ScrollTimeline, ViewTimeline and WAAPI Engines all utilize the JavaScript Web Animations API, and they all use the same ports to communicate with the JS companion. If you use two or more of these engines in your Elm App, depending on your setup, there is the potential for them all to receive the same messages from JS at the same time, which could be confusing.

The library has you covered here though, all incoming messages are gated by each Engine, which is why update returns a Maybe AnimEvent - Nothing means the message was not for this Engine.

Every event carries the animation group name. Some events carry an additional value:

  • Iteration includes the iteration count (Int)
  • AnimError carries an error string from the JavaScript layer
View Source Code
handleAnimEvent : Maybe ScrollTimeline.AnimEvent -> Model -> ( Model, Cmd Msg )
handleAnimEvent maybeEvent model =
    case maybeEvent of
        Just (ScrollTimeline.Ended "hero-card") ->
            ( model, Cmd.none )

        Just (ScrollTimeline.Iteration "hero-card" count) ->
            ( model, Cmd.none )

        Just (ScrollTimeline.AnimError err) ->
            ( model, Cmd.none )

        _ ->
            ( model, Cmd.none )
Event Fires when...
Started Animation begins playing
Ended Animation completes
Cancelled Animation is cancelled before completing
Iteration Each iteration completes (looping or alternating)
AnimError The JavaScript layer reports an error

Subscriptions

Pass the message constructor and the incoming events port to receive lifecycle events.

View Source Code
port motionMsg : (Json.Decode.Value -> msg) -> Sub msg

subscriptions : Model -> Sub Msg
subscriptions _ =
    ScrollTimeline.subscriptions GotScrollMsg motionMsg

📖 See React for more info.

View

Apply attributes to the animated element to attach the required animation group identifier.

View Source Code
div
    (ScrollTimeline.attributes "hero-card")
    [ text "I animate as the user scrolls" ]

📖 See Render for more info.

Axis

Vertical scroll is the default. Use horizontal in the animation pipeline when the container scrolls left and right.

View Source Code
ScrollTimeline.animate motionCmd (Container "carousel") <|
    ScrollTimeline.horizontal
        >> ScrollTimeline.for "slide"
        >> Opacity.begin
        >> Opacity.from 0
        >> Opacity.to 1
        >> Opacity.end

Playback

iterations and alternate work the same as in other engines, but loopForever is not supported - it makes no sense for a scroll driven timeline.

📖 See Playback for iterations and alternate APIs with live examples.

Easing

Set the default easing for all properties that don't override it:

View Source Code
fadeIn =
    ScrollTimeline.easing CubicInOut
        >> ScrollTimeline.for "card"
        >> Opacity.begin
        >> Opacity.to 1
        >> Opacity.end

📖 See Easing for available easing functions.

Spring

Set the default spring for all properties that don't override it: The spring's motion is pre-baked into densely-spaced keyframe stops driven by the scroll timeline.

View Source Code
bouncyReveal =
    ScrollTimeline.spring Spring.wobbly
        >> ScrollTimeline.for "card"
        >> Opacity.begin
        >> Opacity.to 1
        >> Opacity.end

📖 See Spring for the full preset list and tuning guidance.

Discrete Properties

The ScrollTimeline engine manages discrete properties as inline styles. discreteEntry values are applied immediately when the animation starts, and discreteExit values flip when the animation completes. No additional view setup is needed.

📖 See Discrete Properties for the full API, live examples, and source code.

Transform Order

Use transformOrder to set the order in which transform properties are applied.

Call it after for to set the current animation group's order. Call it before selecting a group to set the global default for groups that do not override it.

View Source Code
import Anim.Extra.TransformOrder exposing (TransformProperty(..))

ScrollTimeline.animate motionCmd (Container "carousel") <|
    ScrollTimeline.for "slide"
        >> ScrollTimeline.transformOrder [ Scale, Rotate, Translate ]
        >> Translate.begin
        >> ...

📖 See Transform Order for full details.

When to Choose This Engine

Choose ScrollTimeline when animation progress should be directly tied to scroll position.

  • Best for: progress bars, scroll-driven reveals, and container-linked choreography.
  • Avoid when: you need a time based Engine with related behaviour.

API Quick Reference

Types

Type Description
AnimBuilder eng Carries all animation configuration
AnimMsg Internal engine messages
AnimEvent Events returned by update
AnimGroupName String type alias for the animation group name
Container Scroll source — Document or Container "id"
TransformProperty Custom transform ordering

Trigger

Function Type Description
animate (Value -> Cmd msg) -> Container -> (AnimBuilder eng -> AnimBuilder eng) -> Cmd msg Fire-and-forget scroll-driven animation

Events

Event Description
Ended AnimGroupName Animation completes
Cancelled AnimGroupName Float Animation cancelled; Float is progress at cancellation
Iteration AnimGroupName Int Loop iteration completes; Int is iteration count
AnimError String JavaScript-layer error

Update

Function Type Description
update AnimMsg -> Maybe AnimEvent Process messages and return an optional event

Subscriptions

Function Type Description
subscriptions (AnimMsg -> msg) -> ((Value -> msg) -> Sub msg) -> Sub msg Subscribe to animation events from JavaScript

View

Function Type Description
attributes AnimGroupName -> List (Html.Attribute msg) Attach the animation group identifier to an element

Axis

Function Type Description
horizontal AnimBuilder eng -> AnimBuilder eng Use horizontal scroll as the timeline source

Playback

Function Type Description
iterations Int -> AnimBuilder eng -> AnimBuilder eng Set number of iterations
alternate AnimBuilder eng -> AnimBuilder eng Reverse direction on each iteration

Easing

Function Type Description
easing Easing -> AnimBuilder eng -> AnimBuilder eng Set the easing function

Spring

Function Type Description
spring Spring -> AnimBuilder eng -> AnimBuilder eng Set spring physics

Discrete Properties

Function Type Description
discreteEntry String -> String -> AnimBuilder eng -> AnimBuilder eng Set a CSS property value when the animation starts
discreteExit String -> String -> String -> AnimBuilder eng -> AnimBuilder eng Set a CSS property value during and after the animation

Transform Order

Function Type Description
transformOrder List TransformProperty -> AnimBuilder eng -> AnimBuilder eng Set custom transform order

For complete API details, see the Anim.Engine.ScrollTimeline documentation.

Next Steps

Explore the ViewTimeline Engine:

View Timeline Engine

Or review migration paths and tradeoffs.

Migration Guide →