1import React from "react";
2
3interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
4 label?: string;
5 hint?: string;
6 error?: string;
7 fullWidth?: boolean;
8 iconLeft?: React.ReactNode;
9 iconRight?: React.ReactNode;
10 /** Seat the well in the same bezel as a `size="lg"` button, so a field and
11 a button side by side (a newsletter, a waitlist) share one height. */
12 layered?: boolean;
13}
14
15/* Fields are sunken wells (see --field tokens in globals.css). Raised
16 means "press me", sunken means "fill me" — that is the whole system.
17 Layered keeps the well sunken; only the bezel around it is the button's. */
18
19export function Input({
20 label,
21 hint,
22 error,
23 fullWidth = false,
24 iconLeft,
25 iconRight,
26 layered = false,
27 className = "",
28 id,
29 ...rest
30}: InputProps) {
31 const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
32 const messageId = inputId && (error || hint) ? `${inputId}-message` : undefined;
33
34 return (
35 <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
36 {label && (
37 <label
38 htmlFor={inputId}
39 className="text-[12.5px] font-medium text-neutral-700 tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-neutral-950"
40 >
41 {label}
42 </label>
43 )}
44 <div
45 className={layered ? "relative rounded-[15px] p-[3.5px]" : "contents"}
46 style={layered ? { background: "var(--bezel-light)", boxShadow: "var(--bezel-light-shadow)" } : undefined}
47 >
48 <div className="relative group/input isolate">
49 {iconLeft && (
50 <span
51 className="absolute left-3.5 top-1/2 -translate-y-1/2 text-neutral-400 transition-all duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] group-focus-within/input:text-neutral-800 group-focus-within/input:scale-110 pointer-events-none z-20 flex items-center justify-center"
52 >
53 {iconLeft}
54 </span>
55 )}
56 <input
57 id={inputId}
58 className={[
59 "field relative z-10 w-full text-neutral-900 caret-neutral-900",
60 layered ? "rounded-[11.5px] py-3 text-[15px]" : "rounded-xl py-3 text-[13.5px]",
61 "placeholder:text-neutral-400 placeholder:transition-colors placeholder:duration-200 focus:placeholder:text-neutral-300",
62 "disabled:opacity-40 disabled:cursor-not-allowed",
63 iconLeft ? "pl-10" : "pl-4",
64 iconRight ? "pr-10" : "pr-4",
65 className,
66 ]
67 .filter(Boolean)
68 .join(" ")}
69 aria-invalid={error ? true : undefined}
70 aria-describedby={messageId}
71 {...rest}
72 />
73 <span
74 aria-hidden
75 className={["pointer-events-none absolute inset-0 z-15 overflow-hidden", layered ? "rounded-[11.5px]" : "rounded-xl"].join(" ")}
76 >
77 <span
78 className="block absolute inset-y-0 w-[30%] -left-[30%] opacity-0 group-focus-within/input:animate-input-shimmer"
79 style={{
80 background:
81 "linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.55) 50%, transparent 100%)",
82 }}
83 />
84 </span>
85 {iconRight && (
86 <span
87 className="absolute right-3.5 top-1/2 -translate-y-1/2 text-neutral-400 transition-all duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] group-focus-within/input:text-neutral-800 group-focus-within/input:scale-110 pointer-events-none z-20 flex items-center justify-center"
88 >
89 {iconRight}
90 </span>
91 )}
92 </div>
93 </div>
94 {error && (
95 <p id={messageId} role="alert" className="text-[11.5px] text-red-600 font-medium flex items-center gap-1 mt-0.5">
96 <svg width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
97 <circle cx="12" cy="12" r="10" />
98 <path strokeLinecap="round" d="M12 8v4m0 4h.01" />
99 </svg>
100 {error}
101 </p>
102 )}
103 {hint && !error && (
104 <p id={messageId} className="text-[11.5px] text-neutral-500 mt-0.5">{hint}</p>
105 )}
106 </div>
107 );
108}