(Almost) Pure CSS Material-like Text Fields
Posted on
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!