DEV Community

Cover image for Elm Beginners Tutorial: How to make animated snackbars with zero CSS
lucamug
lucamug

Posted on • Updated on

Elm Beginners Tutorial: How to make animated snackbars with zero CSS

Demo & Code

Alt Text

This is a beginner tutorial to build animated snackbars. It should be understandable also if you never coded in Elm before. If you are already familiar with Elm you can skip the first section Short Elm overview.

Short Elm overview

Find here some random concept about Elm specifically related to this tutorial for a more serious or comprehensive introduction to Elm you should refer to the official guide.

In Elm everything is a function and functions are NOT written like

var sum = function(x, y) { return x + y; };
Enter fullscreen mode Exit fullscreen mode

or

const sum = (x, y) => x + y;
Enter fullscreen mode Exit fullscreen mode

but like

sum x y = x + y
Enter fullscreen mode Exit fullscreen mode

it is basically the same as your favorite language but without all the interstitial decorations.

Sometime above the function there is the "type signature". It describes the type of arguments that go in and out. It is optional but I usually write them everywhere. Like in

sum: Int -> Int -> Int
sum x y = x + y
Enter fullscreen mode Exit fullscreen mode

The last thing in the type signature, after the last arrow, is always the type of the returned value as functions can only return one value.

Some other time we will find triangles (โ–ท โ—) in the code . These are "pipe operators" and will display as triangles only if we use a font with ligatures, otherwise they will just render as |> <|.

The pipe operators reduce the needs of parentheses and in Elm we like uncluttered code. In practices they pass stuff from one side to the other.

So for example, let's suppose we have 4 functions calling each other:

f ( g ( h ( i "Ciao" ) ) )
Enter fullscreen mode Exit fullscreen mode

the same thing can be written as

f <| g <| h <| i "Ciao"
Enter fullscreen mode Exit fullscreen mode

or

"Ciao" |> i |> h |> g |> f
Enter fullscreen mode Exit fullscreen mode

that is usually formatted as

"Ciao"
    |> i
    |> h
    |> g
    |> f
Enter fullscreen mode Exit fullscreen mode

No need to count parenthesis anymore ๐ŸŽ‰

For more info about syntax, refer to the official syntax documentation

In Elm we can define our own types, that is pretty cool ๐Ÿ˜Ž

For example:

type Fruit = Apple | Pear | Banana
Enter fullscreen mode Exit fullscreen mode

is a type that can be either Apple, Pear or Banana.

When instead we see the word "alias" after "type", that is not a type definition but we are just giving a different name to the existing type. For example

type alias HealthyFood = Fruit
Enter fullscreen mode Exit fullscreen mode

Elm applications are usually written following what we call TEA that stand for The Elm Architecture. It is just a loop that waits for events (clicks, etc.) and when they happen, it sends them to us so that we can react and change the interface accordingly.

This animation explains The Elm Architecture cycle:

Alt Text

All that we need to do is to write the 2 functions on the right of the diagram:

  • The update that take the event (we call "message" in Elm) together with the model that is a data structure containing the entire state of the application and return a new model (and possibly other stuff)
  • The view that simply convert the model into HTML

Moreover:

  • All data is immutable
  • Functions must be pure (i.e. "No side effects" and "Same input == Same output")
  • Function start with a small case letter (e.g. sum), types or type aliases with capital case letter (e.g. Fruit)
  • Instead of objects Elm has records. They resemble Javascript objects but instead of : they need = as in { x = 3, y = 4 }. Also being immutable we don't do point.x = 42 but we can do { point | x = 42 } that is like saying "Please make a copy of point and change only the x value". If you are coming from Javascript, you can find this and other useful examples in https://elm-lang.org/docs/from-javascript
  • We will use mdgriffith/elm-ui, the library that will take care of smartly generate HTML and CSS for us, so we don't need to write them. elm-ui has some resemblance to CSS, for example the concept of padding. But it is quite different from CSS, for example it doesn't have margin but rather spacing that is simply the distance between children. Other two main concepts are column and row that are not existing in HTML/CSS.

I think this is all that we need to know about Elm. Elm is a pretty simple language as it has a small but expressive set of language constructs. This doesn't mean that we cannot write complex applications with it.

Some of the Javascript concepts that are not present (or not needed) in Elm are:

  • null
  • undefined
  • this
  • closures
  • callbacks
  • promise
  • async/await
  • hoisting
  • prototypes
  • class
  • apply/call/bind
  • truthiness
  • try...catch
  • type coercion
  • throw
  • var/let/const
  • return
  • for/do...while
  • spread syntax
  • rest syntax
  • new
  • super
  • ++
  • +=
  • == vs. ===
  • IIFE
  • value vs. reference
  • yield/generators
  • runtime exceptions
  • order of execution
  • strange results are better than errors

Some of the concepts that are in Elm but not in Javascript are:

  • immutability
  • purity
  • custom types
  • pattern matching
  • function composition
  • static types
  • type inference
  • a compiler
  • errors at compile time are better than strange results

In Elm we don't write a list of operations to be performed in order but rather we define a list of pure functions that call each other.

In the case of a "list of operations" we need to keep track of everything computed to that point, that is the "state". Changing the state is the "side effect" of the operations.

In the case of "list of pure functions", there is no need to keep track of anything (i.e. stateless) because the output of pure functions depends completely on the input. The code can be reasoned just by looking at a functionโ€™s input and output.

An as John Carmack once said "A large fraction of the flaws in software development are due to programmers not fully understanding all the possible states their code may execute inโ€

To learn more:

https://guide.elm-lang.org/
https://package.elm-lang.org/packages/elm/core/latest/

Tutorial

  • The Model data structure
  • The Messages
  • The update function
  • The view function
  • Wire all together

This is what we will get at the end

Alt Text

Demo & Code

The Model data structure

Let's decide the structure of the model that will contain the entire state of the application.

But wait, why are we talking about the state of the application while we are also saying that functional programming is stateless?

How can we change such a state while remaining pure?

The trick is simple. We pass the-entire-state-of-the-applications to functions that need to modify it and they will return an updated version of the the-entire-state-of-the-applications.

This is why the update function has this type signature

update : Msg -> Model -> Model
Enter fullscreen mode Exit fullscreen mode

It is saying "I want to modify the-entire-state-of-the-applications (model) but I also want to remain pure so please pass me the model, I will make a copy of it and I will return to you the new version without changing the old version".

The Elm Runtime will then take this new Model and, with the help of the view function, will change the state of the browser. So eventually is only the Elm Runtime that can have side effects. 100% of the code that we write is pure, without side effects.

But let's move back to our tutorial.

A description of a Snackbar can be

  • Some content, for example text, images, etc.
  • A style, for example a background color
  • A state that describe if a snackbar is Entering, is Active or is Leaving the view
  • The lifeSpan, that is the duration in milliseconds of the snackbar, 0 meaning forever active

In code:

type alias Snackbar =
    { content : Element.Element Msg
    , style : Style
    , state : State
    , lifeSpan : Float
    }
Enter fullscreen mode Exit fullscreen mode

Let me explain a bit more why the type of content is Element.Element Msg.

The prefix Element. in the reference to the external package elm-ui. The second Element is actually a type (capitalized first letter) defined inside the library.

In the following example snippets I will omit the prefix Element. because I usually import this library with import Element exposing (..) that will automatically expose all of the functions/types of the module so that Element.Element can be written as Element, Element.columns as column, etc.

All other functions also live in modules but they don't need any import or prefix because they are already imported and exposed by default

Why is elm-ui not imported as import Ui? This is because this is not an enforced rule. I opened an issue to make this more explicit in the documentation.

Back to the tutorial.

For the state that describe if a snackbar is Entering, is Active or is Leaving the view we use a custom type:

type State
    = Entering Float
    | Active Float
    | Leaving Float
Enter fullscreen mode Exit fullscreen mode

The number attached to each state will tell us how "old" is the snackbar in each state. It is the number of milliseconds since the snackbar entered that state.

This is an example of snackbar life time:

added to the Model => Entering 0.000
Entering 0.033
Entering 0.066
...
Entering 0.966
Entering 1.000 => change state to Active 0.000
Active   0.001
Active   0.002
...
Active   0.999
Active   1.000 => change state to Leaving 0.000
Leaving  0.033
Leaving  0.066
...
Leaving  0.966
Leaving  1.000 => removed from the Model
Enter fullscreen mode Exit fullscreen mode

Numbers increase 60 times per seconds and the quantity they increase depends on the speed of each transition.

They are always in the range 0 ~ 1. 0 is the beginning of the transition, 1 is the end.

For example if we want the Entering transition to last 0.5 seconds (30 increases), each time we should increase by 1/(60 * 0.5) = 1/30 = 0.03333 so that after 30 increases, it reaches 1.

Don't worry if this is confusing. Just remember that the number always moves gradually from 0 to 1.

At any point in time there can be 0 or more snackbar on the page so the entire state of the application (the Model) will be

type alias Model = List Snackbar
Enter fullscreen mode Exit fullscreen mode

When the application load the first time there will be no snackbars so we can initialize the Model with an empty list:

initModel : Model
initModel = []
Enter fullscreen mode Exit fullscreen mode

Note that in the demo we actually pre-populate the model with some snackbar examples.

The Messages

Let's make a list of action that we want to perform

  • Add a snackbar
  • Remove a snackbar
  • Remove all snackbars

Now we can translate this into a list of Messages that will tell to our update function what to do

type Msg
    = Add Snackbar
    | Close Int
    | CloseAll
    | OnAnimationFrame Time.Posix
Enter fullscreen mode Exit fullscreen mode

We also added OnAnimationFrame Time.Posix that is a special message that fires approximately 60 times per second and is synced with the browser's repainting. This is the same of Javascript requestAnimationFrame and is the beast approach to build smooth animations.

Also note that Add requires a Snackbar while Close requires an Int that is the position in the list of the snackbar that we want to remove.

The update function

The update function, in its simplest form, has this type signature

update : Msg -> Model -> Model
Enter fullscreen mode Exit fullscreen mode

A more advanced form is

update : Msg -> Model -> (Model, Cmd.msg)
Enter fullscreen mode Exit fullscreen mode

This version can handle Commands but we don't need them for this tutorial.

Commands are needed when we need the runtime to do some side effects but if the only side effects that we need are related to changing the page, these are already taken care of by the view function.

An example of Command is a request to send an HTTP request.

Going back to the simplest form, this is the skeleton:

update : Msg -> Model -> Model
update msg model =
    case msg of
        Add snackbar ->
            ...

        Close index ->
            ...

        CloseAll ->
            ...

        OnAnimationFrame _ ->
            ...
Enter fullscreen mode Exit fullscreen mode

Let's start filling the [...] in order of simplicity

Add

Adding s snackbar is trivial:

snackbar :: model
Enter fullscreen mode Exit fullscreen mode

:: is the operator that appends an item at the beginning of the list. The new snackbar should be in the state Entering 0 but we don't enforce it here.

This is too simple so let's rewrite in a more complicated way:

model
    |> (::) snackbar
Enter fullscreen mode Exit fullscreen mode

The reason why we use the form above is for readability. ๐Ÿค”

It will be clearer later once the update function is completed.

Note that (::) is the same as :: but it accepts the argument as a regular function, all of them on the right side. It is called infix notation and yes, the :: operator, like all other operators, is actually a function.

CloseAll

This is also pretty simple. We want to change the state of all snackbar to Leaving 0 meaning "The beginning of the Leaving transition".

Let's make a mini-helper as it can be used in multiple places:

close : Snackbar -> Snackbar
close snackbar =
    { snackbar | state = Leaving 0 }
Enter fullscreen mode Exit fullscreen mode

then we close all the snackbars

model
    |> List.map close
Enter fullscreen mode Exit fullscreen mode

Close

This is also pretty simple as it resembles CloseAll but should be applied to only one snackbar. Considering index as the position of the snackbar in the list of snackbars we can use List.indexedMap:

model
    |> List.indexedMap (\index_ snackbar -> closeIf (index_ == index) snackbar)
Enter fullscreen mode Exit fullscreen mode

Few notes here

  • List.indexedMap is like an usual map function but also gives the index of each item used to detect the snackbar that we want to close.
  • When things start with "\" (that looks like a "ฮป" if we squint, a tribute to Lambda Calculus), it means it is an anonymous function. We use this when we need a throwaway function to be used only once. So, the sum functions explained at the beginning sum a b = a + b can be written as sum = \a b -> a + b.
  • index_ == index is the condition that we use to decide if we close a snackbar or not. The decision is taken by this little helper.
closeIf : Bool -> Snackbar -> Snackbar
closeIf bool snackbar =
    if bool then
        close snackbar

    else
        snackbar
Enter fullscreen mode Exit fullscreen mode

OnAnimationFrame

Now we need to take care of the last section of the update function: OnAnimationFrame. This is a two step process:

  1. We increase the "age" associate to the state and, if the number reaches 1, we change to the next state.
  2. We remove all snackbars that reached the final state of Leaving 1
model
    |> List.map (\snackbar -> { snackbar | state = nextState snackbar.lifeSpan snackbar.state })
    |> List.filter (\snackbar -> snackbar.state /= Leaving 1)
Enter fullscreen mode Exit fullscreen mode

The first step is using couple of extra functions:

nextState : Float -> State -> State
nextState lifeSpan state =
    case state of
        Entering value ->
            nextStateHelper (value + durationEntering) Entering (Active 0)

        Active value ->
            nextStateHelper (value + lifeSpan) Active (Leaving 0)

        Leaving value ->
            nextStateHelper (value + durationLeaving) Leaving (Leaving 1)


nextStateHelper : number -> (number -> a) -> a -> a
nextStateHelper newValue thisState_ nextState_ =
    if newValue > 1 then
        nextState_

    else
        thisState_ newValue
Enter fullscreen mode Exit fullscreen mode

Done! This is all about the update function that is the core of our application. This is how the final results looks like:

update : Msg -> Model -> Model
update msg model =
    case msg of
        Add snackbar ->
            model
                |> (::) snackbar

        Close index ->
            model
                |> List.indexedMap (\index_ snackbar -> closeIf (index_ == index) snackbar)

        CloseAll ->
            model
                |> List.map close

        OnAnimationFrame _ ->
            model
                |> List.map (\snackbar -> { snackbar | state = nextState snackbar.lifeSpan snackbar.state })
                |> List.filter (\snackbar -> snackbar.state /= Leaving 1)
Enter fullscreen mode Exit fullscreen mode

See how all cases now follow a model |> pattern?

The view function

Let's start with some helpers.

This is a function that, depending on the state, it calculate the proper sizes of the snackbar:

calculateValues : State -> { alpha : Float, height : Float, width : Float, font : Float, widthTimer : Float }
calculateValues state =
    case state of
        Entering value ->
            { alpha = value
            , height = maxHeight * value
            , width = maxWidth * value
            , font = maxFont * value
            , widthTimer = 0
            }

        Active value ->
            { alpha = 1
            , height = maxHeight
            , width = maxWidth
            , font = maxFont
            , widthTimer = maxWidth * value
            }

        Leaving value ->
            { alpha = 1 - value
            , height = maxHeight * (1 - value)
            , width = maxWidth * (1 - value)
            , font = maxFont * (1 - value)
            , widthTimer = maxWidth * (1 - value)
            }
Enter fullscreen mode Exit fullscreen mode

Thanks to the fact that value goes from 0 to 1, all these calculations are trivial. alpha is the same as the opacity in CSS.

Now we can apply the calculated value to render the snackbar. Let's enter in the elm-ui realm

viewSnackbar : Int -> Snackbar -> Element Msg
viewSnackbar index snackbar =
    let
        calculated =
            calculateValues snackbar.state
    in
    Input.button
        [ centerX
        , height <| px <| round calculated.height
        ]
        { onPress = Just <| Close index
        , label =
            el
                [ paddingEach { top = 10, right = 0, bottom = 0, left = 0 }
                , height fill
                ]
            <|
                column
                    ([ Border.rounded 10
                     , clip
                     , width <| px <| round calculated.width
                     , height fill
                     , alpha calculated.alpha
                     ]
                        ++ snackbar.style
                    )
                    [ row
                        [ width fill
                        , centerY
                        , paddingXY 10 0
                        , fontSize <| calculated.font
                        ]
                        [ snackbar.content
                        , el [ alignRight ] <| text "โœ•"
                        ]
                    , el
                        [ height <| px 5
                        , width <| px <| round calculated.widthTimer
                        , Background.color <| rgba 1 1 1 0.5
                        , alignBottom
                        ]
                      <|
                        none
                    ]
        }
Enter fullscreen mode Exit fullscreen mode

This is all needed to render one snackbar. I believe it is quite intuitive. I can write a separate post about this if there are questions.

To render all the snackbars:

viewSnackbars : Model -> List (Element Msg)
viewSnackbars model =
    List.indexedMap (\index snackbar -> viewSnackbar index snackbar) model
Enter fullscreen mode Exit fullscreen mode

and to add this to a generic page:

view : Model -> Html.Html Msg
view model =
    layout
        [ inFront <|
            column
                [ alignBottom
                , moveUp 20
                , centerX
                ]
            <|
                List.indexedMap
                    (\index snackbar -> viewSnackbar index snackbar)
                    model
        ]
    <|
        text "Your regular page here"
Enter fullscreen mode Exit fullscreen mode

Wire all together

Time to wire all together! We need to tell the Elm runtime where to find the update and view functions and how to initialize the Model.

Elm has several ways to connect these things together based on the level of expertise of the developer. For this demo we could almost use the simpler, the Browser.sandbox

sandbox :
    { init : model
    , view : model -> Html msg
    , update : msg -> model -> model
    }
    -> Program () model msg
Enter fullscreen mode Exit fullscreen mode

The only thing that is missing here is that we need to subscribe to the browser's onAnimationFrame and this is only allowed from the next level, the Browser.element

element :
    { init : flags -> ( model, Cmd msg )
    , view : model -> Html msg
    , update : msg -> model -> ( model, Cmd msg )
    , subscriptions : model -> Sub msg
    }
    -> Program flags model msg
Enter fullscreen mode Exit fullscreen mode

This level introduces 3 new concepts, the Commands, the Flags and the Subscriptions.

We don't need either Commands nor Flags. Commands are a way to tell Elm to do some side effects, as we discussed earlier, while Flags are a system to pass values from Javascript at the start of the application.

Anyway, let's plug everything, assuming small modifications are done so that all type signature matches:

main : Program () Model Msg
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions =
            \model ->
                if List.isEmpty model then
                    Sub.none

                else
                    Browser.Events.onAnimationFrame OnAnimationFrame
        }
Enter fullscreen mode Exit fullscreen mode

Couple of notes here.

  • The () in the type signature, in the place that contain the type of Flags, means that we are no expecting any Flag. () is the unit type that allows only one value so it cannot hold any information.
  • The subscription to OnAnimationFrame is done only in the case that at least one snackbar exists, otherwise is not necessary and the application will keep looping when there is no need for it.

If you are interested in animation there are several packages in the Elm repository, including elm-playground and elm-animator.

This is all. Thank you for reading!

Demo & Code


Picture in the cover: "The interior of a snack bar in the Netherlands" by Takeaway CC BY-SA 4.0

Top comments (0)