Input

Input, Textarea, and Select with label, hint, error, and size variants.

API Reference

Input

Standard text input with label, hint, and error support.

PropTypeDefault
labelstring
hintstring
errorstring
inputSize"sm" | "md" | "lg""md"
variant"outlined" | "filled""outlined"

Textarea

Multi-line text input. Shares all Input props.

PropTypeDefault
labelstring
hintstring
errorstring
inputSize"sm" | "md" | "lg""md"

Select

Dropdown select powered by Base UI.

PropTypeDefault
labelstring
options{ value: string; label: string }[]
placeholderstring"Select an option..."
hintstring
errorstring
inputSize"sm" | "md" | "lg""md"
valuestring
defaultValuestring
onValueChange(value: string | null) => void

Input

<Input label="Email" placeholder="you@example.com" />

Textarea

<Textarea label="Message" placeholder="Write your message..." />

Select

<Select
  label="Framework"
  options={[
    { value: "react", label: "React" },
    { value: "vue", label: "Vue" },
    { value: "svelte", label: "Svelte" },
    { value: "angular", label: "Angular" },
  ]}
/>

Sizes

<Input label="Small" inputSize="sm" />
<Input label="Medium" inputSize="md" />
<Input label="Large" inputSize="lg" />

Hints & Errors

Hint

We'll never share your email.

<Input label="Email" hint="We'll never share your email." />

Error

<Input label="Email" error="This field is required." />

Basic Usage

import { Input, Textarea, Select } from "@/ui";

<Input label="Email" />
<Textarea label="Message" />
<Select
  label="Framework"
  options={[
    { value: "react", label: "React" },
    { value: "vue", label: "Vue" },
  ]}
/>

Source

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>
  );
}