Input
A simple text input component.
import { Input } from '@/components/input';
export default function InputExample() {
return (
<div className="w-90">
<Input placeholder="Type something..." />
</div>
);
} Dependencies
Source Code
'use client';
import type { VariantProps } from 'cva';
import {
Children,
createContext,
isValidElement,
use,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { Slot } from '@/components/slot';
import { composeRefs } from '@/lib/compose-refs';
import { cn, cva } from '@/lib/utils/classnames';
const inputStyle = cva({
base: [
'transition',
'h-10 w-full rounded-xl border px-4 py-1 font-medium text-base',
'text-foreground/80 placeholder:text-foreground-secondary focus:outline-none focus-visible:border-accent focus-visible:text-foreground focus-visible:ring-4 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-invalid:border-red-500 data-invalid:focus-visible:border-red-500 data-invalid:focus-visible:ring-red-500/20 data-invalid:hover:border-red-600',
'pl-(--prefix-width,calc(var(--spacing)*4))',
'pr-(--suffix-width,calc(var(--spacing)*4))',
],
variants: {
variant: {
default:
'border-border bg-background shadow-xs hover:border-mix-border/8',
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'> {
asChild?: boolean;
}
const InputPrefix = ({ className, ...props }: InputPrefixProps) => {
const { setPrefixWidth } = useInputGroup();
return (
<InputAddon
{...props}
className={cn('left-4', className)}
onSetWidth={setPrefixWidth}
/>
);
};
interface InputSuffixProps extends React.ComponentPropsWithRef<'div'> {
asChild?: boolean;
}
const InputSuffix = ({ className, ...props }: InputSuffixProps) => {
const { setSuffixWidth } = useInputGroup();
return (
<InputAddon
{...props}
className={cn('right-4', className)}
onSetWidth={setSuffixWidth}
/>
);
};
interface InputAddonProps extends React.ComponentPropsWithRef<'div'> {
onSetWidth?: (width: number) => void;
asChild?: boolean;
}
const InputAddon = ({
ref,
className,
onSetWidth,
asChild,
...props
}: InputAddonProps) => {
const Comp = asChild ? Slot : 'div';
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 (
<Comp
data-input-addon
className={cn(
'absolute top-1/2 flex -translate-y-1/2 items-center justify-center font-medium text-base',
'pointer-events-none text-foreground',
className
)}
ref={composeRefs(ref, internalRef)}
{...props}
/>
);
};
export { Input, InputGroup, InputPrefix, InputSuffix, inputStyle }; 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 |
|---|---|---|---|
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.
| Prop | Default | Type |
|---|---|---|
asChild | - | boolean |
InputSuffix
Extends the div element.
| Prop | Default | Type |
|---|---|---|
asChild | - | boolean |
Examples
Simple
Basic usage of the input component.
import { Input } from '@/components/input';
export default function InputExample() {
return (
<div className="w-90">
<Input placeholder="Type something..." />
</div>
);
} Minimal
A more subtle variant with minimal styling.
import { Input } from '@/components/input';
export default function InputMinimal() {
return (
<div className="w-90">
<Input variant="minimal" placeholder="Type something..." />
</div>
);
} Disabled
Example of a disabled input state.
import { Input } from '@/components/input';
export default function InputDisabled() {
return (
<div className="w-90">
<Input placeholder="Type something..." disabled />
</div>
);
} Invalid
Showing validation state with an invalid input.
import { Input } from '@/components/input';
export default function InputInvalid() {
return (
<div className="w-90">
<Input placeholder="Type something..." defaultValue="Pedro" invalid />
</div>
);
} Icon
Input with an icon prefix.
import { MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr';
import { Input, InputGroup, InputPrefix } from '@/components/input';
export default function InputIcon() {
return (
<div className="w-90">
<InputGroup>
<InputPrefix>
<MagnifyingGlassIcon />
</InputPrefix>
<Input placeholder="Search something" />
</InputGroup>
</div>
);
} Text Addons
Using text prefixes and suffixes.
https://
.significa.co
import {
Input,
InputGroup,
InputPrefix,
InputSuffix,
} from '@/components/input';
export default function InputTextAddons() {
return (
<div className="w-90">
<InputGroup>
<InputPrefix>https://</InputPrefix>
<Input placeholder="subdomain" />
<InputSuffix>.significa.co</InputSuffix>
</InputGroup>
</div>
);
} Icon and Action
Combining an icon prefix with an interactive suffix.
'use client';
import { EyeClosedIcon, EyeIcon, LockIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import {
Input,
InputGroup,
InputPrefix,
InputSuffix,
} from '@/components/input';
export default function InputIconAction() {
const [showPassword, setShowPassword] = useState(false);
return (
<div className="w-90">
<InputGroup>
<InputPrefix>
<LockIcon />
</InputPrefix>
<Input
type={showPassword ? 'text' : 'password'}
placeholder="Your password here"
/>
<InputSuffix className="pointer-events-auto">
<button
type="button"
className="cursor-pointer"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeIcon /> : <EyeClosedIcon />}
</button>
</InputSuffix>
</InputGroup>
</div>
);
} Interactive Addon
Example with clickable addon elements.
'use client';
import { InfoIcon } from '@phosphor-icons/react';
import { useRef } from 'react';
import {
Input,
InputGroup,
InputPrefix,
InputSuffix,
} from '@/components/input';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/tooltip';
export default function InputInteractiveAddon() {
const input = useRef<HTMLInputElement>(null);
return (
<div className="w-90">
<InputGroup>
<InputPrefix className="pointer-events-auto" asChild>
<button type="button" onClick={() => alert('interactive')}>
+351
</button>
</InputPrefix>
<Input ref={input} placeholder="000 000 000" />
<InputSuffix className="pointer-events-auto">
<Tooltip>
<TooltipTrigger>
<InfoIcon />
</TooltipTrigger>
<TooltipContent>Your phone number will be visible</TooltipContent>
</Tooltip>
</InputSuffix>
</InputGroup>
</div>
);
} 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
Previous
Dropdown
Next
Label