Floating Label Input
The label sits inside the empty field and lifts to the corner on focus or input while the border lights up with an accent halo. Password fields get a reveal toggle and errors unfold with a small shake. Made for sign-in, sign-up and profile forms.
- React · Motion
- MIT
- educlopez/smoothui
- Remix this
Loading preview
Tune
Highlighted lines hold your current values; copies and downloads include them
// SPDX-License-Identifier: MIT// Copyright (c) 2024 Eduardo Calvo// Source: https://github.com/educlopez/smoothui/blob/b6312bc/packages/smoothui/components/animated-input/index.tsx// Modified by Motif; see the item's provenance.import { cn } from '@motif/runtime'import { AnimatePresence, motion, useReducedMotion } from 'motion/react'import { useId, useState, type CSSProperties, type InputHTMLAttributes, type ReactNode, type Ref } from 'react'/* @motif:defaults */export const defaults = { variant: 'outline', color: '#a78bfa', radius: 12, size: 'md', spring: { visualDuration: 0.3, bounce: 0.15, }, label: 'Email address', placeholder: 'you@company.com',}/* @motif:end */export type InputFloatingLabelProps = Partial<typeof defaults> & Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'color' | 'value' | 'defaultValue' | 'onChange' | 'placeholder'> & { value?: string defaultValue?: string onChange?: (value: string) => void /** 输入框前面的图标。 */ leading?: ReactNode error?: string hint?: string /** 强制显示聚焦外观(演示自动播放用)。 */ focused?: boolean ref?: Ref<HTMLInputElement> className?: string style?: CSSProperties }const HEIGHTS = { sm: 46, md: 54, lg: 62 } as constconst FONT = { sm: 14, md: 15, lg: 16 } as const/** 浮动标签输入框:聚焦或有内容时标签缩小飘到左上角;密码框自带显示 / 隐藏;错误信息展开并抖动。 */export function InputFloatingLabel({ value, defaultValue, onChange, leading, error, hint, focused: forcedFocus, ref, className, style, type = 'text', disabled, id, ...rest }: InputFloatingLabelProps) { const { variant, color, radius, size, spring, label, placeholder, ...inputProps } = { ...defaults, ...rest } const reduced = useReducedMotion() const uid = useId() const inputId = id ?? uid const [inner, setInner] = useState(defaultValue ?? '') const [focus, setFocus] = useState(false) const [reveal, setReveal] = useState(false) const text = value ?? inner const isFocused = forcedFocus ?? focus const floated = isFocused || text.length > 0 const height = HEIGHTS[size as keyof typeof HEIGHTS] ?? 54 const font = FONT[size as keyof typeof FONT] ?? 15 const padX = leading ? 44 : 16 const invalid = Boolean(error) const accent = invalid ? '#f43f5e' : color const transition = reduced ? { duration: 0 } : { type: 'spring' as const, visualDuration: spring.visualDuration, bounce: spring.bounce } const isPassword = type === 'password' return ( <div className={cn('w-full', className)} style={{ '--c': accent, ...style } as CSSProperties}> <div className={cn( 'relative transition-colors duration-200', variant === 'outline' && 'border bg-foreground/[0.03]', variant === 'filled' && 'border-b-2 bg-foreground/[0.06]', variant === 'underline' && 'border-b', isFocused || invalid ? 'border-(--c)' : 'border-input', variant === 'filled' && !(isFocused || invalid) && 'border-foreground/20', disabled && 'opacity-50', )} style={{ height, borderRadius: variant === 'outline' ? radius : variant === 'filled' ? `${radius}px ${radius}px 0 0` : 0, boxShadow: variant === 'outline' && (isFocused || invalid) ? `0 0 0 3.5px color-mix(in oklab, ${accent} 24%, transparent)` : undefined, }} > {leading && ( <span className={cn('pointer-events-none absolute top-1/2 left-3.5 grid size-5 -translate-y-1/2 place-items-center transition-colors duration-200 [&>svg]:size-full', isFocused ? 'text-(--c)' : 'text-muted-foreground')}>{leading}</span> )} <motion.label htmlFor={inputId} className="pointer-events-none absolute top-0 origin-left leading-5 whitespace-nowrap" style={{ left: variant === 'underline' && !leading ? 0 : padX, fontSize: font, color: isFocused || invalid ? accent : 'var(--muted-foreground)' }} initial={false} animate={{ y: floated ? 8 : height / 2 - 10, scale: floated ? 0.78 : 1 }} transition={transition} > {label} </motion.label> <input {...inputProps} ref={ref} id={inputId} type={isPassword && reveal ? 'text' : type} value={text} disabled={disabled} aria-invalid={invalid || undefined} aria-describedby={error || hint ? `${inputId}-note` : undefined} placeholder={floated ? placeholder : ''} onChange={(event) => { if (value === undefined) setInner(event.target.value) onChange?.(event.target.value) }} onFocus={(event) => { setFocus(true) inputProps.onFocus?.(event) }} onBlur={(event) => { setFocus(false) inputProps.onBlur?.(event) }} className="absolute inset-0 h-full w-full bg-transparent pt-[22px] pb-1 text-foreground outline-none placeholder:text-muted-foreground/50" style={{ fontSize: font, paddingLeft: variant === 'underline' && !leading ? 0 : padX, paddingRight: isPassword ? 46 : 16, caretColor: accent }} /> {isPassword && ( <button type="button" aria-label={reveal ? 'Hide password' : 'Show password'} aria-pressed={reveal} onClick={() => setReveal((v) => !v)} className="absolute top-1/2 right-2 grid size-8 -translate-y-1/2 place-items-center rounded-md text-muted-foreground transition-colors duration-150 outline-none hover:text-foreground focus-visible:outline-2 focus-visible:outline-(--c)" > <svg viewBox="0 0 24 24" className="size-[18px]" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden> <path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" /> <circle cx="12" cy="12" r="3" /> {!reveal && <path d="m4 4 16 16" />} </svg> </button> )} {variant === 'underline' && ( <motion.span aria-hidden className="absolute inset-x-0 -bottom-px h-0.5 origin-left bg-(--c)" initial={false} animate={{ scaleX: isFocused || invalid ? 1 : 0 }} transition={transition} /> )} </div> <AnimatePresence initial={false} mode="wait"> {(error || hint) && ( <motion.p key={error ? 'error' : 'hint'} id={`${inputId}-note`} role={error ? 'alert' : undefined} className={cn('px-1 pt-1.5 text-xs', error ? 'text-rose-400' : 'text-muted-foreground')} initial={{ opacity: 0, y: reduced ? 0 : -4 }} animate={{ opacity: 1, y: 0, x: error && !reduced ? [0, -5, 4, -2, 0] : 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.28 }} > {error ?? hint} </motion.p> )} </AnimatePresence> </div> )}

