Input

A simple text input component.

Dependencies

Source Code

"use client";
 
import {
  Children,
  createContext,
  isValidElement,
  use,
  useLayoutEffect,
  useRef,
  useState,
} from "react";
import { VariantProps } from "cva";
 
import { cva, cn } from "@/lib/utils";
import { composeRefs } from "@/lib/compose-refs";
 
const inputStyle = cva({
  base: [
    "transition",
    "w-full border h-10 rounded-xl px-4 py-1 text-base font-medium",
    "focus:outline-none focus-visible:border-foreground/20 focus-visible:ring-4 focus-visible:ring-ring focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-50 text-foreground/80 placeholder:text-foreground-secondary data-[invalid]:border-red-500 data-[invalid]:hover:border-red-600 data-[invalid]:focus-visible:border-red-500 data-[invalid]:focus-visible:ring-red-500/20",
    "pl-[var(--prefix-width,calc(var(--spacing)*4))]",
    "pr-[var(--suffix-width,calc(var(--spacing)*4))]",
  ],
  variants: {
    variant: {
      default: "border-border bg-background hover:border-border-hard shadow-xs",
      minimal:
        "border-transparent bg-transparent hover:bg-background-secondary focus-visible:bg-background",
    },
  },
  defaultVariants: {
    variant: "default",
  },
});
 
interface InputProps
  extends Omit<React.ComponentPropsWithRef<"input">, "size"> {
  invalid?: boolean;
  variant?: VariantProps<typeof inputStyle>["variant"];
}
 
const Input = ({ className, invalid, variant, ...props }: InputProps) => {
  return (
    <input
      data-invalid={invalid}
      aria-invalid={invalid}
      className={cn(inputStyle({ variant }), className)}
      {...props}
    />
  );
};
 
interface InputGroupContextType {
  prefixWidth: number;
  suffixWidth: number;
  setPrefixWidth: (width: number) => void;
  setSuffixWidth: (width: number) => void;
}
 
const InputGroupContext = createContext<InputGroupContextType>({
  prefixWidth: 0,
  suffixWidth: 0,
  setPrefixWidth: () => {},
  setSuffixWidth: () => {},
});
 
const useInputGroup = () => {
  const ctx = use(InputGroupContext);
 
  if (!ctx) {
    throw new Error("InputGroup must be used within an InputGroupContext");
  }
 
  return ctx;
};
 
const InputGroup = ({
  className,
  style,
  ...props
}: React.ComponentPropsWithRef<"div">) => {
  const [prefixWidth, setPrefixWidth] = useState(0);
  const [suffixWidth, setSuffixWidth] = useState(0);
 
  // check if there are more than one InputPrefix or InputSuffix
  const tooManyPrefixes =
    Children.toArray(props.children).filter(
      (child) => isValidElement(child) && child.type === InputPrefix
    ).length > 1;
 
  const tooManySuffixes =
    Children.toArray(props.children).filter(
      (child) => isValidElement(child) && child.type === InputSuffix
    ).length > 1;
 
  if (tooManyPrefixes || tooManySuffixes) {
    throw new Error(
      "InputGroup cannot have more than one InputPrefix or InputSuffix"
    );
  }
 
  return (
    <InputGroupContext
      value={{ prefixWidth, suffixWidth, setPrefixWidth, setSuffixWidth }}
    >
      <div
        className={cn("relative", className)}
        style={{
          ...(prefixWidth > 0 && {
            "--prefix-width": `calc(${prefixWidth}px + var(--spacing)*5.5)`,
          }),
          ...(suffixWidth > 0 && {
            "--suffix-width": `calc(${suffixWidth}px + var(--spacing)*5.5)`,
          }),
          ...style,
        }}
        {...props}
      />
    </InputGroupContext>
  );
};
 
interface InputPrefixProps extends React.ComponentPropsWithRef<"div"> {
  interactive?: boolean;
}
 
const InputPrefix = (props: InputPrefixProps) => {
  const { setPrefixWidth } = useInputGroup();
 
  return <InputAddon {...props} onSetWidth={setPrefixWidth} />;
};
 
interface InputSuffixProps extends React.ComponentPropsWithRef<"div"> {
  interactive?: boolean;
}
 
const InputSuffix = (props: InputSuffixProps) => {
  const { setSuffixWidth } = useInputGroup();
 
  return <InputAddon {...props} onSetWidth={setSuffixWidth} />;
};
 
interface InputAddonProps extends React.ComponentPropsWithRef<"div"> {
  onSetWidth?: (width: number) => void;
  interactive?: boolean;
}
 
const InputAddon = ({
  ref,
  className,
  onSetWidth,
  interactive,
  ...props
}: InputAddonProps) => {
  const internalRef = useRef<HTMLDivElement | null>(null);
 
  useLayoutEffect(() => {
    onSetWidth?.(internalRef.current?.offsetWidth ?? 0);
 
    const observer = new ResizeObserver(([entry]) => {
      if (entry?.contentRect.width) {
        onSetWidth?.(Math.round(entry.contentRect.width));
      }
    });
 
    if (internalRef.current) {
      observer.observe(internalRef.current);
    }
 
    return () => observer.disconnect();
  }, [onSetWidth]);
 
  return (
    <div
      data-input-addon
      className={cn(
        "absolute top-1/2 flex -translate-y-1/2 items-center justify-center text-base font-medium",
        "first:left-4 last:right-4",
        interactive
          ? "text-foreground hover:text-foreground-secondary transition"
          : "text-foreground pointer-events-none",
        className
      )}
      ref={composeRefs(ref, internalRef)}
      {...props}
    />
  );
};
 
export { Input, inputStyle, InputGroup, InputPrefix, InputSuffix };

Features

  • Multiple Variants: Supports default and minimal styles
  • Validation States: Built-in support for invalid state
  • Flexible Addons: Support for prefix and suffix elements
  • Interactive Elements: Optional interactive addons for enhanced functionality

Anatomy

<InputGroup>
  <InputPrefix />
  <Input />
  <InputSuffix />
</InputGroup>

API Reference

Input

Extends the input element.

PropDefaultTypeDescription

variant

"default"

"default"

"minimal"

The visual style variant of the input.

invalid

-

boolean

Whether the input is in an invalid state.

InputGroup

Extends the div element.

This component is used to wrap the input when you want to add a prefix or suffix (e.g.: search icon, currency symbol, etc.)

InputPrefix

Extends the div element.

PropDefaultTypeDescription

interactive

-

boolean

Activates pointer events in the element

InputSuffix

Extends the div element.

PropDefaultTypeDescription

interactive

-

boolean

Activates pointer events in the element

Examples

Simple

Basic usage of the input component.

Minimal

A more subtle variant with minimal styling.

Disabled

Example of a disabled input state.

Invalid

Showing validation state with an invalid input.

Icon

Input with an icon prefix.

Text Addons

Using text prefixes and suffixes.

Icon and Action

Combining an icon prefix with an interactive suffix.

Interactive Addon

Example with clickable addon elements.

Best Practices

  1. Validation:

    • Provide clear error messages
    • Use appropriate input types
  2. Addons:

    • Keep icons and text addons relevant
    • Ensure interactive elements are clearly clickable
    • Maintain consistent spacing