(Almost) Pure CSS Material-like Text Fields

Posted 2019-09-19

Despite what you may believe from simply looking at this site, I’ve actually done quite a bit of front-end development. A couple of years ago, I worked on a project with a friend of mine. For part of the project, he’d designed the behavior of a form control inspired by Material Design which I then built from scratch. Recently, he asked me to remind him how I’d implemented it, and I thought I’d take the opportunity to turn it into a blog post.

Here’s what it looks like:

It’s not quite pure CSS, but it’s pretty close. Let’s think about how this is put together.

At a high level, the appearance of the text field at any given moment is the result of two CSS classes, focused and populated, being added and removed via JavaScript. On this page, I’ve simply written a few lines of code to add and remove them at the proper times, but in practice this is probably best done through your frontend JavaScript framework (Angular/React/Vue/…), if you’re using one.

First, let’s talk about the moving placeholder. While CSS does have a ::placeholder pseudo-element that we can use for styling how the placeholder attribute of the <input> is displayed, unfortunately we can’t use it here because we want the placeholder to remain visible while the user edits the field, and the browser-supplied placeholder vanishes when the field isn’t empty.

Another semantically-useful way to display this is the <label> element, so that’s what I’ve used. The label is absolutely positioned to appear over the <input> where you’d expect the placeholder. So our basic markup looks like this:

<div class="form-group">
  <label class="control-label">
    First Name
  </label>
  <input type="text" class="form-control">
</div>

When the populated class is applied to the form-group div, an extra CSS rule gets applied to the control-label, changing its position, size, and color. CSS transitions are used to gently animate the movement.

The next interesting element is the heavy bottom border. It would be nice if we could simply use border-bottom on the <input>, but we want to animate it collapsing and expanding, and that wouldn’t be possible using border-bottom without also collapsing and expanding the content of the text input, which we definitely don’t want.

The solution I came up with was to use the ::after pseudo-element to just display a block of color. At rest, it has width: 0, but when the focused class is applied to the containing form-group, then it gets width: 100% and is again animated using CSS transitions.

This is annoyingly close to pure CSS. There are some hacks that can get even closer to being pure CSS, like using the CSS sibling combinator ~ to write rules like

.form-control:focus ~ .control-label {
  /* the control is focused, move the label to the top */
}

but the ultimate stumbling block is that there’s no way to use the current value of the text input in a CSS rule, so we can’t make the label disappear when the input is blurred and non-empty. You can of course use an attribute selector in your CSS like input:not([value='']), but this only considers the actual original attribute value, not whatever it might get changed to by the user later on. You could of course write some JavaScript to make that happen, but if you’ve resorted to JavaScript then you may as well just use the easier and cleaner approach that toggles the classes.

There is one way I thought of that could work to do a pure CSS implementation. There’s a :valid pseudo-class that considers the HTML form validation state. If we make the <input> only valid when it is non-empty, either with the pattern or required attributes, then we could write a rule like

.form-control:not(:focus):valid ~ .control-label {
  /* the control is blurred and has a value, hide the label */
}

However, :valid isn’t supported in all browsers, and this presumes you aren’t using the HTML form validation for anything else, so it’s a little too hacky to rely on. In our case, we were already using React, so adding and removing the classes with JavaScript ended up being quite easy.

Check out the source code for this page to get the code, I promise it’s easy to understand!