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.
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.
Let’s make a button that starts pulsing on hover:
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
.
What if I want to have three animated buttons instead of one? It would look like this:
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!
How about fancier animations? Let’s say I want each button to rotate first and then pulse on hover:
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.
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.
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.