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

PropTypeDefaultDescription
labelstring— (required)Rendered as a <label htmlFor> above the field.
errorstring | 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.
refRef<HTMLInputElement>React 19 ref-as-prop — no forwardRef wrapper needed.
...restnative input propsSpread 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