Skip to content
UI Kit
Accent color

Input field for React

Text input with label, hint, error as text, icons, prefix, suffix and addons.

One of 9 components in Forms and inputs

4 variants

The text field done properly

The input is the field every form starts with. This one ties its label, hint and error to the field with the right attributes, so screen readers read all three, and it shows errors as text next to the field instead of only a red border.

States shows default, focus, filled, disabled, read only and error side by side. Icons and addons adds a search or mail icon, a prefix such as https or a currency, a suffix such as a unit and attached buttons. Sizes matches the three button sizes, so an input and a button in one row line up. Password has a show and hide button and the right autocomplete for new and current passwords.

Set type and autocomplete for every field, phones then show the right keyboard and fill known data. Keep the label visible, a placeholder is a hint and disappears while typing. Say in the error what to do, Enter a date like 24.12.2026, not Invalid input.

Variants

States

Label, hint, optional, required, error, success and disabled. The focus ring fades in, an error shakes once, a valid phone number draws a check. Errors are text, never color alone.

Icons and addons

Leading icon, inline prefix and suffix, attached segments and a button inside.

Sizes

Small, medium and large, each as tall as the button of the same size. The last one has a floating label, 56 px tall, it rises when you type.

Password

Show and hide with a labelled button, a link in the label row and an error.

Details

What Input brings along, read from the registry and the source.

Variants
4
Version
1.0.1 · since 1.0.0
File
components/ui/input.tsx
Brings along
utilskiticon
npm packages
None
Languages
English and German, 3 texts of its own
Studio
Adjust in code

Props

The InputProps interface from components/ui/input.tsx, with the comments from the code. Also takes every prop of FieldOwnProps, Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'prefix'>.

  • sizeoptional
    ControlSize

    smmdlg

  • iconoptional
    IconName

    Icon at the start

  • prefixoptional
    React.ReactNode

    Quiet text inside the box, before the value, e.g. "https://"

  • suffixoptional
    React.ReactNode

    Quiet text inside the box, after the value, e.g. "€" or "kg"

  • startAddonoptional
    React.ReactNode

    A boxed segment attached at the start, e.g. a protocol or a country code

  • endAddonoptional
    React.ReactNode

    A boxed segment attached at the end, e.g. a domain

  • trailingoptional
    React.ReactNode

    Any element at the end inside the box, e.g. a button, a key hint or a spinner

  • invalidoptional
    boolean

    Marks the control invalid without an error text (use error for text). The box shakes once when it turns invalid.

  • successoptional
    boolean

    Marks the value as accepted: a green edge and a check that draws itself

  • floatingLabeloptional
    boolean

    The label sits inside the box like a placeholder and floats up when the field has focus or a value. The box is 56 px tall. Off by default.

  • refoptional
    React.Ref<HTMLInputElement>
  • inputClassNameoptional
    string

Built-in texts

Input writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 3 texts
optionalEnglish OptionalGerman Optional
showEnglish Show passwordGerman Passwort zeigen
hideEnglish Hide passwordGerman Passwort verbergen

Accessibility

Read from the source of the component.

  • Sets aria-describedby, aria-invalid, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 442 lines
components/ui/input.tsx
'use client';

import { createContext, useContext, useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';

/*
 * Text input and the field system every form control shares: a label,
 * a hint, an error as text, and the wiring between them (id, aria-describedby,
 * aria-invalid). Wrap any control in <Field>, or pass label/hint/error
 * straight to the control.
 */

const TEXTS = {
  en: { optional: 'Optional', show: 'Show password', hide: 'Hide password' },
  de: { optional: 'Optional', show: 'Passwort zeigen', hide: 'Passwort verbergen' },
};

export type ControlSize = 'sm' | 'md' | 'lg';

/** Heights match the button sizes, so inputs and buttons line up in a row (small grows to 44 px on phones, like the small button). 16 px text on phones keeps iOS from zooming. */
export const CONTROL_HEIGHT: Record<ControlSize, string> = {
  sm: 'h-11 rounded-md text-base sm:h-9 sm:text-[13px]',
  md: 'h-11 rounded-lg text-base sm:text-sm',
  lg: 'h-12 rounded-lg text-base sm:text-[15px]',
};
export const CONTROL_PAD: Record<ControlSize, string> = { sm: 'px-3', md: 'px-3.5', lg: 'px-4' };
const PAD_START: Record<ControlSize, string> = { sm: 'ps-3', md: 'ps-3.5', lg: 'ps-4' };
const PAD_END: Record<ControlSize, string> = { sm: 'pe-3', md: 'pe-3.5', lg: 'pe-4' };
const MARGIN_START: Record<ControlSize, string> = { sm: 'ms-3', md: 'ms-3.5', lg: 'ms-4' };
const ICON_SIZE: Record<ControlSize, number> = { sm: 15, md: 17, lg: 18 };

/** The box of every control: border, surface, hover, focus ring, invalid and disabled */
export const controlBox = cn(
  // The focus ring grows out of the border and fades in, it does not snap on
  'border border-input bg-background text-foreground shadow-[var(--shadow-sm)] transition-[border-color,box-shadow,background-color,scale] duration-300 ease-[var(--ease-kit)]',
  'hover:border-border-strong',
  'focus-within:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent),var(--shadow-sm)]',
  'data-invalid:border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))] data-invalid:focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--destructive)_16%,transparent),var(--shadow-sm)]',
  'data-disabled:cursor-not-allowed data-disabled:bg-muted data-disabled:text-muted-foreground data-disabled:shadow-none data-disabled:hover:border-input',
);

/** The same box for a single focusable element (select trigger, buttons that look like fields) */
export const controlBoxFocus = cn(
  controlBox,
  'outline-none focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent),var(--shadow-sm)]',
  'aria-invalid:border-[color-mix(in_oklab,var(--destructive)_75%,var(--border))]',
  'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground disabled:shadow-none',
);

/* ------------------------------------------------------------------ */
/* Motion shared by the form controls                                  */
/* ------------------------------------------------------------------ */

const EASE = 'cubic-bezier(0.2,0.7,0.2,1)';
const SHAKE: Keyframe[] = [
  { transform: 'translateX(0)' },
  { transform: 'translateX(-6px)' },
  { transform: 'translateX(5px)' },
  { transform: 'translateX(-3px)' },
  { transform: 'translateX(2px)' },
  { transform: 'translateX(0)' },
];

/** Shakes the element once when it turns invalid. Not on the first render, not with reduced motion. */
export function useShake(ref: React.RefObject<HTMLElement | null>, invalid: boolean) {
  const was = useRef(invalid);
  useEffect(() => {
    const turned = invalid && !was.current;
    was.current = invalid;
    const el = ref.current;
    if (!turned || !el || prefersReducedMotion() || typeof el.animate !== 'function') return;
    el.animate(SHAKE, { duration: 420, easing: 'ease-out' });
  }, [invalid, ref]);
}

/*
 * What is on the page from the start just stands there; what arrives later
 * (an error after typing, a check after a valid entry) comes in with a short
 * movement. The first control effect marks the moment the page came alive.
 */
let alive = 0;
/** ms since the first form control on this page ran its effects */
export function sinceAlive() {
  const now = performance.now();
  if (!alive) alive = now;
  return now - alive;
}

/** Rises in a few pixels when it mounts after the page came alive */
export function useArrival<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  useEffect(() => {
    const el = ref.current;
    if (sinceAlive() < 400 || !el || prefersReducedMotion() || typeof el.animate !== 'function') return;
    el.animate([{ transform: 'translateY(-4px)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }], { duration: 320, easing: EASE });
  }, []);
  return ref;
}

/** A check that draws itself (stroke-dashoffset) when it appears after the page came alive */
export function SuccessMark({ size = 16, className }: { size?: number; className?: string }) {
  const ref = useRef<SVGPathElement>(null);
  useEffect(() => {
    const el = ref.current;
    if (sinceAlive() < 400 || !el || prefersReducedMotion() || typeof el.animate !== 'function') return;
    el.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 1, offset: 0.15 }, { strokeDashoffset: 0 }], { duration: 480, easing: EASE });
  }, []);
  return (
    <svg viewBox="0 0 24 24" width={size} height={size} fill="none" aria-hidden className={cn('shrink-0 text-success', className)}>
      <path ref={ref} d="m5 12.5 4.5 4.5L19 7.5" pathLength={1} strokeDasharray="1 1" strokeDashoffset={0} stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* Field                                                               */
/* ------------------------------------------------------------------ */

interface FieldState {
  id: string;
  describedBy?: string;
  invalid: boolean;
  disabled?: boolean;
  required?: boolean;
}

const FieldContext = createContext<FieldState | null>(null);

/** The props a control needs to be wired to its field. Works without a field too. */
export function useControl(props: { id?: string; invalid?: boolean; disabled?: boolean; required?: boolean; 'aria-describedby'?: string }) {
  const field = useContext(FieldContext);
  const auto = useId();
  const invalid = props.invalid ?? field?.invalid ?? false;
  return {
    id: props.id ?? field?.id ?? auto,
    'aria-describedby': cn(field?.describedBy, props['aria-describedby']) || undefined,
    'aria-invalid': invalid || undefined,
    disabled: props.disabled ?? field?.disabled,
    required: props.required ?? field?.required,
    invalid,
  };
}

export interface FieldOwnProps {
  label?: React.ReactNode;
  /** Help text under the control */
  hint?: React.ReactNode;
  /** Error as text. Replaces the hint and marks the control invalid. */
  error?: React.ReactNode;
  /** Shows "Optional" next to the label */
  optional?: boolean;
  /** Something at the right end of the label row, e.g. a "Forgot password?" link */
  labelAction?: React.ReactNode;
}

export interface FieldProps extends FieldOwnProps {
  id?: string;
  required?: boolean;
  disabled?: boolean;
  className?: string;
  children: React.ReactNode;
}

export function Label({ htmlFor, required, optional, className, children, id }: { htmlFor?: string; required?: boolean; optional?: boolean; className?: string; children: React.ReactNode; id?: string }) {
  const t = useTexts(TEXTS);
  return (
    <label id={id} htmlFor={htmlFor} className={cn('inline-flex items-baseline gap-1.5 text-sm font-medium leading-5 text-foreground', className)}>
      <span>
        {children}
        {required && (
          <span aria-hidden className="ms-0.5 text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]">
            *
          </span>
        )}
      </span>
      {optional && <span className="text-xs font-normal text-subtle-foreground">{t.optional}</span>}
    </label>
  );
}

export function FieldHint({ id, className, children }: { id?: string; className?: string; children: React.ReactNode }) {
  return (
    <p id={id} className={cn('text-[13px] leading-5 text-muted-foreground', className)}>
      {children}
    </p>
  );
}

export function FieldError({ id, className, children }: { id?: string; className?: string; children: React.ReactNode }) {
  const ref = useArrival<HTMLParagraphElement>();
  return (
    <p ref={ref} id={id} className={cn('flex items-start gap-1.5 text-[13px] font-medium leading-5 text-destructive', className)}>
      <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px]" />
      <span>{children}</span>
    </p>
  );
}

/** Label, control, hint or error, wired together. */
export function Field({ id, label, hint, error, optional, labelAction, required, disabled, className, children }: FieldProps) {
  const auto = useId();
  const fid = id ?? auto;
  const messageId = `${fid}-message`;
  const hasMessage = Boolean(error || hint);
  return (
    <FieldContext.Provider value={{ id: fid, describedBy: hasMessage ? messageId : undefined, invalid: Boolean(error), disabled, required }}>
      <div className={cn('grid w-full min-w-0 grid-cols-[minmax(0,1fr)] content-start gap-2', disabled && 'opacity-80', className)}>
        {(label || labelAction) && (
          <div className="flex min-h-5 items-baseline justify-between gap-3">
            {label && (
              <Label htmlFor={fid} required={required} optional={optional}>
                {label}
              </Label>
            )}
            {labelAction && <div className="text-[13px]">{labelAction}</div>}
          </div>
        )}
        {children}
        <div aria-live="polite" className="empty:hidden">
          {error ? <FieldError id={messageId}>{error}</FieldError> : hint ? <FieldHint id={messageId}>{hint}</FieldHint> : null}
        </div>
      </div>
    </FieldContext.Provider>
  );
}

/** Wraps a control in a Field when label, hint or error are given */
export function withField(props: FieldOwnProps & { id?: string; required?: boolean; disabled?: boolean; className?: string }, control: React.ReactNode) {
  const { label, hint, error, optional, labelAction, id, required, disabled, className } = props;
  if (!label && !hint && !error && !labelAction) return control;
  return (
    <Field {...{ label, hint, error, optional, labelAction, id, required, disabled, className }}>
      {control}
    </Field>
  );
}

/* ------------------------------------------------------------------ */
/* Input                                                               */
/* ------------------------------------------------------------------ */

export interface InputProps extends FieldOwnProps, Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'prefix'> {
  size?: ControlSize;
  /** Icon at the start */
  icon?: IconName;
  /** Quiet text inside the box, before the value, e.g. "https://" */
  prefix?: React.ReactNode;
  /** Quiet text inside the box, after the value, e.g. "€" or "kg" */
  suffix?: React.ReactNode;
  /** A boxed segment attached at the start, e.g. a protocol or a country code */
  startAddon?: React.ReactNode;
  /** A boxed segment attached at the end, e.g. a domain */
  endAddon?: React.ReactNode;
  /** Any element at the end inside the box, e.g. a button, a key hint or a spinner */
  trailing?: React.ReactNode;
  /** Marks the control invalid without an error text (use error for text). The box shakes once when it turns invalid. */
  invalid?: boolean;
  /** Marks the value as accepted: a green edge and a check that draws itself */
  success?: boolean;
  /**
   * The label sits inside the box like a placeholder and floats up when the
   * field has focus or a value. The box is 56 px tall. Off by default.
   */
  floatingLabel?: boolean;
  ref?: React.Ref<HTMLInputElement>;
  /** Class of the outer element (field or box) */
  className?: string;
  inputClassName?: string;
}

function Addon({ side, children }: { side: 'start' | 'end'; children: React.ReactNode }) {
  return (
    <span className={cn('flex h-full shrink-0 items-center bg-surface-2 px-3 text-muted-foreground', side === 'start' ? 'rounded-s-[inherit] border-e border-input' : 'rounded-e-[inherit] border-s border-input')}>
      {children}
    </span>
  );
}

const SUCCESS_BOX =
  'border-[color-mix(in_oklab,var(--success)_55%,var(--border))] hover:border-[color-mix(in_oklab,var(--success)_65%,var(--border))] focus-within:border-[color-mix(in_oklab,var(--success)_70%,var(--border-strong))] focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--success)_16%,transparent),var(--shadow-sm)]';
const MARGIN_END: Record<ControlSize, string> = { sm: 'me-3', md: 'me-3.5', lg: 'me-4' };

function InputBox({
  size = 'md',
  icon,
  prefix,
  suffix,
  startAddon,
  endAddon,
  trailing,
  invalid,
  success,
  floating,
  className,
  inputClassName,
  ref,
  ...rest
}: Omit<InputProps, keyof FieldOwnProps | 'floatingLabel'> & { floating?: { label: React.ReactNode } }) {
  const c = useControl({ ...rest, invalid });
  const boxRef = useRef<HTMLDivElement>(null);
  useShake(boxRef, c.invalid);
  const ok = Boolean(success) && !c.invalid;
  const lead = Boolean(icon || prefix) && !startAddon;
  const tail = Boolean(suffix || trailing || ok) && !endAddon;
  const padStart = lead ? (prefix ? 'ps-1' : 'ps-2.5') : startAddon ? 'ps-3' : PAD_START[size];
  const input = (
    <input
      {...rest}
      ref={ref}
      id={c.id}
      disabled={c.disabled}
      required={c.required}
      aria-invalid={c['aria-invalid']}
      aria-describedby={c['aria-describedby']}
      placeholder={floating ? rest.placeholder ?? ' ' : rest.placeholder}
      className={cn(
        'h-full w-0 min-w-0 flex-1 bg-transparent text-inherit outline-none placeholder:text-subtle-foreground disabled:cursor-not-allowed',
        '[&::-webkit-search-cancel-button]:appearance-none',
        padStart,
        tail ? 'pe-2' : endAddon ? 'pe-3' : PAD_END[size],
        floating && 'peer pt-[18px] placeholder:text-transparent focus:placeholder:text-subtle-foreground',
        inputClassName,
      )}
    />
  );
  return (
    <div
      ref={boxRef}
      data-invalid={c.invalid || undefined}
      data-disabled={c.disabled || undefined}
      data-success={ok || undefined}
      className={cn(
        // No overflow-hidden: it would clip the 44 px hit area of a button inside (password eye). The addons round their own corners.
        'group/input relative flex w-full min-w-0 items-center',
        floating ? 'h-14 rounded-lg text-base sm:text-[15px]' : CONTROL_HEIGHT[size],
        controlBox,
        ok && SUCCESS_BOX,
        className,
      )}
    >
      {startAddon && <Addon side="start">{startAddon}</Addon>}
      {icon && (
        <Icon
          name={icon}
          size={ICON_SIZE[size]}
          className={cn('shrink-0 text-subtle-foreground transition-colors duration-300 group-focus-within/input:text-muted-foreground', startAddon ? 'ms-3' : MARGIN_START[size])}
        />
      )}
      {prefix && <span className={cn('shrink-0 select-none text-subtle-foreground', icon ? 'ps-2' : startAddon ? 'ps-3' : PAD_START[size], floating && 'pt-[18px]')}>{prefix}</span>}
      {floating ? (
        <span className="relative flex h-full min-w-0 flex-1">
          {input}
          <label
            htmlFor={c.id}
            className={cn(
              'pointer-events-none absolute inset-y-0 start-0 flex max-w-full origin-[0_50%] items-center truncate text-subtle-foreground transition-[translate,scale,color] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none',
              padStart,
              'peer-focus:-translate-y-[11px] peer-focus:scale-[0.8] peer-focus:text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]',
              'peer-[:not(:placeholder-shown)]:-translate-y-[11px] peer-[:not(:placeholder-shown)]:scale-[0.8] peer-[:not(:placeholder-shown)]:text-muted-foreground',
            )}
          >
            {floating.label}
            {c.required && (
              <span aria-hidden className="ms-0.5 text-[color-mix(in_oklab,var(--destructive)_85%,var(--foreground))]">
                *
              </span>
            )}
          </label>
        </span>
      ) : (
        input
      )}
      {ok && !endAddon && <SuccessMark size={ICON_SIZE[size] - 1} className={suffix || trailing ? 'me-2' : MARGIN_END[size]} />}
      {suffix && <span className={cn('shrink-0 select-none text-subtle-foreground', trailing ? 'pe-2' : PAD_END[size], floating && 'pt-[18px]')}>{suffix}</span>}
      {trailing && <span className={cn('flex shrink-0 items-center', size === 'sm' ? 'pe-1' : 'pe-1.5')}>{trailing}</span>}
      {endAddon && <Addon side="end">{endAddon}</Addon>}
    </div>
  );
}

/**
 * Text input with label, hint, error, icon, inline prefix/suffix and attached addons.
 * The focus ring fades in, an error shakes the box once, success draws a check.
 *   <Input label="Email" type="email" icon="mail" hint="We never share it." />
 *   <Input label="Website" startAddon="https://" placeholder="northwind.io" />
 *   <Input label="Price" suffix="€" error="Enter a price above 0." />
 *   <Input label="Email" floatingLabel />
 */
export function Input({ label, hint, error, optional, labelAction, floatingLabel, className, ...rest }: InputProps) {
  if (floatingLabel && label) {
    const wrapped = Boolean(hint || error || labelAction);
    return withField(
      { hint, error, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className },
      <InputBox {...rest} floating={{ label }} className={wrapped ? undefined : className} />,
    );
  }
  return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <InputBox {...rest} className={label || hint || error ? undefined : className} />);
}

/** A small square button inside a control, e.g. clear or reveal. A new icon turns in with a short spin. */
export function InlineButton({ label, icon, onClick, className, pressed }: { label: string; icon: IconName; onClick?: () => void; className?: string; pressed?: boolean }) {
  const ref = useRef<HTMLButtonElement>(null);
  const last = useRef(icon);
  useEffect(() => {
    if (last.current === icon) return;
    last.current = icon;
    const svg = ref.current?.querySelector('svg');
    if (!svg || prefersReducedMotion() || typeof svg.animate !== 'function') return;
    svg.animate([{ transform: 'rotate(-60deg) scale(0.5)', opacity: 0 }, { transform: 'rotate(0) scale(1)', opacity: 1 }], { duration: 320, easing: EASE });
  }, [icon]);
  return (
    <button
      ref={ref}
      type="button"
      aria-label={label}
      aria-pressed={pressed}
      onClick={onClick}
      className={cn(
        'relative grid size-8 place-items-center rounded-md text-muted-foreground outline-none transition-[background-color,color,scale] duration-200 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-90 active:duration-100 motion-reduce:active:scale-100',
        'after:absolute after:-inset-1.5 after:content-[""]',
        className,
      )}
    >
      <Icon name={icon} size={16} />
    </button>
  );
}

/** Password input with a show/hide button */
export function PasswordInput(props: Omit<InputProps, 'type' | 'trailing'>) {
  const t = useTexts(TEXTS);
  const [visible, setVisible] = useState(false);
  return (
    <Input
      autoComplete="current-password"
      {...props}
      type={visible ? 'text' : 'password'}
      trailing={<InlineButton label={visible ? t.hide : t.show} icon={visible ? 'eye-off' : 'eye'} onClick={() => setVisible((v) => !v)} />}
    />
  );
}

Frequent questions

How do I install Input?

With aiw add @ai-website/input. The command puts components/ui/input.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.

Which variants does Input have?

4 variants, States, Icons and addons, Sizes and Password.

Can I translate the texts of Input?

Yes. Input carries 3 texts of its own in English and German, such as “Optional”, “Show password” and “Hide password”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Input?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Input with aiw_components and install it with aiw_add @ai-website/input.

Often searched

Jump to

Copied to the clipboard