The PET Stack - Part III

Welcome to the final post of the PET-Stack series. If you've missed the previous parts, here they are: Part I and Part II. By now, we have written a small guessing game with Elm, a delightful functional language for the frontend and use the convenient Parcel bundler to build for development and production.

The game works, but is still pretty raw and looks like this:

It lacks some pretty styling and the UX could use some ❤️. We're going to add some styles with TailwindCSS.

T is for Tailwind

Tailwind is different from frameworks like Bootstrap, in that it doesn't make any assumptions about your components and uses a so-called utility-first approach. This means that instead of classes like .button or .modal-dialog you have much more focussed classes, like .text-right and .flex-row that usually only do one thing. This may sound like more work at first, but we'll see in a bit how this approach is very composable and leads to better reuse of styles across your application. It also goes really well with Elm's functional programming paradigm, having lots of self-contained, single-responsibility components that lend themselves very well to composition.

Things you'll notice when writing Tailwind include:

  • you barely need to write CSS at all
  • no more having to come up with class names
  • easy to build custom layouts

Tailwind comes with a built-in color palette and spacing scale and break points for different screen sizes. Everything is fully customizable in Tailwind's config, but unless you have specific requirements, this should be rarely necessary.

Installing Tailwind

TailwindCSS is a plugin for PostCSS, which can transform CSS, kind of like Babel does with JavaScript. Parcel supports PostCSS out of the box. To use it, we need to prepare a few things. First, thing we need to do is install tailwind.

# we do not explicitly need to install postcss because parcel will install it
# for us once it detects a postcss.config.js file
npm i -D tailwindcss

Then we add a postcss.config.js in the root of our project.

module.exports = {
  plugins: [ require("tailwindcss") ]
};

Tailwind consists of three parts:

  1. Browsers resets to normalize cross-browser differences.
  2. Components (currently only contains a fluid .container class).
  3. All utility classes which we'll be working with.

Lastly, we replace the content of src/style.css with this so that Tailwind's parts will get imported into out stylesheet.

/* resets */
@tailwind base;

/* components */
@tailwind components;

/* utility classes */
@tailwind utilities;

Some basics

Tailwind's classes are very easy to learn and mostly self-explanatory. Here's a few examples:

Spacing

The spaces are defined in rem so that they adjust when the page's font-size changes.

  • .p-1 == padding: 0.25rem; all-around padding
  • .p-4 == padding: 1rem; bigger all-around padding
  • .px-1 == padding-left: 0.25rem; padding-right: 0.25rem; horizontal-padding
  • .py-1 == padding-top: 0.25rem; padding-bottom: 0.25rem; vertical-padding
  • .pt-1 == padding-top: 0.25rem;, also works for pb, pr and pl

If you want margins instead, simply substitute p with m

Color

All the classes that set colors use Tailwind's default color palette. All colors (not black or white) come in a 9 shades from 100 to 900.

  • .text-white == color: #FFFFFF;
  • .text-gray-400 == color: #CBD5E0;
  • .bg-black == background-color: #000000;
  • .bg-gray-400 == background-color: #CBD5E0;
  • .border-white == border-color: #FFFFFF;

Responsiveness and pseudo-classes

Tailwind generates classes with prefixes to model pseudo-classes and media queries.

  • .w-full == width: 100%; on all screens.
  • .sm:w-1/2 == width: 50% on sm-screens an up.
  • .md:w-1/2 == width: 50% on md-screens an up.
  • .lg:w-1/2 == width: 50% on lg-screens an up.
  • .hover:opacity-50 == opacity: 0.5 when hovered.
  • .focus:bg-white == background-color: #FFFFFF when focused.

The full reference of classes can be found here.

Making our app pretty

Designing is often an iterative process. Unfortunately, going through all the steps to arrive at the final results would be beyond the scope of this post. Instead I'll show how to style one of our components as an example: The input field, which we have in our viewGuessInput function.

  input
    [ type_ "text"
    , value guessInput
    , placeholder "13"
    , onInput GuessChange
    , class "w-full p-2 border border-green-400 hover:border-green-600 focus:border-green-600 md:flex-1"
    , class "text-center rounded shadow focus:outline-none focus:shadow-outline"
    ]
    []

Let's disect, what's happending here. I have added some Tailwind classes using Elm's class function. It can be used multiple times on the same element. When this is done, the classes won't override each other, but rather keep appending classes together. Since with Tailwind, we often have very many classes, breaking them up into multiple class attributes is a handy trick to keep lines shorter and things more readable. In this case, I also use the first class call to add classes related to layout and add other styles in the second class call. This grouping can help to quickly find a class, when returning to this code later.

Let's check what the styles are:

  • w-full p-2 md:flex-1: We use 100% width and a padding of 0.5 rem. On medium screens and larger, we also set flex: 1 to make sure our element grows or shrinks according to available space(the input's parent will be a flex container).
  • border border-green-400 hover:border-green-600 focus:border-green-600: We set a thin border width and use a green tone for the color. When the input is hovered or focused, the color is darkened.
  • text-center rounded: We use centered text, because that fits our layout better and smooth the appearance of the input with rounded corners.
  • shadow focus:outline-none focus:shadow-outline: We add a small box shadow to make the input stand out a bit. Browsers often add an outline on focus, which doesn't look particularly good and also will ignore rounded corners. To fix that, we set the outline to none and a different outline using box shadow that will also display with rounded corners.

After this little warm-up excercise with the input field, here is the entire view part with styles and some small refactorings. Since we want our game to be playable on the go, the layout is designed to work mobile-first, but more on that below.

-- VIEW

commonButtonClasses : Html.Attribute msg
commonButtonClasses =
    class "p-2 text-white bg-blue-400 rounded shadow-lg focus:outline-none hover:bg-blue-600 focus:bg-blue-600"


viewGuessInput : String -> Html Msg
viewGuessInput guessInput =
    div
        [ class "flex flex-col flex-wrap items-center mx-auto mt-4 max-w-screen-sm md:flex-row"
        , class "text-gray-600"
        ]
        [ p [ class "w-full mb-2" ] [ text "What's your guess?" ]
        , input
            [ type_ "text"
            , value guessInput
            , placeholder "13"
            , onInput GuessChange
            , class "w-full p-2 border border-green-400 hover:border-green-600 focus:border-green-600 md:flex-1"
            , class "text-center rounded shadow focus:outline-none focus:shadow-outline"
            ]
            []
        , button
            [ type_ "button"
            , onClick GuessSubmitted
            , commonButtonClasses
            , class "w-full mt-2 md:ml-4 md:mt-0 md:flex-1"
            ]
            [ text "Submit"
            ]
        ]


viewResult : String -> Order -> Html Msg
viewResult guessInput order =
    let
        resultClasses =
            class "text-xl tracking-widest text-blue-600 uppercase"
    in
    case order of
        LT ->
            div []
                [ p [ resultClasses ] [ text "Too Small" ]
                , viewGuessInput guessInput
                ]

        GT ->
            div []
                [ p [ resultClasses ] [ text "Too Big" ]
                , viewGuessInput guessInput
                ]

        EQ ->
            div []
                [ p [ resultClasses ] [ text "Correct" ]
                , button
                    [ type_ "button"
                    , onClick Restart
                    , commonButtonClasses
                    , class "mt-2"
                    ]
                    [ text "Start Over" ]
                ]


view : Model -> Browser.Document Msg
view model =
    { title = "PET Stack Guessing Game"
    , body =
        [ div [ class "flex flex-col items-center justify-center h-full px-4 text-center" ]
            [ h1 [ class "pt-2 mb-4 text-4xl text-blue-600" ] [ text "PET Stack Guessing Game" ]
            , div []
                [ case model.state of
                    Initializing ->
                        text "Generating Number"

                    Playing num guess ->
                        case guess of
                            NotGuessed ->
                                div []
                                    [ p [] [ text "" ]
                                    , viewGuessInput model.guessInput
                                    ]

                            Guessed difference ->
                                viewResult model.guessInput difference

                            InvalidGuess ->
                                div []
                                    [ p [ class "text-red-500" ] [ text "You didn't enter a valid number" ]
                                    , viewGuessInput model.guessInput
                                    ]
                ]
            ]
        ]
    }

The whole view is now styled. Go ahead and read through the code a bit to understand what classes are used and what they do. Since our game is small, the layout is very simple. We use a container with the classes flex flex-col to create a vertical layout direction. On bigger screens we want to take up more horizontal space and place the input and the button next to each other. We achieve that, by switching to a row layout on starting at medium screen sizes, by adding md:flex-row. This pattern, as well as Tailwind's responsive classes can be used to create responsive layouts easily.

Next, we'll cover some small UX improvements to make the game flow a bit smoother.

UX improvements

We now have some styling in place. But there are a couple of things we could do to, make the game flow a bit smoother.

  1. Right now, the player always has to hit backspace to remove the value in the input field after a wrong guess before entering a new one. It would be nice if the field was cleared every time automatically. To achieve this, we need to set the value on the input field, which is already done in the view code above. On top of that, we need to reset the guessInput in the model to an empty string. We can do that by returning the following in the update function's branch for the GuessSubmitted message(for the Playing state only):
  ( { model
      | state = Playing secretNumber newGuess
      , guessInput = ""
    }
  , Cmd.none
  )
  1. Also currently, for example, when we guess a number that's too small two times in a row, the text saying "Too Small" just stays there unchanged. This can feel like a bug even though everything worked, because we don't get any visual response from our input. We can fix this, by removing the text saying "Too Small" as soon as we type something else in the input field. This way, the players will see the Text disappear and reappear every time they enter a new guess. To achieve this behavior, we need to adjust our handling of the GuessChange message in the update function like this:
  GuessChange str ->
      let
          newState =
              case model.state of
                  Playing secretNumber _ ->
                      -- set the guess to NotGuessed so that no text will be displayed
                      Playing secretNumber NotGuessed

                  _ ->
                      model.state
      in
      ( { model | guessInput = str, state = newState }, Cmd.none )
  1. Right now, the input field loses focus, whenever the submit button is pressed. For quicker guessing, it would be nice if the focus would be set back to the input after every guess submission. This can be implemented using Browser.Dom.focus. I will leave this as an excercise to the reader. 😉

Here's the full code for our finished game. It came a long way. By now it should like this:

Optimizing

When we run npm run build, Parcel shows us the asset sizes of the output.

CSS asset size

The CSS is quite big with more than 800 KB. Then again, it is also no surprise. Tailwind generates many classes for us and even combinations of classes and modifyers. Wouldn't it be good if we had a way to only pay the price for the classes we use instead of all of them? Luckily, there's a tool for that, which will remove about 99% of our CSS bloat.

First, we need install PurgeCSS, another POSTCSS plugin, which can do dead-code elimination for CSS.

npm i -D @fullhuman/postcss-purgecss

Then, we configure it in the postcss.config.js. Since it's an optimization, we only add in for production builds. PurgeCSS uses a configurable regex to check for usages of classnames in your files. Whatever it finds, will be kept in the final CSS. Whatever classname isn't found, will be discarded. This sounds very simple, but works amazingly well. Let's change postcss.config.js to look like this.

const purgecss = require('@fullhuman/postcss-purgecss')({
  // Specify the paths to all of the template files in your project
  content: ['./src/**/*.elm'],

  // Include any special characters you're using in this regular expression
  defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || []
});

module.exports = {
  plugins: [
    require('tailwindcss'),
    ...(process.env.NODE_ENV === 'production' ? [purgecss] : [])
  ]
};

Since, PurgeCSS will aggressively remove everything it could not match with it's regex in the source files, we need to disable it for some parts of our stylesheet like Tailwind's components and resets. We can do that with special comments in src/style.css. Now PurgeCSS will show mercy for resets and components but throw out all unused utility classes.

/* purgecss start ignore */
@tailwind base;

@tailwind components;
/* purgecss end ignore */

@tailwind utilities;

When you now run npm run build again, you should see that the CSS filesize has descreased dramatically to less than 10 KB. 🥳

Recap

In this post we added styles to our game. Notice, how we didn't have to write a single CSS class. This is quite common in small projects that use Tailwind. Even in bigger projects I have found, that I may write only a handful of classes myself, for things that don't come with Tailwind out-of-the-box, like animations, or very custom things like background gradients. Either way, the CSS needed is usually just a fraction of what is needed with traditional CSS frameworks. I like how this frees me from having to make up names for classes and lets me focus on styling my app nicely.

We covered installing TailwindCSS, how some basic classes work, responsiveness, colors and reducing CSS file size with PurgeCSS. We also added some UX improvements to make the game nicer to play.

This concludes this series about the PET Stack. I hope you learned a thing or two. If you want to start a new PET Stack project, you can check out this script I made, which quickly scaffolds a new project with Parcel, Elm and Tailwind.

If you want to see a live PET stack application in action, check out the source code of this website. I hope you enjoyed this series of posts. Maybe you will consider using Parcel, Elm or Tailwind or all of them in your future projects. It's certainly my favorite way to build web apps right now. 😊

Here's again some links to documentation:


More Posts