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:
- Browsers resets to normalize cross-browser differences.
- Components (currently only contains a fluid
.container
class). - 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 forpb
,pr
andpl
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 setflex: 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 tonone
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.
- 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 theguessInput
in the model to an empty string. We can do that by returning the following in theupdate
function's branch for theGuessSubmitted
message(for thePlaying
state only):
( { model
| state = Playing secretNumber newGuess
, guessInput = ""
}
, Cmd.none
)
- 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 theupdate
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 )
- 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.
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: