Skip to content

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:

  1. Relative CSS units - re-evaluated by the browser as layout changes
  2. Snap Re-Anchoring - snap to a given state when the layout changes
  3. Measured pixel targets - proportional remapping on resize (Sub and WAAPI only)

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, plus Sv* / 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
TriggerLayoutChanged ->
    ( { model | animState = Transition.retarget model.animState newLayoutTarget }
    , Cmd.none
    )
TriggerLayoutChanged ->
    ( { model | animState = Keyframe.retarget model.animState newLayoutTarget }
    , Cmd.none
    )
TriggerLayoutChanged ->
    ( { model | animState = Sub.retarget model.animState newLayoutTarget }
    , Cmd.none
    )
TriggerLayoutChanged ->
    let
        ( animState, animCmd ) =
            WAAPI.retarget model.animState newLayoutTarget
    in
    ( { model | animState = animState }
    , animCmd
    )

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 Translate animation, 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:

  • Transition and Keyframe - X snaps to it's end value - the right edge.
  • Sub and WAAPI - 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.

Next Steps

Engines Overview Controlling Animations