Forms

Input

Input wraps a native <input> with label/helpText/error chrome, leading/trailing icon slots, and an optional clear button.

Installation

npx shadcn add https://rck.vibeboxph.com//r/input.json

Live preview & controls

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

Anatomy

  • FieldLabelBuilt-in top field label (re-exported as InputLabel).
  • input-wrapperThe bordered field container (focus ring lives here).
  • inputThe native <input> element.
  • FieldMessageHelp text or error message below the field.

Props

PropTypeDefaultDescription
labelReact.ReactNodeField label.
helpTextReact.ReactNodeSupplementary help text shown when there's no error.
errorReact.ReactNodeError text; also sets `aria-invalid` and switches to the destructive style.
size"sm" | "md" | "lg"mdField height.
leadingIcon / trailingIconReact.ReactNodeIcon slots either side of the input.
iconReact.ReactNodeShorthand icon slot — any React node (custom SVG, Icon, emoji). Defaults to leading.
iconPosition"leading" | "trailing"leadingWhich side the `icon` prop renders on.
clearablebooleanShows a clear button once the field has a value.
value / defaultValuestringControlled / uncontrolled value.
onValueChange(value: string) => voidConvenience change handler (in addition to native onChange).
classNamestringMerged onto the native <input> element.
containerClassNamestringMerged onto the outer field wrapper.
wrapperClassNamestringMerged onto the bordered input wrapper.
onBlur, onFocus, onKeyDown, onChange, …InputHTMLAttributes<HTMLInputElement>All native input events and attributes are forwarded to the underlying <input>.

Accessibility

  • Label is always associated to the field via `htmlFor`/`id` (auto-generated with `useId` when not provided).
  • `error` sets `aria-invalid` and `aria-describedby` pointing at the message, and the message itself has `role="alert"`.
  • The clear button has an explicit `aria-label` and is skipped in the tab order only when hidden (no value).

Source

registry/rck/ui/input.tsx
"use client";

import * as React from "react";
import { X } from "@/lib/icons";
import { cn } from "@/lib/utils";
import {
  fieldWrapperVariants,
  FieldLabel,
  FieldMessage,
  type FieldWrapperVariantProps,
} from "@/registry/rck/ui/field-chrome";

export {
  fieldWrapperVariants as inputWrapperVariants,
  FieldLabel,
  FieldLabel as InputLabel,
  FieldMessage,
  FieldMessage as InputMessage,
} from "@/registry/rck/ui/field-chrome";
export { Textarea, type TextareaProps } from "@/registry/rck/ui/textarea";

export interface InputProps
  extends
    Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
    FieldWrapperVariantProps {
  label?: React.ReactNode;
  helpText?: React.ReactNode;
  error?: React.ReactNode;
  /** Shorthand for a leading/trailing icon slot — accepts any React node (SVG, Icon, emoji, etc.). */
  icon?: React.ReactNode;
  iconPosition?: "leading" | "trailing";
  leadingIcon?: React.ReactNode;
  trailingIcon?: React.ReactNode;
  clearable?: boolean;
  onValueChange?: (value: string) => void;
  containerClassName?: string;
  wrapperClassName?: string;
}

export const Input = React.forwardRef<HTMLInputElement, InputProps>(
  (
    {
      className,
      containerClassName,
      wrapperClassName,
      size,
      label,
      helpText,
      error,
      icon,
      iconPosition = "leading",
      leadingIcon,
      trailingIcon,
      clearable,
      disabled,
      required,
      id: providedId,
      value,
      defaultValue,
      onChange,
      onValueChange,
      ...props
    },
    ref,
  ) => {
    const generatedId = React.useId();
    const id = providedId ?? generatedId;
    const helpTextId = helpText ? `${id}-help-text` : undefined;
    const errorId = error ? `${id}-error` : undefined;
    const invalid = Boolean(error);

    const innerRef = React.useRef<HTMLInputElement>(null);
    React.useImperativeHandle(ref, () => innerRef.current as HTMLInputElement);

    const isControlled = value !== undefined;
    const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
    const currentValue = isControlled ? value : internalValue;
    const showClear = clearable && !disabled && String(currentValue ?? "").length > 0;
    const resolvedLeadingIcon =
      leadingIcon ?? (icon && iconPosition === "leading" ? icon : undefined);
    const resolvedTrailingIcon =
      trailingIcon ?? (icon && iconPosition === "trailing" ? icon : undefined);

    function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
      if (!isControlled) setInternalValue(event.target.value);
      onChange?.(event);
      onValueChange?.(event.target.value);
    }

    function handleClear() {
      if (!isControlled) setInternalValue("");
      onValueChange?.("");
      if (innerRef.current) {
        const nativeSetter = Object.getOwnPropertyDescriptor(
          window.HTMLInputElement.prototype,
          "value",
        )?.set;
        nativeSetter?.call(innerRef.current, "");
        innerRef.current.dispatchEvent(new Event("input", { bubbles: true }));
      }
      innerRef.current?.focus();
    }

    return (
      <div
        data-slot="input-container"
        className={cn("flex flex-col gap-1.5", containerClassName)}
      >
        {label && (
          <FieldLabel htmlFor={id} required={required}>
            {label}
          </FieldLabel>
        )}
        <div
          data-slot="input-wrapper"
          className={cn(fieldWrapperVariants({ size, invalid }), wrapperClassName)}
        >
          {resolvedLeadingIcon && (
            <span
              data-slot="input-icon-leading"
              className="text-muted-foreground flex shrink-0 items-center [&_svg]:size-4"
            >
              {resolvedLeadingIcon}
            </span>
          )}
          <input
            ref={innerRef}
            id={id}
            data-slot="input"
            disabled={disabled}
            required={required}
            aria-invalid={invalid || undefined}
            aria-describedby={cn(helpTextId, errorId) || undefined}
            className={cn(
              "placeholder:text-muted-foreground w-full min-w-0 bg-transparent outline-none disabled:cursor-not-allowed",
              className,
            )}
            value={isControlled ? value : undefined}
            defaultValue={isControlled ? undefined : defaultValue}
            onChange={handleChange}
            {...props}
          />
          {showClear && (
            <button
              type="button"
              onClick={handleClear}
              className="text-muted-foreground hover:text-foreground focus-ring flex shrink-0 items-center rounded-sm"
              aria-label="Clear input"
            >
              <X className="size-3.5" />
            </button>
          )}
          {resolvedTrailingIcon && (
            <span
              data-slot="input-icon-trailing"
              className="text-muted-foreground flex shrink-0 items-center [&_svg]:size-4"
            >
              {resolvedTrailingIcon}
            </span>
          )}
        </div>
        {helpText && !error && <FieldMessage id={helpTextId}>{helpText}</FieldMessage>}
        {error && (
          <FieldMessage id={errorId} invalid role="alert">
            {error}
          </FieldMessage>
        )}
      </div>
    );
  },
);
Input.displayName = "Input";
registry/rck/ui/field-chrome.tsx
import * as React from "react";
import { cva, type VariantProps } from "@/lib/cva";
import { cn } from "@/lib/utils";

export const fieldWrapperVariants = cva(
  "flex w-full items-center gap-2 rounded-md border bg-background px-3 shadow-sm " +
    "transition-colors has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50 " +
    "has-[textarea:disabled]:cursor-not-allowed has-[textarea:disabled]:opacity-50",
  {
    variants: {
      size: {
        sm: "h-8 text-[10px]",
        md: "h-[39px] text-[11px]",
        lg: "h-11 text-xs",
      },
      invalid: {
        true: "border-destructive focus-within:ring-2 focus-within:ring-destructive/40",
        false:
          "border-border-strong focus-within:border-primary focus-within:ring-2 focus-within:ring-ring/30",
      },
    },
    defaultVariants: {
      size: "md",
      invalid: false,
    },
  },
);

/** Built-in field label — shared by Input, Textarea, and boolean field wrappers. */
export function FieldLabel({
  className,
  required,
  children,
  ...props
}: React.LabelHTMLAttributes<HTMLLabelElement> & { required?: boolean }) {
  return (
    <label
      data-slot="field-label"
      className={cn("text-foreground text-sm leading-none font-medium", className)}
      {...props}
    >
      {children}
      {required && <span className="text-destructive ml-0.5">*</span>}
    </label>
  );
}

export function FieldMessage({
  className,
  invalid,
  ...props
}: React.HTMLAttributes<HTMLParagraphElement> & { invalid?: boolean }) {
  return (
    <p
      data-slot="field-message"
      className={cn(
        "text-xs",
        invalid ? "text-destructive" : "text-muted-foreground",
        className,
      )}
      {...props}
    />
  );
}

export type ControlPosition = "start" | "end";

export interface BooleanGroupOption {
  value: string;
  /** Inline label beside the control. */
  text: string;
  disabled?: boolean;
  helpText?: React.ReactNode;
}

export interface BooleanFieldLayoutProps {
  label?: React.ReactNode;
  text?: React.ReactNode;
  helpText?: React.ReactNode;
  error?: React.ReactNode;
  required?: boolean;
  /** Places the control at inline-start (default) or inline-end — flips automatically in RTL. */
  controlPosition?: ControlPosition;
  disabled?: boolean;
  id?: string;
  className?: string;
  containerClassName?: string;
  children: (args: {
    id: string;
    describedBy?: string;
    disabled?: boolean;
  }) => React.ReactNode;
}

export function BooleanFieldLayout({
  label,
  text,
  helpText,
  error,
  required,
  controlPosition = "start",
  disabled,
  id: providedId,
  className,
  containerClassName,
  children,
}: BooleanFieldLayoutProps) {
  const generatedId = React.useId();
  const id = providedId ?? generatedId;
  const helpTextId = helpText && !error ? `${id}-help-text` : undefined;
  const errorId = error ? `${id}-error` : undefined;
  const describedBy = cn(helpTextId, errorId) || undefined;
  const control = children({ id, describedBy, disabled });

  return (
    <div
      data-slot="boolean-field"
      className={cn("flex flex-col gap-1.5", containerClassName)}
    >
      {label && (
        <FieldLabel htmlFor={id} required={required}>
          {label}
        </FieldLabel>
      )}
      <div
        className={cn(
          "flex w-full items-start gap-2",
          controlPosition === "end" && "flex-row-reverse justify-between",
          className,
        )}
      >
        {control}
        {text && (
          <label
            htmlFor={id}
            className={cn(
              "text-foreground text-sm leading-none font-medium",
              controlPosition === "end" && "flex-1",
              disabled && "cursor-not-allowed opacity-50",
            )}
          >
            {text}
          </label>
        )}
      </div>
      {helpText && !error && <FieldMessage id={helpTextId}>{helpText}</FieldMessage>}
      {error && (
        <FieldMessage id={errorId} invalid role="alert">
          {error}
        </FieldMessage>
      )}
    </div>
  );
}

export type FieldWrapperVariantProps = VariantProps<typeof fieldWrapperVariants>;

Related examples

Browse the full examples gallery