FoundationsInput
A single-line text field on the input hairline.
The thinking
A text field is where the layer between intent and software gets thinnest — you think a word and it appears — so the whole job here is to disappear and let the typing happen. The one load-bearing decision is the hairline: this field draws its border from
--input, not --border, so before you ever click it the heavier edge already says "you can write here." That's affordance through depth, the same reason I reserve the strongest contrast for the things you act on. Everything else defers to that read — the background stays bg-transparent so the field borrows whatever surface it sits on, and the only motion is a duration-150 ease-swift color transition that collapses under motion-reduce. I'd rather a text field be quietly correct than clever.Interface design
--input is one step stronger than --border — 16% ink against 12% in light mode — so editable fields advertise themselves with a slightly heavier hairline than passive dividers. The placeholder has its own token too: --placeholder is a cool blue-gray that doesn't read as a typed value, which is the whole job. And unlike the button there are no variants and no sizes here — one h-9 rounded-md text-sm field, w-full by default, with className as the only escape hatch.Interaction
Buttons ring only on focus-visible; fields ring on any focus. A click into a field is the beginning of typing, so the ring earns its place even for mouse users — it marks where keystrokes will land. That asymmetry is deliberate, and it's why this is
focus:ring-1 here and focus-visible:ring-1 everywhere else. The one easing is transition-colors at duration-150 ease-swift, and it covers exactly what the name says — colors. The ring is box-shadow, outside that property list, so it lands the instant focus does. A mark for where keystrokes go is not something I let fade in.Sound design
Silent on purpose. The SoundLayer's capturing click listener speaks only for elements that carry an interactive role, and its selector list never included text fields — keystrokes are their own feedback, and focusing a field is preparation, not commitment. So there's no
data-silent to remember and no opt-out to manage; a field that clicks at you is a field you stop trusting.Empathy
Disabled is
disabled:opacity-50 plus disabled:cursor-not-allowed — and that second half is a different call than the button makes. The button hides from the mouse with pointer-events-none; a field keeps taking the pointer so the not-allowed cursor can say why nothing is happening, and because this renders a real <input>, the browser drops it from the tab order on its own. Keyboard users can't fall through a focus-visible gap here — the ring fires on any focus, so tabbing in draws the same ring-ring a click does. Under motion-reduce:transition-none the one color transition snaps, and nothing else was moving to begin with. The file: styles — file:border-0, file:bg-transparent, file:font-medium on file:text-foreground — are there so a type="file" input doesn't arrive wearing browser chrome. What I don't ship is a name: this is a bare input, anonymous to a screen reader until the composer wires a label or an aria-label, the way the demo does. Long values scroll inside the box — native behavior, inherited, not written.import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
ref?: React.Ref<HTMLInputElement>;
}
// fields ring on ANY focus, not just focus-visible — a click into a field
// is the beginning of typing, so the ring earns its place for mouse users
// too. buttons keep the focus-visible discipline; this asymmetry is the point.
const Input = ({ ref, className, type, ...props }: InputProps) => (
<input
type={type}
ref={ref}
className={cn(
// border-input, not border-border: editable fields advertise
// themselves with the one-step-stronger hairline
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm transition-colors duration-150 ease-swift placeholder:text-placeholder file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none",
className
)}
{...props}
/>
);
Input.displayName = "Input";
export { Input };