Pure CSS Input with Shrinking Label

Published Sunday, October 23rd 2022 · 6min read

Designing form elements is a bit of a passion of mine. It usually is the first part I start with in a new design system and I try to give all my form elements—and input fields especially—a unique touch in every one of my projects.

Up until last week, that almost always involved a sprinkle of optional JavaScript, but thanks to new CSS properties that are now supported in most modern browsers, I was able to achieve a pretty satisfying result with just CSS, which I’d like to share with you in this post.

Here’s an image of what we’ll be creating:

Three input fields next to each other, one shows the unfocussed state, one the invalid state by having a red border and one the focussed state by having a light green background and a green border. In the invalid and focussed states, the label of the field is shown smaller at the top left

When the input field is empty and not focused, it shows just the label. Once focused, the label shrinks upwards, revealing the placeholder of the field. Once a value is entered, that value is shown even after the field loses focus. If the value is not valid, the input field turns red, showing that the value is invalid.


The HTML for such a field is relatively simple, a standard <input> tag wrapped in a <label>. The label-text itself is wrapped in a <span> so it can be better targeted using CSS:

<label for="input">
  <input id="input" pattern="[0-9]{5}" placeholder="Placeholder" type="text">

Since the label wraps the input element, it is not strictly necessary to supply the for attribute with the id of the input element, but it is good practice nonetheless. Since we’ll be using the <label> to give the input field its appearance, this structure helps focusing the <input> no matter where we click on the visual area of the component.

Also note the placeholder attribute on the input element. For this technique to work, it is necessary to supply a placeholder, but this placeholder may be a single space (not an empty string though!). The pattern attribute is used to ensure that the field is only valid if it is supplied with a certain value, in this case exactly five numbers between 0 and 9. It’s just an example to illustrate the invalid styles for this demo, you could also use a required attribute or a different type according to your needs.

Making the Magic Happen

Now for the tricky part, the actual CSS styles. This technique is based on the :has() selector combined with three other pseudo-classes:

  • :placeholder-shown, used to select any input or textarea element currently showing a placeholder
  • :focus-within, which is applied when an element within the targeted element currently has focus
  • :invalid, which is used to target invalid form elements

As a side note, using a CSS pre-processor that supports nesting such as SCSS or Stylus could greatly help with the readability of the following code, but as that requires transpilation, I’ve included the pure CSS code.

Let me walk you through it:

label {
  background-color: #f6f6f8;
  border: 0.0625rem solid #C9CACC;
  border-radius: 0.75rem;
  padding: 0.5rem 1rem;
  display: block;
  position: relative;
  overflow: hidden;
  cursor: text;

label input {
  font-family: inherit;
  font-size: inherit;
  font-weight: inherit;
  letter-spacing: inherit;
  background-color: transparent;
  border: none;
  display: block;
  width: 100%;
  padding: 0;
  line-height: 1rem;
  margin-top: 1.25rem;
  margin-bottom: 0.25rem;

label input:focus {
  outline: none;

label span {
  transform-origin: top left;
  font-weight: 600;
  opacity: 0.5;
  line-height: 1rem;
  font-size: 0.625rem;
  position: absolute;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  transition: transform 200ms ease;

These are just some basic styles to make everything look nice. They also determine what the input field will look like in browsers that don’t support the :has() or other pseudo-classes yet.

We’re also making sure that the input element has no styling even when it’s focused by hiding the outline browsers would usually apply.

The most important part is probably setting the transform-origin of the span element within the label to the top left, since we’ll be using a CSS transform to make it appear as though it is replacing the actual input element in the next step:

label:has(:placeholder-shown):not(:focus-within) span {
  transform: translateY(0.5rem) scale(1.6);

label:has(:placeholder-shown):not(:focus-within) input {
  opacity: 0;

label:focus-within {
  background-color: #dafaf1;
  border-color: #1dd1a1;
  box-shadow: inset 0 0 0 0.0625rem #1dd1a1;

This is the juicy bit. We are telling the browser to select a <span> within a <label> if it has an <input> field as a child that is currently showing a placeholder, but is not currently focused. If that’s the case, the span element should be transformed to look centred within the label. You may have to tweak these values within the rule a bit if you’re using different dimensions for your input fields when applying this technique.

The next selector does the same thing, except it selects the input element instead of the span and hides it by setting its opacity to 0. Note that you cannot use display: none in this case, as that would break focusing the input field by using the Tab key.

The last selector simply selects a <label> if an element within (in this case the <input> has focus and styles the label to look like it is focused.

And that’s basically it! Modern browsers supporting the newer pseudo selectors will show the input field like the rightmost one in the image above. Older browsers (and modern Firefox) will show the field as if it were focused (so both the label and the placeholder), but without the focus-styles.

Focusing the input by clicking the label or tabbing into it makes the label shrink to the top left and reveals the placeholder. If a value is entered, the field will stay like that when it loses focus. If not, it reverts back to its initial state.

The Invalid State

As a small bonus, here’s the code that makes the invalid state work as seen in the middle input field in the image:

label:has(input:invalid:not(:placeholder-shown)):not(:focus-within) {
  background-color: #fff0f0;
  border-color: #ff6b6b;
  box-shadow: inset 0 0 0 0.0625rem #ff6b6b;

label input:invalid:not(:focus):not(:placeholder-shown) {
  color: #ee5253;

Again, we use :has() to style all label elements which have a child that is an input, which is invalid and not showing a placeholder, but only if none of the children of that <label> are currently focused. The :invalid pseudo-class represents all form elements, which aren’t valid according to their validation options. You can learn more about it here.

Similarly, we use the :invalid pseudo-class directly on the input element to color it red—but only if it’s not showing its placeholder, since otherwise fields with the required attribute would look invalid before the user interacted with them, which would not be good UX.

Closing Words

And that’s it! A modern looking and feeling input field with a shrinking label in pure CSS without a single line of JavaScript that works just fine even in older browsers. You can play around with an interactive demo here.

I hope you liked this post and I’m curious to see what you create with it. Feel free to tag me on Twitter or Mastodon with your creations. Thank you for reading and see you next month!