Input
Inputs are fairly simple and minimal but when it came to scaling this for production/scalability, it became apparent that there was a huge gap between the components I made previously versus what went into this one. There were so many states and factors to consider such as a developer jumping in and adding in any native html props without prop bombing my component. Another factor that I thought was important that I neglected in the past was accessibility through keyboard navigation. I have to go back and implement those features in the previous components and check if any other components need refs. Lastly, I thought it was really cool to dig into state management and understanding how to manually sort out styling priority depending on state versus utilizing twmerge and clsx like I have in the past without really knowing why it worked in the first place.
Props & Styling
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — (required) | Rendered as a <label htmlFor> above the field. |
error | string | boolean | — (required) | Truthy switches to the red error style. A string value is also rendered as helper text below the input; true shows only the red border with no message. |
ref | Ref<HTMLInputElement> | — | React 19 ref-as-prop — no forwardRef wrapper needed. |
...rest | native input props | — | Spread directly onto the <input> (type, placeholder, disabled, onChange, etc.). |
Styling
State styling is resolved with a fixed priority, not combined: disabled is checked first, then error, then the normal style (see getInputStyles()). A disabled+error input will always render as disabled — the red error border never shows through.
aria-invalid and aria-describedby are derived automatically from error, and the field's id falls back to a generated useId() when you don't pass one — the error message's id (${inputId}-error) is wired to aria-describedby for you, so screen readers announce it without any extra props.
Code
import { cn } from "src/lib/utils"
import { useId } from "react"
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error: string | boolean;
ref: React.Ref<HTMLInputElement>;
}
const variantInputStyles = {
error: 'border-red-500 focus:ring-2 focus-ring-red-300',
normal: 'border-gray-200 focus:ring-2 focus-ring-blue-300',
disabled: 'bg-gray-50 border-gray-100 cursor-not-allowed text-gray-100'
}
const Input = ({label, error, ref, ...props}: InputProps) => {
const generatedId = useId();
const inputId = props.id || generatedId;
function getInputStyles() {
const styles = [];
if (props.disabled) {
styles.push(variantInputStyles.disabled);
}
else if (error) {
styles.push(variantInputStyles.error);
}
else {
styles.push(variantInputStyles.normal);
}
return styles.join(' ');
}
return (
<div className = "flex flex-col gap-1">
<label
className = "font-medium text-gray-700"
htmlFor={inputId}
>
{label}
</label>
<input
{...props}
ref={ref}
id={inputId}
aria-invalid = {!!error}
aria-describedby= {error ? `${inputId}-error` : undefined}
className = {cn("w-full px-3 py-2 rounded-md border",
getInputStyles())}
/>
{error && typeof error === 'string' && (
<p
className = "text-sm text-red-600 mt-1"
id = {`${inputId}-error`}
>
{error}
</p>
)}
</div>
)
}
export default Input