ComponentsInput Focus Blur
Input Focus Blur
Placeholder
Install dependencies
npm i framer-motion tailwind-variants lucide-react
InputFocusBlur.tsx
// @NOTE: in case you are using Next.js
"use client";
import React, { forwardRef, useState, useCallback } from "react";
import { motion, AnimationProps } from "framer-motion";
import { tv } from "tailwind-variants";
import { AlertCircle } from "lucide-react";
interface InputFocusBlurProps extends React.ComponentProps<"input"> {
feedbackError?: string;
}
const EIXO_X_PLACEHOLDER = 24;
const STANDARD_DURATION = 0.3;
const inputFocusBlurStyles = tv({
slots: {
baseStyle: `w-full h-[42px] px-3 flex items-center rounded-xl border border-neutral-800 focus-within:border-neutral-200
bg-neutral-900 transition-all duration-200 relative data-[filled=true]:border-neutral-200`,
inputStyle: `flex-1 h-full py-2 outline-none text-sm text-neutral-300 bg-transparent relative z-[9999] placeholder:sr-only
disabled:cursor-not-allowed`,
placeholderStyle: `text-sm text-neutral-500 absolute left-3`,
feedbackErrorStyle: `flex items-center gap-1 text-xs text-red-300 mt-1`,
},
variants: {
error: {
true: {
baseStyle: `border-red-300`,
},
},
disabled: {
true: {
baseStyle: `bg-neutral-800 cursor-not-allowed`,
},
},
},
});
const { baseStyle, inputStyle, placeholderStyle, feedbackErrorStyle } =
inputFocusBlurStyles();
export const InputFocusBlur = forwardRef<HTMLInputElement, InputFocusBlurProps>(
({ placeholder, feedbackError = "", disabled, value, ...props }, ref) => {
const [isFocus, setIsFocus] = useState(false);
const [internalValue, setInternalValue] = useState("");
const handle = useCallback((type: "focus" | "blur") => {
setIsFocus(type === "focus");
}, []);
function observeFieldChange(event: React.ChangeEvent<HTMLInputElement>) {
setInternalValue(event.target.value);
}
const isFilled = internalValue.length > 0 || !!value;
const isFocusOrFilled = isFocus || isFilled;
const isError = feedbackError.length > 0 && !disabled;
const placeholderAnimation: AnimationProps["animate"] = isFocusOrFilled
? {
x: EIXO_X_PLACEHOLDER,
filter: "blur(4px)",
opacity: 0,
}
: {
x: 0,
};
return (
<div className="w-full max-w-[300px]">
<div
className={baseStyle({ error: isError, disabled })}
data-filled={isFilled}
>
<input
ref={ref}
type="text"
className={inputStyle()}
placeholder={placeholder}
onFocus={() => handle("focus")}
onBlur={() => handle("blur")}
onChange={observeFieldChange}
disabled={disabled}
value={value}
{...props}
/>
<motion.span
className={placeholderStyle()}
initial={{
x: 0,
}}
animate={placeholderAnimation}
transition={{
easings: ["easeOut"],
duration: STANDARD_DURATION,
}}
>
{placeholder}
</motion.span>
</div>
{isError && (
<motion.span
className={feedbackErrorStyle()}
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
transition={{
duration: STANDARD_DURATION,
}}
>
<AlertCircle size={12} />
{feedbackError}
</motion.span>
)}
</div>
);
}
);
InputFocusBlur.displayName = "InputFocusBlur";