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.
Implementations
Read section ImplementationsI’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
Notiz floating form field
Read section Notiz floating form fieldMarc 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.
Full input with animation
Read section Full input with animationBecause 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.
Background color problem
Read section Background color problemThe 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>
Full input with animation
Read section Full input with animationUnfortunately, 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
Static notched border
Read section Static notched borderThe 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.
Fieldset
Read section FieldsetAs 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>
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>
Text inside the border
Read section Text inside the borderThis 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>
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.
Outward notch
Read section Outward notchIn 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>
Words
Inward notch
Read section Inward notchA 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>
Words