Floating label
Field labels sit inside the input, then float up and shrink on focus or once filled, with a growing underline.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useId } from "react";
import styles from "./demo.module.css";
const FIELDS = [
{ name: "guest", label: "Guest name", type: "text", autoComplete: "name", value: "Maya Fernandes" },
{ name: "email", label: "Email", type: "email", autoComplete: "email", value: "" },
{ name: "phone", label: "WhatsApp number", type: "tel", autoComplete: "tel", value: "" },
];
export default function Demo() {
const id = useId();
return (
<div className={styles.root}>
<form className={styles.form} onSubmit={(e) => e.preventDefault()}>
{FIELDS.map((f) => (
<div key={f.name} className={styles.field}>
{/* A single space placeholder lets CSS tell an empty field from a filled one. */}
<input
id={`${id}-${f.name}`}
name={f.name}
type={f.type}
autoComplete={f.autoComplete}
defaultValue={f.value}
placeholder=" "
/>
<label htmlFor={`${id}-${f.name}`}>{f.label}</label>
<span className={styles.line} aria-hidden="true" />
</div>
))}
</form>
</div>
);
}
demo.module.css
.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.form { display: grid; gap: 10px; width: min(340px, 100%); }
.field { position: relative; }
.field input { width: 100%; height: 54px; padding: 22px 14px 6px; border: 0; border-bottom: 1.5px solid var(--line); border-radius: 10px 10px 4px 4px; background: var(--white); font: inherit; font-size: 16px; color: var(--ink); outline: none; }
.field label { position: absolute; left: 14px; top: 17px; font-size: 16px; color: var(--muted); pointer-events: none; transform-origin: 0 0; transition: transform .25s var(--out), color .2s; }
.field input:focus + label, .field input:not(:placeholder-shown) + label { transform: translateY(-10px) scale(.75); }
.field input:focus + label { color: var(--accent-text); }
.line { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; border-radius: 1px; background: var(--accent-text); transform: scaleX(0); transition: transform .3s var(--out); }
.field input:focus ~ .line { transform: scaleX(1); }
.field input:focus-visible { outline: none; }
@media (prefers-reduced-motion: reduce) {
.field label, .line { transition: none; }
}
Use it for
- Compact forms such as checkout or guest details
- Mobile layouts where vertical space is tight
Avoid
- Long forms, where labels above fields are faster to scan
- Using the label as the only hint for complex formats
How it works
Each input has a placeholder of a single space, so :placeholder-shown tells an empty field from a filled one with no JavaScript. The label follows the input and the sibling selector moves it with translateY and scale from a top left origin. A span after the input is the underline; it scales from 0 to 1 on X when the input has focus.
Accessibility
Labels are real label elements tied to their inputs, so they are always announced, and they never disappear. Focus is shown by the accent label and underline. Reduced motion jumps straight to the floated state.