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
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
3. Subscribe¶
Only the Sub engine needs subscriptions:
View Source Code
4. Trigger¶
Each engine starts the same scroll definition a little differently:
View Source Code
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
)
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
2. Initialize¶
Only the Sub engine keeps state in the model:
View Source Code
3. Subscribe¶
Only the Sub engine needs subscriptions:
View Source Code
4. Trigger¶
Each engine starts the same scroll definition a little differently:
View Source Code
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
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
2. Initialize¶
Only the Sub engine keeps state in the model:
View Source Code
3. Subscribe¶
Only the Sub engine needs subscriptions:
View Source Code
4. Trigger¶
Each engine starts the same scroll definition a little differently:
View Source Code
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
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.