Tailwind CSS

Outlined inputs and notched borders

17 min read

Material Design sports and popularized a unique design for its text inputs. The filled design is relatively simple to implement, however the outlined variant is not. In its outlined variant, whenever the label is focused or filled, the label placeholder text is animated upward and appears as part of the input’s border.

The relatively new :placeholder-shown pseudo-class can be used to detect whether an input has any content by inverting it (:not(:placeholder-shown)). A non-empty placeholder must be used for :placeholder-shown to have any effect (placeholder=" " is used here). CSS transitions and translate transforms are capable of providing the animation. But how does the border work? It’s impossible after all to define borders to be a certain minimum or maximum width. The border-width property refers to its thickness, not its length.

As it turns out, there is a little-known HTML element that can be used to implement this effect: [fieldset] (combined with legend). It can also be done with CSS absolute positioning. Join me in comparing a number of approaches.

I’ve compiled three vastly different approaches, each with their own up and downsides. The three approaches have been standardized to use Tailwind utility classes, making them easier to compare.

MUI is a popular React implementation of Material Design. They have started to deviate somewhat from Google’s guidelines, but the text field is still mostly identical.

Here is a Tailwind implementation of this approach (bar some minor differences like font). It has one huge downside compared to the other approaches: it requires scripting. The :placeholder-shown trick does not work as there is no way to change the styling of a parent based on a child’s condition. Additionally, because the label does not contain the input element, an id and for on the label is required.

<div class="flex items-center gap-2">
  <div class="relative m-2 inline-flex w-60 flex-col">
    <!-- This label is what the user actually sees -->
    <label
      class="pointer-events-none absolute inset-0 z-[1] block max-w-[calc(100%-24px)] origin-top-left translate-x-[14px] translate-y-4 scale-100 overflow-hidden text-ellipsis whitespace-nowrap leading-6 text-black text-opacity-60 transition-all dark:text-white"
      for="outlined-basic"
      >Outlined</label
    >
    <!-- The input box -->
    <div class="relative inline-flex cursor-text items-center rounded-[4px] leading-7 text-black text-opacity-[87]">
      <!-- The design here requires `box-sizing: content-box`, this is easy to miss -->
      <input
        type="text"
        aria-invalid="false"
        id="outlined-basic"
        class="mui-input box-content block h-[theme('lineHeight.6')] w-full min-w-0 bg-transparent py-[16.5px] px-[14px] leading-6 placeholder:opacity-0 placeholder:transition-opacity focus:outline-none"
        value=""
      />
      <!-- This fieldset implements the border around the input. -->
      <!-- When the input is active, the legend span creates a gap in the border. -->
      <!-- This gap requires the span to have the exact same text as the <label>, -->
      <!-- as well as the exact same font rendering parameters (font-size, line-height) etc. -->
      <!-- We don't want screen readers to announce the label twice, so we have to add aria-hidden="true" -->
      <fieldset
        aria-hidden="true"
        class="pointer-events-none absolute inset-0 -top-[5px] min-w-[0%] overflow-hidden rounded-[4px]
    border border-solid border-opacity-[23] px-2 hover:border-opacity-[87] dark:border-white dark:bg-black"
      >
        <!-- h- adds additional padding to the input -->
        <!-- max-w-full set in the script creates a space in the border -->
        <legend
          class="invisible block h-[11px] w-auto max-w-[0.01px] overflow-hidden whitespace-nowrap leading-6 transition-all duration-100"
        >
          <!-- The px here creates horizontal space around the border -->
          <span class="visible inline-block px-[5px] opacity-0">Outlined</span>
        </legend>
      </fieldset>
    </div>
  </div>
  <label> <input type="checkbox" id="forceInputActive" /> Force active </label>
</div>
<script>
  function toggleInput(input, active) {
    const label = input.parentElement.previousElementSibling;
    const fieldset = input.nextElementSibling;
    const legend = fieldset.children[0];
    // .classList methods only accept a single class at a time. When passed any other value it will call .toString() on it,
    // which in the case of an array would lead to a class like "border,border-l-2", not what you want.

    for (const cls of "text-blue-500 dark:text-blue-500 max-w-[calc(133%-24px)] translate-y-[-9px]".split(" "))
      label.classList.toggle(cls, active);
    for (const cls of "border-blue-500 dark:border-blue-500 border-opacity-100 hover:border-opacity-100 border-2".split(
      " "
    ))
      fieldset.classList.toggle(cls, active);
    for (const cls of "!max-w-full".split(" ")) legend.classList.toggle(cls, active);
  }

  const getInputs = () => document.querySelectorAll(".mui-input");
  function registerListeners() {
    for (const input of getInputs()) {
      // Activate the input when it's focused
      input.addEventListener("focusin", () => toggleInput(input, true));
      // Deactivate it when it's not focused and not filled
      input.addEventListener("focusout", () => !input.value && toggleInput(input, false));
      // If the input was autofilled, activate it too
      toggleInput(input, !!input.value);
    }
  }
  registerListeners();

  // This checkbox allows you to force the input to be active to make it easier to see what's going on in the console
  forceInputActive.addEventListener("click", () => (forceInputActive.checked ? forceEnable : registerListeners)());
  if (forceInputActive.checked) forceEnable(); // bfcache

  function forceEnable() {
    // Remove event listeners; the inputs will no longer be valid after so we need to loop twice with a new nodelist from `getInputs`
    for (const input of getInputs()) {
      input.outerHTML = input.outerHTML;
    }
    for (const input of getInputs()) {
      toggleInput(input, true);
    }
  }
</script>

Upsides:

  • Works in all scenarios
  • Uses native fieldset element

Downsides:

  • Requires script
  • Painful to debug: Devtools cannot be used to force the focus-within CSS pseudostate as the logic is handled by script.
  • Label text is duplicated twice in the DOM
  • Requires an ID to attach label to input

Marc Stammerjohann created a version using background-color and z-index based on this video. This solution is the simplest of all and works well when a site uses a constant background-color everywhere. The label must be given the right background-color for it to not look out of place.

The version given here replaces the negative z-index with pointer-events-none, otherwise the label would be hidden in dark mode. The functionality remains the same.

The animation feels a little sluggish compared to other solutions, because the background clips into the border to hide it. The other solutions hide the background before moving the text.

Because the label is implemented with a negative z-index, it cannot receive pointer events, meaning we can skip the pointer-events-none utility. This makes the markup even more concise.

Unfortunately, Tailwind’s peer does not support :not(:placeholder-shown), so we must write a bit of extra CSS ourselves and specify a class to enable the right element to be targeted for the animation.

<div class="relative border-2 focus-within:border-blue-500 dark:border-white">
  <input
    type="text"
    id="username"
    placeholder=" "
    class="notiz-input block w-full appearance-none bg-transparent p-4 text-lg focus:outline-none"
  />
  <label
    for="username"
    class="pointer-events-none absolute top-0 origin-bottom-left bg-white p-4 text-lg duration-300 dark:bg-black dark:text-white"
    >Username</label
  >
</div>
<style>
  .notiz-input:focus-within ~ label,
  .notiz-input:not(:placeholder-shown) ~ label {
    @apply ml-3 -translate-y-4 scale-75 transform px-1 py-0;
  }

  .notiz-input:focus-within ~ label {
    @apply text-blue-500 dark:text-blue-500;
  }
</style>

When the input is activated, the label is moved up and shrunk by the translate, scale, and transform declarations.

The label having its own background color makes it clip into the emerald background. This can be fixed by manually specifying the correct color for label to use. If this is not possible, another solution should be picked.

<div class="bg-emerald-300 p-2">
  <div class="relative border-2 border-blue-500">
    <input
      type="text"
      name="username"
      placeholder=" "
      class="block w-full appearance-none bg-transparent p-4 text-lg focus:outline-none"
    />
    <label
      for="username"
      class="origin-0 pointer-events-none absolute top-0 ml-3 -translate-y-4 scale-75 transform bg-white px-1 py-0 text-lg text-blue-500"
    >
      Username
    </label>
  </div>
</div>

To solve this, another approach must be taken. If this is not a problem however, this solution is excellent for its simplicity.

Upsides:

  • Simplest
  • Can be implemented with just HTML and CSS

Downsides:

  • Sets a background color which may cause visual clipping in some situations
  • Requires an ID to attach label to input

Google’s approach is to make up the input of an outer box (the label) and have the input-notched-outline be three separate boxes providing their own borders. When the md-input is focused or filled.

I’ve added different colored borders throughout the examples to illustrate the boundaries between the different parts of the box.

<label class="relative inline-flex h-14 w-full overflow-visible rounded-t-md pl-4 align-baseline">
  <!-- This absolutely positioned box provides the border -->
  <!-- Without pointer-events-none, these boxes mess up selection which is especially noticeable when clicking on an input field -->
  <div class="pointer-events-none absolute inset-0 z-[1] h-full w-full max-w-full">
    <span class="pointer-events-none absolute inset-0 flex h-full w-full max-w-full">
      <!-- Decorative left border -->
      <span class="pointer-events-none h-full w-3 border-y-2 border-l-2 border-emerald-400"></span>
      <!-- Main content, with no top border -->
      <span
        class="pointer-events-none h-full w-auto max-w-[calc(100%-24px)] basis-auto border-y-2 border-t-0 border-amber-400"
      >
        <!-- Content is positioned up using -translate-y to appear as though it's part of the border -->
        <span
          class="pointer-events-none relative bottom-[-27px] top-[50%] left-2 mr-4 inline-block max-w-full origin-left -translate-y-9 overflow-hidden whitespace-nowrap text-sm leading-5 transition-all [text-overflow:clip]"
        >
          Label
        </span>
      </span>
      <!-- Remaining content (fills the flexbox using flex-grow) -->
      <span class="pointer-events-none h-full flex-grow border-y-2 border-r-2 border-rose-400"></span>
    </span>
  </div>
</label>

Unfortunately, Tailwind’s peer does not support nesting nor :not(:placeholder-shown), so we must write a bit of extra CSS ourselves and specify classes to enable the right element to be targeted for the animation.

<label class="relative inline-flex h-14 w-full overflow-visible rounded-t-md pl-4 align-baseline">
  <input class="md-input flex h-full w-full bg-transparent pr-4 outline-none" placeholder=" " type="text" />
  <div class="input-notched-outline pointer-events-none absolute inset-0 z-[1] h-full w-full max-w-full">
    <span class="input-outline pointer-events-none absolute inset-0 flex h-full w-full max-w-full">
      <span class="pointer-events-none h-full w-3 border-y border-l dark:border-white"></span>
      <span
        class="input-notch pointer-events-none h-full w-auto max-w-[calc(100%-24px)] basis-auto border-y dark:border-white"
      >
        <span
          class="input-label pointer-events-none relative bottom-[-27px] top-[50%] left-2 inline-block max-w-full origin-left -translate-y-[50%] overflow-hidden whitespace-nowrap leading-5 transition-all [text-overflow:clip]"
        >
          Label
        </span>
      </span>
      <span class="pointer-events-none h-full flex-grow border-y border-r dark:border-white"></span>
    </span>
  </div>
</label>

<style>
  .md-input:focus ~ .input-notched-outline .input-label,
  .md-input:not(:placeholder-shown) ~ .input-notched-outline .input-label {
    @apply mr-4 -translate-y-9 text-sm;
  }
  .md-input:focus ~ .input-notched-outline .input-notch,
  .md-input:not(:placeholder-shown) ~ .input-notched-outline .input-notch {
    @apply border-t-0;
  }
</style>

Upsides:

  • Works in all scenarios
  • Can be implemented with just HTML and CSS
  • No ID required to attach label to input

Downsides:

  • More complex HTML than other approaches

The input example is significantly more complicated than just a static box. We had to write custom CSS in order to respond with an animation to the input active state. The Google approach is actually much simpler to apply to an element without animations. Instead of messing with transforms, we can simply use top and bottom for positioning.

As a reminder, here’s what the native fieldset can look like.

<div class="flex gap-2">
  <fieldset class="w-72 border p-4 dark:border-white">
    <legend class="px-2">Top Label</legend>
    <span class="font-bold">Title</span>
  </fieldset>
  <fieldset class="w-72 border p-4 dark:border-white">
    <legend class="ml-[50%] px-2">Top Label</legend>
    <span class="font-bold">Title</span>
  </fieldset>
</div>
Top Label Title
Top Label Title

The placement of the legend can be adjusted with margin-left, but can be finicky to get right.

What if we want to place the label in the bottom border? We’ll need to do something similar to Google’s approach then. The order of the legend in the DOM does not matter, and adding negative bottom margin or positive padding/margin to the legend only adds space above the fieldset box:

<fieldset class="border p-4 dark:border-white">
  <span class="font-bold">Title</span>
  <legend class="ml-[50%] mt-8 px-2">Top Label</legend>
</fieldset>
Title Top Label

This is a great pattern for a border-based design with high information density; for example, an article card that embeds its word count or estimated time to read in the border. The position of the bottom and top label can be swapped around in the DOM and padding can be adjusted by changing the w- values of the left and right borders. Either label can safely be removed, the flex-grow center border will take its place.

<article class="relative flex max-w-md flex-col gap-2 px-4 py-3">
  <!-- Overlayed box with borders, should not be selectable -->
  <div class="pointer-events-none absolute inset-0 z-[1]">
    <span class="absolute inset-0 flex">
      <!-- Left border -->
      <span class="w-3 border-y-2 border-l-2 border-emerald-400"></span>
      <span class="grid items-end border-y-2 border-b-0 border-amber-400">
        <span class="relative -bottom-2.5 -mt-[100%] inline-block overflow-hidden px-2">
          <span class="pointer-events-auto flex items-center gap-1">Bottom Label</span>
        </span>
      </span>
      <!-- Center border -->
      <span class="flex-grow border-y-2 border-cyan-400"></span>
      <span class="border-y-2 border-t-0 border-indigo-400">
        <span class="pointer-events-auto relative -top-0.5 -mt-[100%] inline-block overflow-hidden  px-2">
          Top Label
        </span>
      </span>
      <!-- Right border -->
      <span class="w-6 border-y-2 border-r-2 border-rose-400"></span>
    </span>
  </div>
  <header class="font-bold">Title</header>
</article>
Bottom Label Top Label
Title

With some slight changes to the positioning logic, it’s also possible to make the notch apply its own side and top borders, creating a notch akin to that popularized by the iPhone.

In this design, the notches stick out outward. This requires the container (article) to have additional padding, otherwise boxes may clip each other because the notches are absolutely positioned. The black borders complete the traditional “notch” appearance.

<article class="py-6">
  <div class="relative flex max-w-md flex-col gap-2 px-4 py-3">
    <div class="pointer-events-none absolute inset-0 z-[1]">
      <span class="absolute inset-0 flex">
        <span class="w-3 border-y-2 border-l-2 border-emerald-400"></span>
        <span class="grid items-end border-y-2 border-b-0 border-amber-400">
          <span class="relative -bottom-[24px] -mt-[100%] inline-block overflow-hidden border-x border-b px-2">
            <span class="pointer-events-auto flex items-center gap-1">Bottom Text</span>
          </span>
        </span>
        <span class="flex-grow border-y-2 border-cyan-400"></span>
        <span class="border-y-2 border-t-0 border-indigo-400">
          <span
            class="pointer-events-auto relative -top-[15px] -mt-[100%] inline-block overflow-hidden border-x border-t px-2"
          >
            Top Text
          </span>
        </span>
        <span class="w-6 border-y-2 border-r-2 border-rose-400"></span>
      </span>
    </div>
    <header class="font-bold">Title</header>
    <p class="break-words">Words</p>
  </div>
</article>
Bottom Text Top Text
Title

Words

A notch can also go “inward”, in which case no additional outside padding is required, and it draws its own bottom and inline borders instead. Care should be taken for the notch to not clip text inside the box.

<article>
  <div class="relative flex max-w-md flex-col gap-2 px-4 py-3">
    <div class="pointer-events-none absolute inset-0 z-[1]">
      <span class="absolute inset-0 flex">
        <span class="w-6 border-y-2 border-l border-emerald-400"></span>
        <span class="grid items-end border-y-2 border-b-0 border-amber-400">
          <span class="inline-block overflow-hidden border-x border-t  px-2 ">
            <span class="pointer-events-auto flex items-center gap-1">Bottom Text</span>
          </span>
        </span>
        <span class="flex-grow border-y-2 border-cyan-400"></span>
        <span class="border-y-2 border-t-0 border-indigo-400">
          <span class="pointer-events-auto inline-block overflow-hidden border-x border-b px-2"> Top Text </span>
        </span>
        <span class="w-6 border-y-2 border-r border-rose-400"></span>
      </span>
    </div>
    <header class="ml-3 font-bold">Title</header>
    <p class="self-end break-words">Words</p>
  </div>
</article>
Bottom Text Top Text
Title

Words