Inputs & Formsv0.1.0
Floating Label Input
The label glides into a notch in the border on focus.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/floating-label-input.jsonManual
Install the dependencies
npm install clsx tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.ts. See Installation if you have not set them up yet.Add the theme tokens
This component uses --destructive-text, which shadcn does not define by default. Add it to your global CSS (--destructive-text falls back to your --destructive unless you set it):
app/globals.css@theme inline { --color-destructive-text: var(--destructive-text, var(--destructive)); }Copy the source
components/ui/floating-label-input/floating-label-input.tsx"use client"; import { useId, type ComponentProps, type CSSProperties } from "react"; import { cn } from "@/lib/utils"; export interface FloatingLabelInputProps extends Omit<ComponentProps<"input">, "size"> { /** Text of the label that floats into the border. */ label: string; /** Helper text under the field. */ description?: string; /** Error message under the field. Also marks the input invalid. */ error?: string; /** Field height. @default "md" */ size?: "sm" | "md" | "lg"; /** Classes for the outer wrapper. */ className?: string; } const sizes = { sm: { input: "h-10", half: "1.25rem" }, md: { input: "h-12", half: "1.5rem" }, lg: { input: "h-14", half: "1.75rem" }, } as const; // Floating is pure CSS (:focus-within, :placeholder-shown, :autofill), so it is // correct before hydration and for browser autofill. const floated = [ "group-focus-within/field:-translate-y-[calc(50%+var(--field-half))] group-focus-within/field:scale-75", "group-has-[input:not(:placeholder-shown)]/field:-translate-y-[calc(50%+var(--field-half))] group-has-[input:not(:placeholder-shown)]/field:scale-75", "group-has-[input:autofill]/field:-translate-y-[calc(50%+var(--field-half))] group-has-[input:autofill]/field:scale-75", ].join(" "); const notchOpen = "group-focus-within/field:scale-x-0 group-has-[input:not(:placeholder-shown)]/field:scale-x-0 group-has-[input:autofill]/field:scale-x-0"; export function FloatingLabelInput({ label, description, error, size = "md", className, id, placeholder, "aria-describedby": describedBy, "aria-invalid": ariaInvalid, ...props }: FloatingLabelInputProps) { const generatedId = useId(); const inputId = id ?? generatedId; const messageId = `${inputId}-message`; const invalid = Boolean(error) || ariaInvalid === true || ariaInvalid === "true"; const message = error ?? description; return ( <div className={cn("group/field w-full", className)} style={{ "--field-half": sizes[size].half } as CSSProperties}> <div className="relative"> <input id={inputId} // A single space keeps :placeholder-shown meaningful when no placeholder is given. placeholder={placeholder ?? " "} aria-invalid={invalid || undefined} aria-describedby={[message ? messageId : null, describedBy].filter(Boolean).join(" ") || undefined} className={cn( "peer block w-full rounded-md bg-transparent px-3 text-sm text-foreground outline-none", "placeholder:text-transparent focus:placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", sizes[size].input, )} {...props} /> {/* The border. The legend reserves a notch the size of the floated label; a 1px "bridge" covers the notch until the label floats up into it. */} <fieldset aria-hidden="true" className={cn( "pointer-events-none absolute inset-x-0 -top-[5px] bottom-0 m-0 min-w-0 rounded-md border border-input px-[9px] text-left shadow-xs transition-colors duration-200", "group-focus-within/field:border-2 group-focus-within/field:border-ring group-focus-within/field:px-[8px]", "group-hover/field:border-[color-mix(in_oklab,var(--input),var(--foreground)_15%)] group-focus-within/field:group-hover/field:border-ring", invalid && "border-destructive group-hover/field:border-destructive group-focus-within/field:border-destructive", )} > <legend className="invisible relative block h-[11px] w-auto max-w-full p-0 text-[0.65625rem] leading-none whitespace-nowrap"> <span className="inline-block px-0.5">{label}</span> <span className={cn( "visible absolute inset-x-0 top-[5px] h-px origin-center transition-transform duration-200 ease-out motion-reduce:transition-none", invalid ? "bg-destructive" : "bg-input", notchOpen, )} /> </legend> </fieldset> <label htmlFor={inputId} className={cn( "pointer-events-none absolute top-1/2 left-3 max-w-[calc(100%-1.5rem)] origin-[left_center] -translate-y-1/2 truncate text-sm text-muted-foreground", "transition-[translate,scale,color] duration-200 ease-out motion-reduce:transition-[color] group-focus-within/field:text-primary", floated, invalid && "text-destructive-text group-focus-within/field:text-destructive-text", )} > {label} </label> </div> {/* grid-template-rows 0fr → 1fr animates the message height without measuring. */} <div className={cn( "grid transition-[grid-template-rows] duration-200 ease-out", message ? "grid-rows-[1fr]" : "grid-rows-[0fr]", )} > <div className="overflow-hidden"> <p id={messageId} aria-live="polite" className={cn("px-3 pt-1.5 text-[13px] leading-5", error ? "text-destructive-text" : "text-muted-foreground")} > {message} </p> </div> </div> </div> ); }
Usage
import { FloatingLabelInput } from "@/components/ui/floating-label-input/floating-label-input";
export function EmailField() {
return <FloatingLabelInput label="Email address" type="email" autoComplete="email" />;
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
label* | string | – | Text of the label that floats into the border. |
description | string | – | Helper text under the field. |
error | string | – | Error message under the field. Also marks the input invalid. |
size | "sm" | "md" | "lg" | "md" | Field height. |
className | string | – | Classes for the outer wrapper. |
Also accepts every native <input> attribute, including ref and className.
- className styles the outer wrapper so you can size the field; all other props go to the input.
- The notch is a fieldset legend sized to the floated label, so it works on any background, not just white.
- A placeholder, if given, only appears while the field is focused, so it never collides with the label.
Accessibility
- Uses a real <label> tied to the input, so the label is the accessible name and clicking it focuses the field.
- Description and error text are linked with aria-describedby; the error also sets aria-invalid and is announced politely.
- The floating state is pure CSS (:focus-within, :placeholder-shown and :autofill), so it is correct before hydration and with browser autofill.
- The float animates only transform. With reduced motion enabled the label and notch move instantly; only the color fades.