Responsive Animations¶
Responsive design is a broad topic, so this page focuses on how Elm Motion keeps animations aligned when layout changes.
Elm Motion supports three responsive animation workflows:
- Relative CSS units - re-evaluated by the browser as layout changes
- Snap Re-Anchoring - snap to a given state when the layout changes
- Measured pixel targets - proportional remapping on resize (
SubandWAAPIonly)
You can mix all strategies on the same page for different animations, or even for different properties or axes in the same animation group.
Path 1 - Relative Units¶
The browser re-evaluates relative CSS units as layout changes. Express your animation in those units and resize behaviour comes for free - no subscriptions, no measurement, no remapping.
Elm Motion exposes the full set of relative units through Anim.Unit:
- element / font units -
Percent,Em,Rem,Lh,Ch, ... - viewport units -
Vw,Vh,Vi,Vb,Vmin,Vmax, plusSv*/Lv*/Dv*variants - container units -
Cqi,Cqb,Cqw,Cqh,Cqmin,Cqmax
View Source Code
dropBall : AnimBuilder eng -> AnimBuilder eng
dropBall =
Translate.begin
>> Translate.cssUnit Cqh
>> Translate.fromY 0
>> Translate.toY 88
>> Translate.speed 75
>> Translate.easing BounceOut
>> Translate.end
1cqh is 1% of the nearest containment context's block size, so the ball drops 88% of the container's height regardless of how the container is sized, and it travels at 75% of the height per second. If you change cssUnit, adjust the numeric values accordingly.
For working examples see:
Path 2 - Snap Re-Anchoring¶
Use this any time you need to snap an animtion to a new state instantly, for example if a user action invalidates a running animation and you need to end it and move it to a new state.
retarget will stop the animation and snap it to the new state. It is available on:
View Source Code
How each engine behaves¶
retarget only touches what your builder mentions. Anything you don't mention is left alone - but what "left alone" looks like depends on the engine:
- Transition and Keyframe - untouched properties/axes snap to their targeted end state. If you retarget only the Y axis of a
Translateanimation, and the X axis is also animating, the Y axis snaps to the targeted value, while the X axis snaps to it's end target value. - Sub and WAAPI - untouched values keep animating along their existing curve. Retargeting only Y snaps Y while X carries on toward its original target uninterrupted.
Example¶
Press Animate diagonally, then mid-flight press Retarget Y to 0 - the builder only mentions the Y axis. Watch what the X axis does:
TransitionandKeyframe- X snaps to it's end value - the right edge.SubandWAAPI- X keeps gliding toward the right edge.
View Example
View Source Code
module Animation.Transition.Retarget.Main exposing (main)
import Anim.Builder exposing (AnimBuilder)
import Anim.Engine.Transition as Transition
import Anim.Property.Translate as Translate
import Anim.Unit exposing (Unit(..))
import Browser
import Html exposing (Html, button, div, p, text)
import Html.Attributes exposing (class, style)
import Html.Events exposing (onClick)
import Motion.Easing exposing (Easing(..))
-- MAIN
main : Program () Model Msg
main =
Browser.element
{ init = \_ -> init
, view = view
, update = update
, subscriptions = \_ -> Sub.none
}
-- MODEL
type alias Model =
{ animState : Transition.AnimState }
animGroup : String
animGroup =
"square"
boxSize : Float
boxSize =
12
endXY : Float
endXY =
100 - boxSize
init : ( Model, Cmd Msg )
init =
( { animState =
Transition.init
[ Translate.initXY animGroup 0 0 >> Translate.cssUnitX Cqw >> Translate.cssUnitY Cqh
]
}
, Cmd.none
)
-- ANIMATION
animateDiagonal : AnimBuilder { eng | withTiming : () } -> AnimBuilder { eng | withTiming : () }
animateDiagonal =
Translate.begin
>> Translate.toXY endXY endXY
>> Translate.duration 5000
>> Translate.easing Linear
>> Translate.end
retargetYToTop : AnimBuilder { eng | withTiming : () } -> AnimBuilder { eng | withTiming : () }
retargetYToTop =
Translate.begin
>> Translate.toY 0
>> Translate.end
-- UPDATE
type Msg
= Animate
| RetargetY
| Reset
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Animate ->
( { model
| animState =
Transition.animate model.animState <|
Transition.for animGroup
>> animateDiagonal
}
, Cmd.none
)
RetargetY ->
( { model
| animState =
Transition.retarget model.animState <|
Transition.for animGroup
>> retargetYToTop
}
, Cmd.none
)
Reset ->
( { model | animState = Transition.reset animGroup model.animState }
, Cmd.none
)
-- VIEW
view : Model -> Html Msg
view model =
div [ class "example-stage" ]
[ div [ class "example-controls" ]
[ button [ onClick Animate, class "ui-action-button primary" ] [ text "▶️ Animate diagonally" ]
, button [ onClick RetargetY, class "ui-action-button warning" ] [ text "⬆️ Retarget Y to 0" ]
, button [ onClick Reset, class "ui-action-button purple" ] [ text "⏮️ Reset" ]
]
, p [ style "margin" "0 0 8px 0", style "font-size" "13px" ]
[ text "Press Animate, then mid-flight press \"Retarget Y to 0\". The Transition engine snaps Y to 0; X to it's end value." ]
, animationArea model.animState
]
animationArea : Transition.AnimState -> Html msg
animationArea animState =
div
[ class "example-canvas--fluid"
, style "container-type" "size"
, style "background-color" "#ffffff"
, style "border" "2px solid #333"
, style "border-radius" "8px"
]
[ div
(Transition.attributes animGroup animState
++ [ style "position" "absolute"
, style "top" "0"
, style "left" "0"
, style "width" (String.fromFloat boxSize ++ "cqw")
, style "height" (String.fromFloat boxSize ++ "cqh")
, style "background-color" "rgba(59, 130, 246, 0.35)"
, style "border" "0.4cqmin solid rgb(59, 130, 246)"
, style "border-radius" "1.6cqmin"
, style "box-sizing" "border-box"
]
)
[]
]
module Animation.Keyframe.Retarget.Main exposing (main)
import Anim.Builder exposing (AnimBuilder)
import Anim.Engine.Keyframe as Keyframe
import Anim.Property.Translate as Translate
import Anim.Unit exposing (Unit(..))
import Browser
import Html exposing (Html, button, div, p, text)
import Html.Attributes exposing (class, style)
import Html.Events exposing (onClick)
import Motion.Easing exposing (Easing(..))
-- MAIN
main : Program () Model Msg
main =
Browser.element
{ init = \_ -> init
, view = view
, update = update
, subscriptions = \_ -> Sub.none
}
-- MODEL
type alias Model =
{ animState : Keyframe.AnimState }
init : ( Model, Cmd Msg )
init =
( { animState =
Keyframe.init
[ Translate.initXY animGroup 0 0 >> Translate.cssUnitX Cqw >> Translate.cssUnitY Cqh
]
}
, Cmd.none
)
animGroup : String
animGroup =
"square"
boxSize : Float
boxSize =
12
endXY : Float
endXY =
100 - boxSize
-- ANIMATION
animateDiagonal : AnimBuilder { eng | withTiming : () } -> AnimBuilder { eng | withTiming : () }
animateDiagonal =
Translate.begin
>> Translate.toXY endXY endXY
>> Translate.duration 5000
>> Translate.easing Linear
>> Translate.end
retargetYToTop : AnimBuilder { eng | withTiming : () } -> AnimBuilder { eng | withTiming : () }
retargetYToTop =
Translate.begin
>> Translate.toY 0
>> Translate.end
-- UPDATE
type Msg
= Animate
| RetargetY
| Reset
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Animate ->
( { model
| animState =
Keyframe.animate model.animState <|
Keyframe.for animGroup
>> animateDiagonal
}
, Cmd.none
)
RetargetY ->
( { model
| animState =
Keyframe.retarget model.animState <|
Keyframe.for animGroup
>> retargetYToTop
}
, Cmd.none
)
Reset ->
( { model | animState = Keyframe.reset animGroup model.animState }
, Cmd.none
)
-- VIEW
view : Model -> Html Msg
view model =
div [ class "example-stage" ]
[ Keyframe.styleNode model.animState
, div [ class "example-controls" ]
[ button [ onClick Animate, class "ui-action-button primary" ] [ text "▶️ Animate diagonally" ]
, button [ onClick RetargetY, class "ui-action-button warning" ] [ text "⬆️ Retarget Y to 0" ]
, button [ onClick Reset, class "ui-action-button purple" ] [ text "⏮️ Reset" ]
]
, p [ style "margin" "0 0 8px 0", style "font-size" "13px" ]
[ text "Press Animate, then mid-flight press \"Retarget Y to 0\". The Keyframe engine snaps Y to 0; X to it's end value." ]
, animationArea model.animState
]
animationArea : Keyframe.AnimState -> Html msg
animationArea animState =
div
[ class "example-canvas--fluid"
, style "container-type" "size"
, style "background-color" "#ffffff"
, style "border" "2px solid #333"
, style "border-radius" "8px"
]
[ div
(Keyframe.attributes animGroup animState
++ [ style "position" "absolute"
, style "top" "0"
, style "left" "0"
, style "width" (String.fromFloat boxSize ++ "cqw")
, style "height" (String.fromFloat boxSize ++ "cqh")
, style "background-color" "rgba(59, 130, 246, 0.35)"
, style "border" "0.4cqmin solid rgb(59, 130, 246)"
, style "border-radius" "1.6cqmin"
, style "box-sizing" "border-box"
]
)
[]
]
module Animation.Sub.Retarget.Main exposing (main)
import Anim.Builder exposing (AnimBuilder)
import Anim.Engine.Sub as Sub
import Anim.Property.Translate as Translate
import Anim.Unit exposing (Unit(..))
import Browser
import Html exposing (Html, button, div, p, text)
import Html.Attributes exposing (class, style)
import Html.Events exposing (onClick)
import Motion.Easing exposing (Easing(..))
-- MAIN
main : Program () Model Msg
main =
Browser.element
{ init = \_ -> init
, view = view
, update = update
, subscriptions = subscriptions
}
-- MODEL
type alias Model =
{ animState : Sub.AnimState }
animGroup : String
animGroup =
"square"
boxSize : Float
boxSize =
12
endXY : Float
endXY =
100 - boxSize
init : ( Model, Cmd Msg )
init =
( { animState =
Sub.init
[ Translate.initXY animGroup 0 0 >> Translate.cssUnitX Cqw >> Translate.cssUnitY Cqh
]
}
, Cmd.none
)
-- ANIMATION
animateDiagonal : AnimBuilder { eng | withTiming : () } -> AnimBuilder { eng | withTiming : () }
animateDiagonal =
Translate.begin
>> Translate.toXY endXY endXY
>> Translate.duration 5000
>> Translate.easing Linear
>> Translate.end
retargetYToTop : AnimBuilder { eng | withTiming : () } -> AnimBuilder { eng | withTiming : () }
retargetYToTop =
Translate.begin
>> Translate.toY 0
>> Translate.end
-- UPDATE
type Msg
= Animate
| RetargetY
| Reset
| GotSubMsg Sub.AnimMsg
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GotSubMsg subMsg ->
let
( newAnimState, _ ) =
Sub.update subMsg model.animState
in
( { model | animState = newAnimState }
, Cmd.none
)
Animate ->
( { model
| animState =
Sub.animate model.animState <|
Sub.for animGroup
>> animateDiagonal
}
, Cmd.none
)
RetargetY ->
( { model
| animState =
Sub.retarget model.animState <|
Sub.for animGroup
>> retargetYToTop
}
, Cmd.none
)
Reset ->
( { model | animState = Sub.reset animGroup model.animState }
, Cmd.none
)
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.subscriptions GotSubMsg model.animState
-- VIEW
view : Model -> Html Msg
view model =
div [ class "example-stage" ]
[ div [ class "example-controls" ]
[ button [ onClick Animate, class "ui-action-button primary" ] [ text "▶️ Animate diagonally" ]
, button [ onClick RetargetY, class "ui-action-button warning" ] [ text "⬆️ Retarget Y to 0" ]
, button [ onClick Reset, class "ui-action-button purple" ] [ text "⏮️ Reset" ]
]
, p [ style "margin" "0 0 8px 0", style "font-size" "13px" ]
[ text "Press Animate, then mid-flight press \"Retarget Y to 0\". The Sub engine snaps Y to 0 while X keeps gliding toward the right edge uninterrupted." ]
, animationArea model.animState
]
animationArea : Sub.AnimState -> Html msg
animationArea animState =
div
[ class "example-canvas--fluid"
, style "container-type" "size"
, style "background-color" "#ffffff"
, style "border" "2px solid #333"
, style "border-radius" "8px"
]
[ div
(Sub.attributes animGroup animState
++ [ style "position" "absolute"
, style "top" "0"
, style "left" "0"
, style "width" (String.fromFloat boxSize ++ "cqw")
, style "height" (String.fromFloat boxSize ++ "cqh")
, style "background-color" "rgba(59, 130, 246, 0.35)"
, style "border" "0.4cqmin solid rgb(59, 130, 246)"
, style "border-radius" "1.6cqmin"
, style "box-sizing" "border-box"
]
)
[]
]
port module Animation.WAAPI.Retarget.Main exposing (main)
import Anim.Builder exposing (AnimBuilder)
import Anim.Engine.WAAPI as WAAPI
import Anim.Property.Translate as Translate
import Anim.Unit exposing (Unit(..))
import Browser
import Html exposing (Html, button, div, p, text)
import Html.Attributes exposing (class, style)
import Html.Events exposing (onClick)
import Json.Encode as Encode
import Motion.Easing exposing (Easing(..))
-- PORTS
port motionCmd : Encode.Value -> Cmd msg
port motionMsg : (Encode.Value -> msg) -> Sub msg
-- MAIN
main : Program () Model Msg
main =
Browser.element
{ init = \_ -> init
, view = view
, update = update
, subscriptions = subscriptions
}
-- MODEL
type alias Model =
{ animState : WAAPI.AnimState Msg }
animGroup : String
animGroup =
"square"
boxSize : Float
boxSize =
12
endXY : Float
endXY =
100 - boxSize
init : ( Model, Cmd Msg )
init =
( { animState =
WAAPI.init motionCmd motionMsg <|
[ Translate.initXY animGroup 0 0 >> Translate.cssUnitX Cqw >> Translate.cssUnitY Cqh
]
}
, Cmd.none
)
-- ANIMATION
animateDiagonal : AnimBuilder { eng | withTiming : () } -> AnimBuilder { eng | withTiming : () }
animateDiagonal =
Translate.begin
>> Translate.toXY endXY endXY
>> Translate.duration 5000
>> Translate.easing Linear
>> Translate.end
retargetYToTop : AnimBuilder { eng | withTiming : () } -> AnimBuilder { eng | withTiming : () }
retargetYToTop =
Translate.begin
>> Translate.toY 0
>> Translate.end
-- UPDATE
type Msg
= Animate
| RetargetY
| Reset
| GotWaapiMsg WAAPI.AnimMsg
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GotWaapiMsg subMsg ->
let
( newAnimState, _ ) =
WAAPI.update subMsg model.animState
in
( { model | animState = newAnimState }
, Cmd.none
)
Animate ->
let
( newAnimState, animCmd ) =
WAAPI.animate model.animState <|
WAAPI.for animGroup
>> animateDiagonal
in
( { model | animState = newAnimState }
, animCmd
)
RetargetY ->
let
( newAnimState, animCmd ) =
WAAPI.retarget model.animState <|
WAAPI.for animGroup
>> retargetYToTop
in
( { model | animState = newAnimState }
, animCmd
)
Reset ->
let
( newAnimState, resetCmd ) =
WAAPI.reset animGroup model.animState
in
( { model | animState = newAnimState }
, resetCmd
)
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
WAAPI.subscriptions GotWaapiMsg model.animState
-- VIEW
view : Model -> Html Msg
view model =
div [ class "example-stage" ]
[ div [ class "example-controls" ]
[ button [ onClick Animate, class "ui-action-button primary" ] [ text "▶️ Animate diagonally" ]
, button [ onClick RetargetY, class "ui-action-button warning" ] [ text "⬆️ Retarget Y to 0" ]
, button [ onClick Reset, class "ui-action-button purple" ] [ text "⏮️ Reset" ]
]
, p [ style "margin" "0 0 8px 0", style "font-size" "13px" ]
[ text "Press Animate, then mid-flight press \"Retarget Y to 0\". The WAAPI engine snaps Y to 0 while X keeps gliding toward the right edge uninterrupted." ]
, animationArea model.animState
]
animationArea : WAAPI.AnimState msg -> Html msg
animationArea animState =
div
[ class "example-canvas--fluid"
, style "container-type" "size"
, style "background-color" "#ffffff"
, style "border" "2px solid #333"
, style "border-radius" "8px"
]
[ div
(WAAPI.attributes animGroup animState
++ [ style "position" "absolute"
, style "top" "0"
, style "left" "0"
, style "width" (String.fromFloat boxSize ++ "cqw")
, style "height" (String.fromFloat boxSize ++ "cqh")
, style "background-color" "rgba(59, 130, 246, 0.35)"
, style "border" "0.4cqmin solid rgb(59, 130, 246)"
, style "border-radius" "1.6cqmin"
, style "box-sizing" "border-box"
]
)
[]
]
Why the difference?¶
The Sub and WAAPI engines have access to mid-flight values, so untouched properties/axes can continue on their way untouched, as intended.
Mid-flight values are not available for CSS transitions or @keframes animations, so untouched properties/axes can't be reconfigured when mid-flight. Therefore they snap to their targeted end state to ensure they finish correctly and as intended.
Path 3 - Measured Pixel Values¶
Use this path when your animation depends on direct pixel values, it is supported by the Sub and WAAPI engines.
Unlike relative values which the browser can re-evaluate when the layout changes, pixel values remain fixed, so animations using pixel values need to be told about the layout change.
This is done by giving the Engine the new bounds for the animation. The bounds represent the space on the page the animation can operate in. All supporting properties have their own bounds builder function which takes a Bounds record - which is then passed to the Engine's onResize function:
View Source Code
import Anim.Property.Translate exposing (AxisBounds)
bounds : AxisBounds
bounds =
{ x = Nothing
, y = Nothing
, z = Nothing
}
OnResize w h ->
let
max =
case getOrientation w h of
Portrait ->
50
Landscape ->
100
in
( { model
| animState =
Sub.onResize model.animState <|
Translate.bounds "logoAnim" { bounds | x = Just {min = 0, max = max} }
}
, Cmd.none
)
import Anim.Property.Translate exposing (AxisBounds)
bounds : AxisBounds
bounds =
{ x = Nothing
, y = Nothing
, z = Nothing
}
OnResize w h ->
let
max =
case getOrientation w h of
Portrait ->
50
Landscape ->
100
( animState, animCmd ) =
WAAPI.onResize model.animState <|
Translate.bounds "logoAnim" { bounds | x = Just {min = 0, max = max} }
in
( { model | animState = animState }
, animCmd
)
When switching from Portrait to Landscape, the logoAnim animation group will adjust it's position on the X axis proportionally. So if it is at x=25 in Portrait (50% of the width) and the user switches to Landscape, it will be remapped to x=50 (50% of the new width), and it's X axis end value will be remapped to x=100, the new max.
bounds can only be paired with onResize, attempting to use it with a Trigger function like animate will produce a type error.
Responsive Tooling¶
| Function | Where | What it helps with |
|---|---|---|
retarget |
Transition, Keyframe, Sub, WAAPI | Re-anchor elements immediately when a layout change makes the old target wrong. |
onResize |
Sub, WAAPI | Apply new bounds to active and idle animations after a layout change. |
bounds |
Translate, Scale, PerspectiveOrigin, Size | Set min/max property bounds after a layout change. |
clamp* |
Translate, Rotate, Scale, Skew, Opacity, Custom | Keep animated values inside safe limits. |
unclamp* |
Translate, Rotate, Scale, Skew, Opacity, Custom | Remove animation limits. |