Input
Input, Textarea, and Select with label, hint, error, and size variants.
Input
Standard text input with label, hint, and error support.
Textarea
Multi-line text input. Shares all Input props.
<Input label="Email" placeholder="you@example.com" /><Textarea label="Message" placeholder="Write your message..." /><Select
label="Framework"
options={[
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "svelte", label: "Svelte" },
{ value: "angular", label: "Angular" },
]}
/><Input label="Small" inputSize="sm" />
<Input label="Medium" inputSize="md" />
<Input label="Large" inputSize="lg" />Hint
We'll never share your email.
<Input label="Email" hint="We'll never share your email." />Error
This field is required.
<Input label="Email" error="This field is required." />import { Input, Textarea, Select } from "@/ui";
<Input label="Email" />
<Textarea label="Message" />
<Select
label="Framework"
options={[
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
]}
/>src/ui/Input.tsx
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { Select as BaseSelect } from "@base-ui/react/select";
import React from "react";
import { cn } from "@/utils";
import { CheckIcon, KeyboardArrowDownIcon } from "./icons";
export const inputVariants = cva(
"w-full border bg-root text-root-contrast placeholder:text-root-contrast-subtle text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
default: "border-divider focus-visible:border-primary",
error:
"border-danger-contrast focus-visible:border-danger-contrast focus-visible:ring-danger-contrast",
},
inputSize: {
sm: "px-3 py-2 rounded-md",
md: "px-4 py-3 rounded-lg",
lg: "px-4 py-4 rounded-xl",
},
},
defaultVariants: {
variant: "default",
inputSize: "md",
},
}
);
function getDescribedBy(
hint: string | undefined,
hintId: string,
error: string | undefined,
errorId: string,
) {
return (
[hint ? hintId : null, error ? errorId : null]
.filter(Boolean)
.join(" ") || undefined
);
}
function FieldLabel({
id,
htmlFor,
children,
}: {
id?: string;
htmlFor?: string;
children: React.ReactNode;
}) {
return (
<label
id={id}
htmlFor={htmlFor}
className="text-root-contrast text-xs font-semibold uppercase tracking-widest"
>
{children}
</label>
);
}
function FieldHint({ id, children }: { id?: string; children: React.ReactNode }) {
return (
<p id={id} className="text-root-contrast-subtle text-xs">
{children}
</p>
);
}
function FieldError({ id, children }: { id?: string; children: React.ReactNode }) {
return (
<p id={id} role="alert" className="text-danger-contrast text-xs">
{children}
</p>
);
}
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputVariants> {
label: string;
hint?: string;
error?: string;
}
export function Input({
label,
hint,
error,
variant,
inputSize,
className,
...props
}: InputProps) {
const id = React.useId();
const hintId = React.useId();
const errorId = React.useId();
const effectiveVariant = error ? "error" : variant;
const describedBy = getDescribedBy(hint, hintId, error, errorId);
return (
<div className="flex flex-col gap-1.5">
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<input
id={id}
aria-invalid={!!error || undefined}
aria-describedby={describedBy}
className={cn(
inputVariants({ variant: effectiveVariant, inputSize }),
className,
)}
{...props}
/>
{hint && <FieldHint id={hintId}>{hint}</FieldHint>}
{error && <FieldError id={errorId}>{error}</FieldError>}
</div>
);
}
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement>,
VariantProps<typeof inputVariants> {
label: string;
hint?: string;
error?: string;
}
export function Textarea({
label,
hint,
error,
variant,
inputSize,
className,
...props
}: TextareaProps) {
const id = React.useId();
const hintId = React.useId();
const errorId = React.useId();
const effectiveVariant = error ? "error" : variant;
const describedBy = getDescribedBy(hint, hintId, error, errorId);
return (
<div className="flex flex-col gap-1.5">
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<textarea
id={id}
aria-invalid={!!error || undefined}
aria-describedby={describedBy}
className={cn(
inputVariants({ variant: effectiveVariant, inputSize }),
"resize-none",
className,
)}
rows={5}
{...props}
/>
{hint && <FieldHint id={hintId}>{hint}</FieldHint>}
{error && <FieldError id={errorId}>{error}</FieldError>}
</div>
);
}
export interface SelectProps extends VariantProps<typeof inputVariants> {
label: string;
options: { value: string; label: string }[];
placeholder?: string;
hint?: string;
error?: string;
value?: string;
defaultValue?: string;
onValueChange?: (value: string | null) => void;
disabled?: boolean;
name?: string;
required?: boolean;
className?: string;
}
export function Select({
label,
options,
placeholder = "Select an option...",
hint,
error,
variant,
inputSize,
className,
value,
defaultValue,
onValueChange,
disabled,
name,
required,
}: SelectProps) {
const labelId = React.useId();
const hintId = React.useId();
const errorId = React.useId();
const effectiveVariant = error ? "error" : variant;
const describedBy = getDescribedBy(hint, hintId, error, errorId);
return (
<div className="flex flex-col gap-1.5">
<FieldLabel id={labelId}>{label}</FieldLabel>
<BaseSelect.Root
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange ? (val) => onValueChange(val) : undefined}
disabled={disabled}
name={name}
required={required}
>
<BaseSelect.Trigger
aria-labelledby={labelId}
aria-describedby={describedBy}
aria-invalid={!!error || undefined}
className={cn(
inputVariants({ variant: effectiveVariant, inputSize }),
"data-placeholder:text-root-contrast-subtle flex cursor-pointer items-center justify-between text-left",
className,
)}
>
<BaseSelect.Value placeholder={placeholder} />
<BaseSelect.Icon className="ml-2 shrink-0 transition-transform data-open:rotate-180">
<KeyboardArrowDownIcon />
</BaseSelect.Icon>
</BaseSelect.Trigger>
<BaseSelect.Portal>
<BaseSelect.Positioner
sideOffset={4}
alignItemWithTrigger={false}
className="z-50"
>
<BaseSelect.Popup className="border-divider bg-root min-w-(--anchor-width) overflow-hidden rounded-lg border py-1 shadow-lg transition-[opacity,transform] duration-100 data-exiting:opacity-0 data-exiting:-translate-y-1 data-open:opacity-100 data-open:translate-y-0 opacity-0 -translate-y-1">
{options.map((o) => (
<BaseSelect.Item
key={o.value}
value={o.value}
className="text-root-contrast data-highlighted:bg-root-100 flex cursor-pointer items-center justify-between px-3 py-2 text-sm outline-none data-selected:font-medium data-disabled:cursor-not-allowed data-disabled:opacity-50"
>
<BaseSelect.ItemText>{o.label}</BaseSelect.ItemText>
<BaseSelect.ItemIndicator className="text-primary ml-2">
<CheckIcon />
</BaseSelect.ItemIndicator>
</BaseSelect.Item>
))}
</BaseSelect.Popup>
</BaseSelect.Positioner>
</BaseSelect.Portal>
</BaseSelect.Root>
{hint && <FieldHint id={hintId}>{hint}</FieldHint>}
{error && <FieldError id={errorId}>{error}</FieldError>}
</div>
);
}