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.
Prop | Default | Type | Description |
---|---|---|---|
|
|
| The visual style variant of the input. |
| - |
| 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.
Prop | Default | Type | Description |
---|---|---|---|
| - |
| Activates pointer events in the element |
InputSuffix
Extends the div
element.
Prop | Default | Type | Description |
---|---|---|---|
| - |
| 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
-
Validation:
- Provide clear error messages
- Use appropriate input types
-
Addons:
- Keep icons and text addons relevant
- Ensure interactive elements are clearly clickable
- Maintain consistent spacing