← Componentes

Input

Mismas alturas que Button (xs / sm / md / lg) para que ambos calcen en la misma fila. El estado de error se activa con aria-invalid — el atributo de accesibilidad que ya deberías estar poniendo, no un prop aparte.

Playground

Size
State
Icon
<Input placeholder="tu@email.com" />

Todas las combinaciones

"use client";

import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./lib/cn";
import { ButtonGroupContext } from "./lib/button-group-context";

const inputVariants = cva(
  "flex w-full rounded-md border border-border bg-background text-foreground transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-danger aria-invalid:focus-visible:ring-danger",
  {
    variants: {
      size: {
        xs: "h-7 px-2 text-xs",
        sm: "h-8 px-3 text-sm",
        md: "h-10 px-3 text-sm",
        lg: "h-12 px-4 text-base",
      },
    },
    defaultVariants: {
      size: "md",
    },
  },
);

export interface InputProps
  // native <input size> is a legacy char-width attribute — omit it so our
  // own `size` variant (xs/sm/md/lg, matching Button) can use the name
  // without a type conflict.
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
    VariantProps<typeof inputVariants> {
  /** decorative by default (clicks pass through to the input) */
  leadingIcon?: React.ReactNode;
  /** not decoration-only — a password-toggle or clear button lives here,
      so unlike leadingIcon it stays clickable */
  trailingIcon?: React.ReactNode;
}

export const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ className, size, leadingIcon, trailingIcon, type = "text", ...props }, ref) => {
    const groupCtx = React.useContext(ButtonGroupContext);
    const effectiveSize = size ?? groupCtx?.size;
    const field = (
      <input
        type={type}
        ref={ref}
        className={cn(
          inputVariants({ size: effectiveSize }),
          leadingIcon && "pl-9",
          trailingIcon && "pr-9",
          className,
        )}
        {...props}
      />
    );

    if (!leadingIcon && !trailingIcon) return field;

    return (
      <div className="relative flex items-center">
        {leadingIcon && (
          <span className="pointer-events-none absolute left-3 flex text-muted-foreground [&_svg]:h-4 [&_svg]:w-4">
            {leadingIcon}
          </span>
        )}
        {field}
        {trailingIcon && (
          <span className="absolute right-3 flex text-muted-foreground [&_svg]:h-4 [&_svg]:w-4">
            {trailingIcon}
          </span>
        )}
      </div>
    );
  },
);
Input.displayName = "Input";

Código real de packages/ui/src/input.tsx.

Tipos

type es un atributo nativo — no hay una prop variant aparte para esto. El toggle de contraseña y las flechas del número son trailingIcon con estado propio — Input no las resuelve solo, y el spinner nativo del navegador queda oculto (mismo motivo que los scrollbars: cero diseño propio, puro ruido).

const [visible, setVisible] = useState(false);

<Input
  type={visible ? "text" : "password"}
  trailingIcon={
    <button type="button" onClick={() => setVisible((v) => !v)}>
      {visible ? <EyeOffIcon /> : <EyeIcon />}
    </button>
  }
/>

const [value, setValue] = useState(1);

<Input
  type="number"
  value={value}
  onChange={(e) => setValue(Number(e.target.value))}
  trailingIcon={
    <div className="flex flex-col divide-y divide-border">
      <button type="button" onClick={() => setValue((v) => v + 1)}>
        <ChevronUpIcon />
      </button>
      <button type="button" onClick={() => setValue((v) => v - 1)}>
        <ChevronDownIcon />
      </button>
    </div>
  }
/>

Con ícono

leadingIcon es decorativo (no bloquea el click hacia el campo); trailingIcon queda clickeable, para botones como el toggle de arriba.

<Input leadingIcon={<SearchIcon />} placeholder="Buscar…" />
<Input trailingIcon={<CheckIcon />} defaultValue="disponible" />

Con label y ayuda

Input no incluye label ni mensaje de ayuda — se componen con HTML normal. aria-describedby conecta el mensaje al campo para lectores de pantalla.

Usamos esto solo para el acceso anticipado.

Ingresa un correo válido.

<div className="flex flex-col gap-1.5">
  <label htmlFor="email">Correo</label>
  <Input id="email" type="email" aria-describedby="email-help" />
  <p id="email-help">Usamos esto solo para el acceso anticipado.</p>
</div>

<div className="flex flex-col gap-1.5">
  <label htmlFor="email-err">Correo</label>
  <Input id="email-err" aria-invalid="true" aria-describedby="email-err-msg" />
  <p id="email-err-msg" className="text-danger">Ingresa un correo válido.</p>
</div>

Error

Mismo token danger que usa Button, leído como borde + anillo de foco en vez de relleno sólido. Ver Colores para el resto de la tabla.

<Input aria-invalid="true" defaultValue="no-es-un-email" />

Uso

import { Input } from "./components/ui/input";

<Input type="email" placeholder="tu@email.com" />

API

PropTipoDefault
sizexs | sm | md | lgmd
leadingIconReactNode
trailingIconReactNode
aria-invalidbooleanfalse

El resto son los atributos nativos de <input> — se pasan directo (type, disabled, placeholder, etc).