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
<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
| Prop | Tipo | Default |
|---|---|---|
| size | xs | sm | md | lg | md |
| leadingIcon | ReactNode | — |
| trailingIcon | ReactNode | — |
| aria-invalid | boolean | false |
El resto son los atributos nativos de <input> — se pasan directo (type, disabled, placeholder, etc).