summaryrefslogtreecommitdiff
path: root/_posts/2019-09-19-almost-pure-css-material-text-fields.md
blob: 1d78cc481cc5b04a14233646f54874894073ab9b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
---
title: (Almost) Pure CSS Material-like Text Fields
---

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.

<!--more-->

Here's what it looks like:

<style type="text/css">
/* Input container */
.form-group {
  position: relative;
  height: 52px;
  margin: 8px 0;
}

/* Bottom border widget, at rest */
.form-group::after {
  content: '';
  height: 2px;
  position: absolute;
  bottom: 0;
  left: 50%;
  background-color: #900;
  width: 0;
  transition-property: all;
  transition-duration: 0.15s;
  transition-timing-function: ease-out;
}

/* Bottom widget when focused */
.form-group.focused::after {
  width: 100%;
  left: 0;
}

/* The input itself */
.form-control {
  outline: none;
  border: none;
  display: block;
  background-color: transparent;
  position: absolute;
  top: 20px;
  height: 24px;
  font-size: 16px;
  width: 100%;
}

/* Label/Placeholder at rest */
.control-label {
  display: block;
  font-weight: 300;
  position: absolute;
  transition-duration: 0.15s;
  transition-property: all;
  transition-timing-function: ease-out;
  top: 26px;
  color: #666;
}

/* Placeholder disappears when the field is populated and blurred */
.form-group.populated:not(.focused) .control-label {
  top: 0;
  font-size: 12px;
  font-weight: 600;
  color: transparent;
}

/* Placeholder moves up above the input when the user is editing */
.form-group.focused .control-label {
  top: 0;
  font-size: 12px;
  font-weight: 600;
  color: #900;
}

.demo {
  background-color: white;
  color: black;
  position: relative;
  border: 2px solid black;
  padding: 2em;
  font-family: sans-serif;
}
</style>

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

<script type="text/javascript">
(function() {
  function setPopulated() {
    const group = document.getElementById('demo-group');
    if (this.value) {
      group.classList.add('populated');
    } else {
      group.classList.remove('populated');
    }
  }

  function setFocused(focused) {
    return () => {
      const group = document.getElementById('demo-group');
      if (focused) {
        group.classList.add('focused');
      } else {
        group.classList.remove('focused');
      }
    }
  }

  const input = document.getElementById('demo-control');
  input.addEventListener('input', setPopulated);
  input.addEventListener('paste', setPopulated);
  input.addEventListener('focus', setFocused(true));
  input.addEventListener('blur', setFocused(false));
})()
</script>

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!