Skip to content

Commit 4d6a88b

Browse files
VINAYMADIVALVinay Nagesh Madival
andauthored
feat: Add Self-Contained Input OTP Component (3-3 Split with Hyphen Separator) (#218)
* Add self-contained Spinner component and integrate into components data * Add self-contained Kbd component with integrated search bar demo and components data entry * Rename Kbd component entry to 'Keyboard Shortcut' for consistency with naming conventions * Add self-contained Input OTP component with 3-3 digit layout and integrated demo --------- Co-authored-by: Vinay Nagesh Madival <you@example.com> Co-authored-by: Vinay Nagesh Madival <[email protected]>
1 parent abaefaa commit 4d6a88b

4 files changed

Lines changed: 299 additions & 0 deletions

File tree

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"class-variance-authority": "^0.7.1",
2828
"clsx": "^2.1.1",
2929
"date-fns": "^4.1.0",
30+
"input-otp": "^1.4.2",
3031
"lucide-react": "^0.544.0",
3132
"next": "15.5.4",
3233
"next-themes": "^0.4.6",

src/components/ui/input-otp.tsx

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import * as React from "react";
2+
import { cn } from "@/lib/utils";
3+
4+
/* ---------- Context ---------- */
5+
type OTPContextValue = {
6+
maxLength: number;
7+
values: string[];
8+
setAt: (index: number, char: string) => void;
9+
focusIndex: (index: number) => void;
10+
registerRef: (index: number, el: HTMLInputElement | null) => void;
11+
handlePaste: (index: number, text: string) => void;
12+
};
13+
const OTPContext = React.createContext<OTPContextValue | null>(null);
14+
function useOTP() {
15+
const ctx = React.useContext(OTPContext);
16+
if (!ctx) throw new Error("InputOTP components must be used inside <InputOTP>");
17+
return ctx;
18+
}
19+
20+
/* ---------- InputOTP (provider) ---------- */
21+
export type InputOTPProps = {
22+
maxLength?: number;
23+
children?: React.ReactNode;
24+
} & React.HTMLAttributes<HTMLDivElement>;
25+
26+
const InputOTPInner = React.forwardRef<HTMLDivElement, InputOTPProps>(
27+
({ maxLength = 6, children, ...props }, ref) => {
28+
const [values, setValues] = React.useState<string[]>(
29+
() => Array.from({ length: maxLength }).map(() => "")
30+
);
31+
32+
// refs for each input slot
33+
const refs = React.useRef<Array<HTMLInputElement | null>>([]);
34+
refs.current = refs.current.slice(0, maxLength);
35+
36+
const registerRef = React.useCallback((index: number, el: HTMLInputElement | null) => {
37+
refs.current[index] = el;
38+
}, []);
39+
40+
const focusIndex = React.useCallback((index: number) => {
41+
const safe = Math.max(0, Math.min(index, maxLength - 1));
42+
refs.current[safe]?.focus();
43+
refs.current[safe]?.select();
44+
}, [maxLength]);
45+
46+
const setAt = React.useCallback(
47+
(index: number, char: string) => {
48+
setValues((prev) => {
49+
const copy = [...prev];
50+
copy[index] = char;
51+
return copy;
52+
});
53+
},
54+
[]
55+
);
56+
57+
// handle paste: fill from index forward
58+
const handlePaste = React.useCallback(
59+
(index: number, text: string) => {
60+
const chars = text.split("").slice(0, maxLength - index);
61+
setValues((prev) => {
62+
const copy = [...prev];
63+
for (let i = 0; i < chars.length; i++) {
64+
copy[index + i] = chars[i];
65+
}
66+
return copy;
67+
});
68+
const nextFocus = Math.min(maxLength - 1, index + chars.length);
69+
// focus next (if filled full, focus last)
70+
setTimeout(() => {
71+
refs.current[nextFocus]?.focus();
72+
refs.current[nextFocus]?.select();
73+
}, 0);
74+
},
75+
[maxLength]
76+
);
77+
78+
const value: OTPContextValue = React.useMemo(
79+
() => ({ maxLength, values, setAt, focusIndex, registerRef, handlePaste }),
80+
[maxLength, values, setAt, focusIndex, registerRef, handlePaste]
81+
);
82+
83+
return (
84+
<OTPContext.Provider value={value}>
85+
{/* The OTP wrapper, a single horizontal row that does not wrap */}
86+
<div
87+
ref={ref}
88+
className={cn(
89+
"inline-flex items-center gap-3 flex-nowrap",
90+
// preserve any caller-supplied classes
91+
(props as any).className
92+
)}
93+
{...props}
94+
>
95+
{children}
96+
</div>
97+
</OTPContext.Provider>
98+
);
99+
}
100+
);
101+
InputOTPInner.displayName = "InputOTP";
102+
103+
/* ---------- InputOTPGroup ---------- */
104+
const InputOTPGroup: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ children, className, ...props }) => {
105+
return (
106+
<div {...props} className={cn("inline-flex items-center gap-2", className)}>
107+
{children}
108+
</div>
109+
);
110+
};
111+
112+
/* ---------- InputOTPSeparator ---------- */
113+
const InputOTPSeparator: React.FC<React.HTMLAttributes<HTMLSpanElement>> = ({ className, ...props }) => {
114+
return (
115+
<span
116+
{...props}
117+
className={cn(
118+
// make the wrapper a flex container and vertically center its child
119+
"flex items-center select-none mx-4",
120+
className
121+
)}
122+
aria-hidden
123+
>
124+
{/* brighter, centered hyphen */}
125+
<span className="block w-6 h-[2px] bg-muted-foreground rounded" />
126+
</span>
127+
);
128+
};
129+
130+
131+
/* ---------- InputOTPSlot ---------- */
132+
type InputOTPSlotProps = {
133+
index: number;
134+
} & React.InputHTMLAttributes<HTMLInputElement>;
135+
136+
const InputOTPSlot = React.forwardRef<HTMLInputElement, InputOTPSlotProps>(({ index, className, ...props }, ref) => {
137+
const { maxLength, values, setAt, focusIndex, registerRef, handlePaste } = useOTP();
138+
139+
// local ref merging
140+
const innerRef = React.useRef<HTMLInputElement | null>(null);
141+
React.useImperativeHandle(ref, () => innerRef.current as HTMLInputElement);
142+
143+
React.useEffect(() => {
144+
registerRef(index, innerRef.current);
145+
// eslint-disable-next-line react-hooks/exhaustive-deps
146+
}, [index, registerRef]);
147+
148+
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
149+
const raw = e.target.value;
150+
if (!raw) {
151+
setAt(index, "");
152+
return;
153+
}
154+
// we accept only the last character typed (typical OTP behavior)
155+
const char = raw.slice(-1);
156+
setAt(index, char);
157+
// move focus to next
158+
const next = index + 1;
159+
if (next < maxLength) {
160+
focusIndex(next);
161+
}
162+
};
163+
164+
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
165+
if (e.key === "Backspace") {
166+
e.preventDefault();
167+
if (values[index]) {
168+
// clear current
169+
setAt(index, "");
170+
// keep focus here
171+
setTimeout(() => innerRef.current?.focus(), 0);
172+
} else {
173+
// move to previous and clear it
174+
const prev = index - 1;
175+
if (prev >= 0) {
176+
setAt(prev, "");
177+
focusIndex(prev);
178+
}
179+
}
180+
} else if (e.key === "ArrowLeft") {
181+
e.preventDefault();
182+
focusIndex(index - 1);
183+
} else if (e.key === "ArrowRight") {
184+
e.preventDefault();
185+
focusIndex(index + 1);
186+
}
187+
};
188+
189+
const onPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
190+
e.preventDefault();
191+
const text = e.clipboardData.getData("text").trim();
192+
if (!text) return;
193+
handlePaste(index, text);
194+
};
195+
196+
return (
197+
<input
198+
ref={innerRef}
199+
inputMode="numeric"
200+
type="text"
201+
pattern="[0-9]*"
202+
value={values[index] ?? ""}
203+
onChange={onChange}
204+
onKeyDown={onKeyDown}
205+
onPaste={onPaste}
206+
maxLength={1}
207+
{...props}
208+
className={cn(
209+
"h-10 w-10 appearance-none rounded-md border border-border bg-transparent text-center text-base placeholder:text-muted-foreground focus:outline-none focus:ring-0",
210+
className
211+
)}
212+
aria-label={`OTP digit ${index + 1}`}
213+
/>
214+
);
215+
});
216+
InputOTPSlot.displayName = "InputOTPSlot";
217+
218+
/* ---------- Preview ---------- */
219+
const InputOTPPreview: React.FC = () => {
220+
// build a centered demo that shows 3 - 3 with hyphen
221+
return (
222+
<div className="w-full flex justify-center">
223+
<div className="inline-flex items-center gap-2 rounded-md p-4">
224+
<InputOTPInner maxLength={6}>
225+
<InputOTPGroup>
226+
<InputOTPSlot index={0} />
227+
<InputOTPSlot index={1} />
228+
<InputOTPSlot index={2} />
229+
</InputOTPGroup>
230+
231+
<InputOTPSeparator />
232+
233+
<InputOTPGroup>
234+
<InputOTPSlot index={3} />
235+
<InputOTPSlot index={4} />
236+
<InputOTPSlot index={5} />
237+
</InputOTPGroup>
238+
</InputOTPInner>
239+
</div>
240+
</div>
241+
);
242+
};
243+
244+
/* ---------- Attach Preview and exports ---------- */
245+
type InputOTPType = typeof InputOTPInner & { Preview: React.FC };
246+
const InputOTP = InputOTPInner as InputOTPType;
247+
InputOTP.Preview = InputOTPPreview;
248+
249+
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot };
250+
export default InputOTP;

src/data/components.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ import { Spinner } from "@/components/ui/spinner";
9393

9494
import { Kbd } from "@/components/ui/kbd";
9595

96+
import { InputOTP } from "@/components/ui/input-otp";
97+
9698
export const componentsData = [
9799
{
98100
id: "button",
@@ -2067,4 +2069,39 @@ export function KbdDemo() {
20672069
},
20682070
],
20692071
},
2072+
{
2073+
id: "input-otp",
2074+
title: "Input OTP",
2075+
description: "An OTP input split into two groups (3-3) separated by a hyphen; supports paste, navigation, and backspace behavior.",
2076+
category: "Form",
2077+
preview: <InputOTP.Preview />,
2078+
code: `import {
2079+
InputOTP,
2080+
InputOTPGroup,
2081+
InputOTPSeparator,
2082+
InputOTPSlot,
2083+
} from "@/components/InputOTP"
2084+
2085+
export function InputOTPDemo() {
2086+
return (
2087+
<InputOTP maxLength={6}>
2088+
<InputOTPGroup>
2089+
<InputOTPSlot index={0} />
2090+
<InputOTPSlot index={1} />
2091+
<InputOTPSlot index={2} />
2092+
</InputOTPGroup>
2093+
<InputOTPSeparator />
2094+
<InputOTPGroup>
2095+
<InputOTPSlot index={3} />
2096+
<InputOTPSlot index={4} />
2097+
<InputOTPSlot index={5} />
2098+
</InputOTPGroup>
2099+
</InputOTP>
2100+
)
2101+
}`,
2102+
propsData: [
2103+
{ name: "maxLength", type: "number", description: "Total number of OTP digits.", default: "6" },
2104+
{ name: "children", type: "ReactNode", description: "Slot groups and separators." },
2105+
],
2106+
},
20702107
];

0 commit comments

Comments
 (0)