Creating an Input with Floating Label with TailwindCSS
7 min read

What we will build
We will create the following input:
- Default
<InputWithFloatingLabel id="firstname_input" label="First Name" />- With Placeholder
<InputWithFloatingLabel
id="firstname_input"
label="First Name"
placeholder="Enter your first name"
/>- With Error
<InputWithFloatingLabel
id="firstname_input"
label="First Name"
placeholder="Enter your first name"
error="First name is required."
/>Creating the <InputWithFloatingLabel /> component
Initial UI
Let us start by simply rendering the input with the label and properly positioning the label.
import * as React from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
}
const InputWithFloatingLabel = React.forwardRef<HTMLInputElement, InputProps>(
({ type = 'text', label, ...props }, ref) => {
return (
<div className="relative w-full flex-1">
<input
type={type}
className="h-[54px] block px-2.5 py-3 w-full text-sm bg-background border border-gray-700 appearance-none focus:outline-none focus:ring-1 focus:ring-primary disabled:bg-disabled-100 disabled:cursor-not-allowed disabled:text-disabled-100"
placeholder={props.placeholder ?? label}
{...props}
ref={ref}
/>
<label
htmlFor={props.id}
className="pointer-events-none select-none absolute cursor-text text-sm text-gray-700 duration-300 px-1.5 bg-background left-1 top-1/2 -translate-y-1/2 z-[5]"
>
{label}
</label>
</div>
);
}
);
InputWithFloatingLabel.displayName = 'InputWithFloatingLabel';
export { InputWithFloatingLabel };This initial UI looks like the one below:
Few things to note here:
- We use React's
forwardRefin order to properly forward therefprop to the HTML input element. - We wrapped the HTML input and label with a
<div />that has a class ofrelativeso we the label element can be absolutely positioned. - The interface
InputPropsis the extendedReact.InputHTMLAttributes<HTMLInputElement>. We added alabelprop here which is a string. - The default placeholder is the required
labelprop value. This is why the label element is displayed on top of the input placeholder. - For accessibility purposes, the
idattribute of the HTML input is the value of the label'shtmlForattribute. - The label element has a class of
pointer-events-noneso that it does not block the focusable area of the input element where it was initially positioned. - The label element has a class of
cursor-textso when a user hovers over it, it will appear ready for editing.
Make the Label 'float' on input focus
Since we need to style the label element based on the focus state of the input and since the label and the input are siblings inside a parent <div>, we use TailwindCSS' peer modifier.
The class origin-[0] is added in the label so that its transform origin is at 0,0.
const InputWithFloatingLabel = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type = 'text', label, ...props }, ref) => {
return (
<div className="relative w-full flex-1">
<input
type={type}
className="/* other classes here */ peer"
placeholder={props.placeholder ?? label}
{...props}
ref={ref}
/>
<label
htmlFor={props.id}
className="/* other classes here */ origin-[0] peer-focus:px-1.5 peer-focus:top-2 peer-focus:scale-75 peer-focus:-translate-y-5 peer-focus:text-white peer-focus:bg-background"
>
{label}
</label>
</div>
);
}
);This yields to the ff. result:
💡 Info
You can learn more about the peer modifier
Now, try to enter a text in the input then click anywhere (removing the focus from the input). What did you notice? That's right, the label element came back to its original position thus covering the text.
We can fix this by using TailwindCSS' placeholder-shown modifier.
const InputWithFloatingLabel = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type = 'text', label, ...props }, ref) => {
return (
<div className="relative w-full flex-1">
<input
type={type}
className="h-[54px] block px-2.5 py-3 w-full text-sm bg-transparent border border-gray-700 appearance-none focus:outline-none focus:ring-1 focus:ring-primary peer disabled:bg-disabled-100 disabled:cursor-not-allowed disabled:text-disabled-100 peer placeholder:invisible"
placeholder={props.placeholder ?? label}
{...props}
ref={ref}
/>
<label
htmlFor={props.id}
className="pointer-events-none select-none absolute cursor-text text-sm text-gray-700 duration-300
px-1.5 -translate-y-5 scale-75 bg-background left-1 top-2 z-[5] origin-[0]
peer-focus:px-1.5 peer-focus:top-2 peer-focus:scale-75 peer-focus:-translate-y-5 peer-focus:text-white peer-focus:bg-background
peer-placeholder-shown:scale-100 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:top-1/2"
>
{label}
</label>
</div>
);
}
);Basically, what we did is use the initial label styles only when the input placeholder is shown. The placeholder of an HTML input becomes invisible when it gets focused. Then, we make the styles for making the label float the base styles.
This yields to the ff. result:
💡 Info
You can learn more about the placeholder-shown modifier
Make the placeholder still visible even when the label is floating
What if the user wants to provide a placeholder text that's different from the label text? We do by leveraging TailwindCSS' data-* modifier.
When we provide a placeholder text, we set a custom data-hasplaceholder attribute a value of true.
<input
data-hasplaceholder={props.placeholder ? true : undefined}
type={type}
className="..."
placeholder={props.placeholder ?? label}
{...props}
ref={ref}
/>The next step is to show the placeholder when the input is focused and make it invisible otherwise.
<input
data-hasplaceholder={props.placeholder ? true : undefined}
type={type}
className="placeholder:text-gray-700 placeholder:invisible data-[hasplaceholder=true]:focus:placeholder:visible"
placeholder={props.placeholder ?? label}
{...props}
ref={ref}
/>Using TailwindCSS, we can have stackable modifiers like data-[hasplaceholder=true]:focus which means "when the input has data-hasplaceholder=true while it is focused, apply these classes". With these changes, we achieve thsi requirement.
Add Error state
The first change is to adjust our props interface to include an error prop.
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}Then we adjust the Component:
const InputWithFloatingLabel = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type = 'text', label, error, ...props }, ref) => {
return (
<div className="flex-1">
<div className="relative w-full flex-1">
<input
type={type}
data-hasplaceholder={!!props.placeholder}
className="..."
placeholder={props.placeholder ?? label}
{...props}
ref={ref}
/>
<label htmlFor={props.id} className="...">
{label}
</label>
</div>
{error ? (
<em className="not-italic text-red-500 text-xs">{error}</em>
) : null}
</div>
);
}
);- We wrap the whole markup with a
<div />. - We extract the
errorprop in line 2. - We conditionally render the error inside an
<em />element.
This produces the ff.:
<InputWithFloatingLabel
id="firstname_input"
label="First Name"
placeholder="Enter your first name"
error="First name is required."
/>Output:
For accessibility purposes, we inform screen-readers that the input has invalid value by giving aria-invalid a value of true when an error is present. Then we add classes that depend on the state of the aria-invalid attribute.
Then, we add an id to the <em /> tag and point the aria-describedby attribute to that id.
<input
type={type}
data-hasplaceholder={props.placeholder ? true : undefined}
className="aria-[invalid=true]:border-red-500 focus:aria-[invalid=true]:ring-red-500 ..."
placeholder={props.placeholder ?? label}
{...props}
ref={ref}
aria-invalid={error ? true : undefined}
aria-describedby={`${props.id}_error`}
/>
<em className="not-italic text-red-500 text-xs"
id={`${props.id}_error`}
>
{error}
</em>💡 Info
You can learn more about the aria-* modifier
Final Code
Code
import * as React from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
const InputWithFloatingLabel = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type = 'text', label, error, ...props }, ref) => {
return (
<div className="flex-1">
<div className="relative w-full flex-1">
<input
type={type}
data-hasplaceholder={props.placeholder ? true : undefined}
className="aria-[invalid=true]:border-red-500 h-[54px] focus:aria-[invalid=true]:ring-red-500 block px-2.5 py-3 w-full text-sm bg-transparent border border-gray-700 appearance-none focus:outline-none focus:ring-1 focus:ring-primary peer placeholder:text-gray-700 placeholder:invisible data-[hasplaceholder=true]:focus:placeholder:visible disabled:bg-disabled-100 disabled:cursor-not-allowed disabled:text-uidisabled"
placeholder={props.placeholder ?? label}
{...props}
ref={ref}
aria-invalid={error ? true : undefined}
aria-describedby={`${props.id}_error`}
/>
<label
htmlFor={props.id}
className="pointer-events-none select-none absolute cursor-text *:hidden text-sm text-gray-700 duration-300 px-1.5 -translate-y-5 scale-75 bg-background left-1 top-2 z-[5] origin-[0] peer-focus:px-1.5 peer-placeholder-shown:scale-100 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:top-1/2 peer-placeholder-shown:*:inline peer-focus:top-2 peer-focus:scale-75 peer-focus:-translate-y-5 peer-focus:text-white peer-focus:bg-background peer-focus:*:hidden rtl:peer-focus:translate-x-1/4 rtl:peer-focus:left-auto"
>
{label}
</label>
</div>
{error ? (
<em
className="not-italic text-red-500 text-xs"
id={`${props.id}_error`}
>
{error}
</em>
) : null}
</div>
);
}
);
InputWithFloatingLabel.displayName = 'InputWithFloatingLabel';
export { InputWithFloatingLabel };Conclusion
TailwindCSS features different modifiers such as focus, placeholder, placeholder-shown, peer, data-*, aria-* and many more which a web developer can leverage in order to create UI styles and interactions without requiring any JavaScript.
Happy coding!
-jeff