Controls
Floating input
A text field whose label floats up on focus, with a hint and an error state. CSS only.
Adapted from Watermelon UI (MIT) and rewritten with keyboard support, labels and reduced motion.
Preview
Usage
floating-input-demo.tsx
import { useState } from 'react'
import { FloatingInput } from '@/components/floating-input'
export default function FloatingInputDemo() {
const [zip, setZip] = useState('')
const bad = zip !== '' && !/^\d{4,5}$/.test(zip)
return (
<form className="mx-auto flex w-full max-w-sm flex-col gap-4" onSubmit={(e) => e.preventDefault()}>
<FloatingInput label="Full name" autoComplete="name" />
<FloatingInput label="Email" type="email" autoComplete="email" hint="We send the receipt here." />
<FloatingInput
label="Postcode"
inputMode="numeric"
autoComplete="postal-code"
value={zip}
onChange={(e) => setZip(e.target.value)}
error={bad ? 'Use 4 or 5 digits.' : undefined}
/>
</form>
)
}Source
floating-input.tsx
// Based on Watermelon UI's “Floating input” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten in CSS only, with a linked label, hint and error.
import { useId, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export type FloatingInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'placeholder'> & {
label: string
hint?: string
/** Shown in red under the field, and marks the field invalid. */
error?: string
}
/** A text field whose label sits inside it and floats up into the border when you type or focus. */
export function FloatingInput({ label, hint, error, className, id, ...props }: FloatingInputProps) {
const own = useId()
const fieldId = id ?? own
const note = error ?? hint
return (
<div className={cn('w-full', className)}>
<div className="relative">
<input
id={fieldId}
placeholder=" "
aria-invalid={error ? true : undefined}
aria-describedby={note ? `${fieldId}-note` : undefined}
className={cn(
'peer h-12 w-full rounded-xl border bg-background px-4 pt-1 outline-none transition-[border-color,box-shadow] duration-200',
'focus:border-ring focus:ring-3 focus:ring-ring/20',
error && 'border-destructive focus:border-destructive focus:ring-destructive/20',
)}
{...props}
/>
<label
htmlFor={fieldId}
className={cn(
'pointer-events-none absolute top-0 left-3 origin-left -translate-y-1/2 bg-background px-1 text-xs text-muted-foreground transition-all duration-200 ease-out',
'peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-base',
'peer-focus:top-0 peer-focus:text-xs peer-focus:text-ring',
error && 'text-destructive peer-focus:text-destructive',
'motion-reduce:transition-none',
)}
>
{label}
</label>
</div>
{note && (
<p id={`${fieldId}-note`} className={cn('mt-1.5 px-1 text-xs', error ? 'text-destructive' : 'text-muted-foreground')}>
{note}
</p>
)}
</div>
)
}Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/floating-input.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/floating-input