Simple performant CSS animations in Elm with Animate.css
2020-03-31

I remembered using a project called Animate.css in the bad old JS days, and wanted to see how easy it would be to use in an Elm application.

Animate.css provides a set of cross-browser CSS animations using opacity and transform. They work across browsers and have good performance. From an Elm perspective, it’s also convenient not to have to track fine-grained animation state in the model and let the browser do it instead.

It’s also cool that Animate.css takes accessibility into account by supporting the prefers-reduced-motion media query. It’s well supported and allows users to disable CSS transitions by setting an option for reduced motion in OS settings.

General usage

To use Animate.css, we just need to include its CSS file in the page:

<head>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.7.2/animate.min.css">
</head>

An element can be animated by adding a few classes:

<h1 class="animated infinite pulse faster delay-2s">Animated header</h1>

There are quite a number of animations included: fading, zooming, rotations, slides and so on.

The infinite class makes the animation loop indefinitely. Without it, it only plays once.

It’s also possible to delay the start of an animation by adding a class such as delay-1s or delay-2s. Animations can be sped up or slowed down with the help of slow, slower, fast and faster classes.

An animated button

Let’s make a button that starts pulsing on hover:

Animated button

The Elm code looks like this:

module Main exposing (..)

import Browser
import Html exposing (..)
import Html.Attributes as Attr
import Html.Events exposing (..)


type AnimationState
    = None
    | Pulse


type Msg
    = UserHoveredButton
    | UserUnhoveredButton


type alias Model =
    { animationState : AnimationState }


update : Msg -> Model -> Model
update msg model =
    case msg of
        UserHoveredButton ->
            { model | animationState = Pulse }

        UserUnhoveredButton ->
            { model | animationState = None }


view : Model -> Html Msg
view model =
    [ button
        [ Attr.style "width" "150px"
        , Attr.style "border" "3px solid #000"
        , Attr.style "border-radius" "6px"
        , Attr.style "background-color" "#fff"
        , Attr.style "font-size" "32px"
        , Attr.classList
            [ ( "animated", model.animationState == Pulse )
            , ( "pulse", model.animationState == Pulse )
            , ( "infinite", model.animationState == Pulse )
            ]
        , onMouseEnter <| UserHoveredButton
        , onMouseLeave <| UserUnhoveredButton
        ]
        [ text "Button" ]
    ]
        |> div
            [ Attr.style "width" "150px"
            , Attr.style "margin" "100px auto 0 auto"
            , Attr.style "text-align" "center"
            ]


main : Program () Model Msg
main =
    Browser.sandbox
        { init = { animationState = None }
        , view = view
        , update = update
        }

In the view function, I’m conditionally adding the animation classes depending on the state of the button. The animation state is changed via two event handlers: onMouseEnter and onMouseLeave.

More buttons!

What if I want to have three animated buttons instead of one? It would look like this:

Three animated buttons

Then I have to keep track of their state in the model, and I need to know which button is producing the messages. To keep things simple, we can go with a Dict of animation states keyed by a string ID in the model, and send the ID of the active button along with the UserHoveredButton and UserUnhoveredButton messages.

Here is the code with the relevant changes:

module Main exposing (..)

import Browser
import Dict exposing (Dict)
import Html exposing (..)
import Html.Attributes as Attr
import Html.Events exposing (..)


type AnimationState
    = None
    | Pulse


type Msg
    = UserHoveredButton String
    | UserUnhoveredButton String


type alias Model =
    { animationState : Dict String AnimationState }


update : Msg -> Model -> Model
update msg model =
    case msg of
        UserHoveredButton id ->
            { model | animationState = Dict.insert id Pulse model.animationState }

        UserUnhoveredButton id ->
            { model | animationState = Dict.insert id None model.animationState }


view : Model -> Html Msg
view model =
    List.range 1 3
        |> List.map
            (\i ->
                let
                    animState =
                        Dict.get (String.fromInt i) model.animationState
                            |> Maybe.withDefault None
                in
                div
                    []
                    [ button
                        [ Attr.style "width" "150px"
                        , Attr.style "border" "3px solid #000"
                        , Attr.style "border-radius" "6px"
                        , Attr.style "background-color" "#fff"
                        , Attr.style "font-size" "32px"
                        , Attr.classList
                            [ ( "animated", animState /= None )
                            , ( "pulse", animState /= None )
                            , ( "infinite", animState /= None )
                            ]
                        , onMouseEnter <| UserHoveredButton <| String.fromInt i
                        , onMouseLeave <| UserUnhoveredButton <| String.fromInt i
                        ]
                        [ text "Button" ]
                    ]
            )
        |> div
            [ Attr.style "width" "150px"
            , Attr.style "margin" "100px auto 0 auto"
            , Attr.style "text-align" "center"
            ]


main : Program () Model Msg
main =
    Browser.sandbox
        { init = { animationState = Dict.fromList [ ( "1", None ), ( "2", None ), ( "3", None ) ] }
        , view = view
        , update = update
        }

Still quite straightforward!

More animation!

How about fancier animations? Let’s say I want each button to rotate first and then pulse on hover:

Three animated buttons

On hover, each button will do a single rotateIn animation and then switch to an infinite pulse animation.

To do that, I need to know when the first part of the animation finishes. At that point I can change the class from rotateIn to pulse. Luckily, that’s possible to do by handling the animationend event.

The types need to change to support this extra snazzy UI:

type AnimationState
    = None
    | Rotate
    | Pulse


type Msg
    = BrowserFinishedAnimation String
    | UserHoveredButton String
    | UserUnhoveredButton String

Each button will now cycle through three animation states, and there is a new message corresponding to the animationend event.

The update function becomes a bit more involved:

update : Msg -> Model -> Model
update msg model =
    case msg of
        BrowserFinishedAnimation id ->
            let
                nextState =
                    Maybe.map (\s -> if s == Rotate then Pulse else s)
            in
            { model | animationState = Dict.update id nextState model.animationState }

        UserHoveredButton id ->
            let
                nextState =
                    Maybe.map (\s -> if s == None then Rotate else s)
            in
            { model | animationState = Dict.update id nextState model.animationState }

        UserUnhoveredButton id ->
            { model | animationState = Dict.insert id None model.animationState }

Lastly, the view function gets more elaborate when building the class list, and each button acquires an on "animationend" attribute:

view : Model -> Html Msg
view model =
    List.range 1 3
        |> List.map
            (\i ->
                let
                    animState =
                        Dict.get (String.fromInt i) model.animationState
                            |> Maybe.withDefault None
                in
                div
                    [ onMouseEnter <| UserHoveredButton <| String.fromInt i
                    , onMouseLeave <| UserUnhoveredButton <| String.fromInt i
                    ]
                    [ button
                        [ Attr.style "width" "150px"
                        , Attr.style "border" "3px solid #000"
                        , Attr.style "border-radius" "6px"
                        , Attr.style "background-color" "#fff"
                        , Attr.style "font-size" "32px"
                        , Attr.classList
                            [ ( "animated", animState /= None )
                            , ( if animState == Rotate then "rotateIn" else "pulse", animState /= None )
                            , ( "infinite", animState == Pulse )
                            ]
                        , on "animationend" <| Decode.succeed <| 
                            BrowserFinishedAnimation <| String.fromInt i
                        ]
                        [ text "Button" ]
                    ]
            )
        |> div
            [ Attr.style "width" "150px"
            , Attr.style "margin" "100px auto 0 auto"
            , Attr.style "text-align" "center"
            ]

You may notice one more change: each button is wrapped in a div, and the hover/unhover messages are now triggered from the div rather than the button itself.

The reason for this is that the rotation animation changes the boundaries of the element, which in turn triggers a chain reaction of onMouseLeave/onMouseEnter events and multiple restarts of the animation. Moving the events to a parent element solves the problem.

Other options

There are other options for implementing animation. You can roll your own CSS. You can roll your own animations in Elm using the onAnimationFrame event.

(I was amused to see that searching for “animation” on the package site brings up a link to onAnimationFrame as well as a list of animation-related packages.)

Alternatively, you can also use an Elm package like mdgriffith/elm-style-animation or z5h/timeline for pure Elm animation.

These approaches have different tradeoffs.

Conclusion

My experiment shows that Animate.css can be combined with Elm, and is an easy way to add simple prepackaged CSS animations to the UI.

Interesting effects can be achieved by combining multiple animations.

Would you like to dive further into Elm?
📢 My book
Practical Elm
skips the basics and gets straight into the nuts-and-bolts of building non-trivial apps.
🛠 Things like building out the UI, communicating with servers, parsing JSON, structuring the application as it grows, testing, and so on.
Practical Elm