Skip to main content
Glama
OTPInput.tsx26.8 kB
'use client'; /** * This component is a fork of https://github.com/guilhermerodz/input-otp */ import { cva } from 'class-variance-authority'; import { MinusIcon } from 'lucide-react'; import { type ChangeEvent, type ClipboardEvent, type ComponentProps, type CSSProperties, createContext, type FC, type InputHTMLAttributes, type KeyboardEvent, type MouseEvent, type ReactNode, type RefObject, useContext, useEffect, useRef, useState, } from 'react'; import { cn } from '../../utils/cn'; import { Button, type ButtonProps } from '../Button'; // ---------------- Utilities ---------------- const syncTimeouts = (cb: (...args: any[]) => unknown): number[] => { const t1 = setTimeout(cb, 0); // For faster machines const t2 = setTimeout(cb, 1_0); const t3 = setTimeout(cb, 5_0); return [t1, t2, t3]; }; const safeInsertRule = (sheet: CSSStyleSheet, rule: string) => { try { sheet.insertRule(rule); } catch { console.error('input-otp could not insert CSS rule:', rule); } }; // Decided to go with <noscript> // instead of `scripting` CSS media query // because it's a fallback for initial page load // and the <script> tag won't be loaded // unless the user has JS disabled. const NOSCRIPT_CSS_FALLBACK = ` [data-input-otp] { --nojs-bg: white !important; --nojs-fg: black !important; background-color: var(--nojs-bg) !important; color: var(--nojs-fg) !important; caret-color: var(--nojs-fg) !important; letter-spacing: .25em !important; text-align: center !important; border: 1px solid var(--nojs-fg) !important; border-radius: 4px !important; width: 100% !important; } @media (prefers-color-scheme: dark) { [data-input-otp] { --nojs-bg: black !important; --nojs-fg: white !important; } }`; // ---------------- Constants ---------------- const PWM_BADGE_MARGIN_RIGHT = 18; const PWM_BADGE_SPACE_WIDTH_PX = 40; const PWM_BADGE_SPACE_WIDTH = `${PWM_BADGE_SPACE_WIDTH_PX}px` as const; const PASSWORD_MANAGERS_SELECTORS = [ '[data-lastpass-icon-root]', // LastPass 'com-1password-button', // 1Password '[data-dashlanecreated]', // Dashlane '[style$="2147483647 !important;"]', // Bitwarden ].join(','); // ---------------- Types ---------------- export type SlotProps = { isActive: boolean; char: string | null; placeholderChar: string | null; hasFakeCaret: boolean; }; export type RenderProps = { slots: SlotProps[]; isFocused: boolean; isHovering: boolean; setSelection: (index: number) => void; }; type OverrideProps<T, R> = Omit<T, keyof R> & R; type OTPInputBaseProps = OverrideProps< InputHTMLAttributes<HTMLInputElement>, { value?: string; onChange?: (newValue: string) => unknown; maxLength: number; onComplete?: (...args: any[]) => unknown; onActiveSlotChange?: (activeSlotIndex: number | null) => unknown; pushPasswordManagerStrategy?: 'increase-width' | 'none'; pasteTransformer?: (pasted: string) => string; containerClassName?: string; noScriptCSSFallback?: string | null; } >; type InputOTPRenderFn = (props: RenderProps) => ReactNode; export type OTPInputProps = OTPInputBaseProps & ( | { render: InputOTPRenderFn; children?: never; } | { render?: never; children: ReactNode; } ); // ---------------- Hooks ---------------- export const usePasswordManagerBadge = ({ containerRef, inputRef, pushPasswordManagerStrategy, isFocused, }: { containerRef: RefObject<HTMLDivElement | null>; inputRef: RefObject<HTMLInputElement | null>; pushPasswordManagerStrategy: OTPInputProps['pushPasswordManagerStrategy']; isFocused: boolean; }) => { /** Password managers have a badge * and I'll use this state to push them * outside the input */ const [hasPWMBadge, setHasPWMBadge] = useState(false); const [hasPWMBadgeSpace, setHasPWMBadgeSpace] = useState(false); const [done, setDone] = useState(false); const willPushPWMBadge = pushPasswordManagerStrategy === 'none' ? false : (pushPasswordManagerStrategy === 'increase-width' || // TODO: remove 'experimental-no-flickering' support in 2.0.0 pushPasswordManagerStrategy === 'experimental-no-flickering') && hasPWMBadge && hasPWMBadgeSpace; const trackPWMBadge = () => { const container = containerRef.current; const input = inputRef.current; if ( !container || !input || done || pushPasswordManagerStrategy === 'none' ) { return; } const elementToCompare = container; // Get the top right-center point of the container. // That is usually where most password managers place their badge. const rightCornerX = elementToCompare.getBoundingClientRect().left + elementToCompare.offsetWidth; const centereredY = elementToCompare.getBoundingClientRect().top + elementToCompare.offsetHeight / 2; const x = rightCornerX - PWM_BADGE_MARGIN_RIGHT; const y = centereredY; // Do an extra search to check for famous password managers const pmws = document.querySelectorAll(PASSWORD_MANAGERS_SELECTORS); // If no password manager is automatically detect, // we'll try to dispatch document.elementFromPoint // to identify badges if (pmws.length === 0) { const maybeBadgeEl = document.elementFromPoint(x, y); // If the found element is the input itself, // then we assume it's not a password manager badge. // We are not sure. Most times that means there isn't a badge. if (maybeBadgeEl === container) { return; } } setHasPWMBadge(true); setDone(true); }; useEffect(() => { const container = containerRef.current; if (!container || pushPasswordManagerStrategy === 'none') { return; } // Check if the PWM area is 100% visible const checkHasSpace = () => { const viewportWidth = window.innerWidth; const distanceToRightEdge = viewportWidth - container.getBoundingClientRect().right; setHasPWMBadgeSpace(distanceToRightEdge >= PWM_BADGE_SPACE_WIDTH_PX); }; checkHasSpace(); const interval = setInterval(checkHasSpace, 1000); return () => { clearInterval(interval); }; }, [containerRef, pushPasswordManagerStrategy]); useEffect(() => { const _isFocused = isFocused || document.activeElement === inputRef.current; if (pushPasswordManagerStrategy === 'none' || !_isFocused) { return; } const t1 = setTimeout(trackPWMBadge, 0); const t2 = setTimeout(trackPWMBadge, 2000); const t3 = setTimeout(trackPWMBadge, 5000); const t4 = setTimeout(() => { setDone(true); }, 6000); return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); clearTimeout(t4); }; }, [inputRef, isFocused, pushPasswordManagerStrategy]); return { hasPWMBadge, willPushPWMBadge, PWM_BADGE_SPACE_WIDTH }; }; export const usePrevious = <T,>(value: T): T | undefined => { const ref = useRef<T | undefined>(undefined); useEffect(() => { ref.current = value; }); return ref.current; }; // ---------------- Context ---------------- export const OTPInputContext = createContext<RenderProps>({} as RenderProps); // ---------------- Core Component ---------------- export const OTPInput: FC<OTPInputProps> = ({ value: uncheckedValue, onChange: uncheckedOnChange, maxLength, pattern, placeholder, inputMode = 'numeric', onComplete, onActiveSlotChange, pushPasswordManagerStrategy = 'increase-width', pasteTransformer, containerClassName, noScriptCSSFallback = NOSCRIPT_CSS_FALLBACK, render, children, ...props }) => { // Only used when `value` state is not provided const [internalValue, setInternalValue] = useState( typeof props.defaultValue === 'string' ? props.defaultValue : '' ); // Definitions const value = uncheckedValue ?? internalValue; const previousValue = usePrevious(value); const onChange = (newValue: string) => { uncheckedOnChange?.(newValue); setInternalValue(newValue); }; const regexp = pattern !== undefined ? typeof pattern === 'string' ? new RegExp(pattern) : pattern : null; /** useRef */ const inputRef = useRef<HTMLInputElement>(null); const containerRef = useRef<HTMLDivElement>(null); const initialLoadRef = useRef({ value, onChange, isIOS: typeof window !== 'undefined' && window?.CSS?.supports?.('-webkit-touch-callout', 'none'), }); const inputMetadataRef = useRef<{ prev: [number | null, number | null, 'none' | 'forward' | 'backward']; }>({ prev: [ inputRef.current?.selectionStart ?? null, inputRef.current?.selectionEnd ?? null, inputRef.current?.selectionDirection ?? 'none', ], }); useEffect(() => { const input = inputRef.current; const container = containerRef.current; if (!input || !container) { return; } // Sync input value if (initialLoadRef.current.value !== input.value) { initialLoadRef.current.onChange(input.value); } // Previous selection inputMetadataRef.current.prev = [ input.selectionStart, input.selectionEnd, input.selectionDirection ?? 'none', ]; const onDocumentSelectionChange = () => { if (document.activeElement !== input) { setMirrorSelectionStart(null); setMirrorSelectionEnd(null); setActualCaretPosition(null); return; } const selectionStart = input.selectionStart; const selectionEnd = input.selectionEnd; const selectionDirection = input.selectionDirection; const maxLength = input.maxLength; const value = input.value; const previousSelection = inputMetadataRef.current.prev; let calculatedStart = -1; let calculatedEnd = -1; let calculatedDirection: 'forward' | 'backward' | 'none' = selectionDirection ?? 'none'; if ( value.length !== 0 && selectionStart !== null && selectionEnd !== null ) { const isSingleCaret = selectionStart === selectionEnd; const isInsertMode = selectionStart === value.length && value.length < maxLength; if (isSingleCaret && !isInsertMode) { const caretPosition = selectionStart; if (caretPosition === 0) { calculatedStart = 0; calculatedEnd = 1; calculatedDirection = 'forward'; } else if (caretPosition === maxLength) { calculatedStart = caretPosition - 1; calculatedEnd = caretPosition; calculatedDirection = 'backward'; } else if (maxLength > 1 && value.length > 1) { let offset = 0; if ( previousSelection[0] !== null && previousSelection[1] !== null ) { calculatedDirection = caretPosition < previousSelection[1] ? 'backward' : 'forward'; const wasPreviouslyInserting = previousSelection[0] === previousSelection[1] && previousSelection[0] < maxLength; if ( calculatedDirection === 'backward' && !wasPreviouslyInserting ) { offset = -1; } } calculatedStart = offset + caretPosition; calculatedEnd = offset + caretPosition + 1; } } if ( calculatedStart !== -1 && calculatedEnd !== -1 && calculatedStart !== calculatedEnd ) { inputRef.current?.setSelectionRange( calculatedStart, calculatedEnd, calculatedDirection ); } } const finalSelectionStart = calculatedStart !== -1 ? calculatedStart : selectionStart; const finalSelectionEnd = calculatedEnd !== -1 ? calculatedEnd : selectionEnd; const finalDirection = calculatedDirection; // Track actual caret position (before expansion) for active slot detection if (selectionStart !== null && selectionEnd !== null) { const isSingleCaret = selectionStart === selectionEnd; if (isSingleCaret) { setActualCaretPosition(selectionStart); } else { // When selection is expanded, use the start position as the caret setActualCaretPosition(finalSelectionStart); } } else { setActualCaretPosition(null); } setMirrorSelectionStart(finalSelectionStart); setMirrorSelectionEnd(finalSelectionEnd); inputMetadataRef.current.prev = [ finalSelectionStart, finalSelectionEnd, finalDirection, ]; }; document.addEventListener('selectionchange', onDocumentSelectionChange, { capture: true, }); // Set initial mirror state onDocumentSelectionChange(); if (document.activeElement === input) { setIsFocused(true); } // Apply needed styles if (!document.getElementById('input-otp-style')) { const styleEl = document.createElement('style'); styleEl.id = 'input-otp-style'; document.head.appendChild(styleEl); if (styleEl.sheet) { const autofillStyles = 'background: transparent !important; color: transparent !important; border-color: transparent !important; opacity: 0 !important; box-shadow: none !important; -webkit-box-shadow: none !important; -webkit-text-fill-color: transparent !important;'; safeInsertRule( styleEl.sheet, '[data-input-otp]::selection { background: transparent !important; color: transparent !important; }' ); safeInsertRule( styleEl.sheet, `[data-input-otp]:autofill { ${autofillStyles} }` ); safeInsertRule( styleEl.sheet, `[data-input-otp]:-webkit-autofill { ${autofillStyles} }` ); // iOS safeInsertRule( styleEl.sheet, `@supports (-webkit-touch-callout: none) { [data-input-otp] { letter-spacing: -.6em !important; font-weight: 100 !important; font-stretch: ultra-condensed; font-optical-sizing: none !important; left: -1px !important; right: 1px !important; } }` ); // PWM badges safeInsertRule( styleEl.sheet, `[data-input-otp] + * { pointer-events: all !important; }` ); } } // Track root height const updateRootHeight = () => { if (container) { container.style.setProperty('--root-height', `${input.clientHeight}px`); } }; updateRootHeight(); const resizeObserver = new ResizeObserver(updateRootHeight); resizeObserver.observe(input); return () => { document.removeEventListener( 'selectionchange', onDocumentSelectionChange, { capture: true } ); resizeObserver.disconnect(); }; }, []); /** Mirrors for UI rendering purpose only */ const [isHoveringInput, setIsHoveringInput] = useState(false); const [isFocused, setIsFocused] = useState(false); const [mirrorSelectionStart, setMirrorSelectionStart] = useState< number | null >(null); const [mirrorSelectionEnd, setMirrorSelectionEnd] = useState<number | null>( null ); const [actualCaretPosition, setActualCaretPosition] = useState<number | null>( null ); /** Effects */ useEffect(() => { syncTimeouts(() => { // Forcefully remove :autofill state inputRef.current?.dispatchEvent(new Event('input')); // Update the selection state const s = inputRef.current?.selectionStart ?? null; const e = inputRef.current?.selectionEnd ?? null; const dir = inputRef.current?.selectionDirection ?? 'none'; if (s !== null && e !== null) { setMirrorSelectionStart(s); setMirrorSelectionEnd(e); // Track actual caret position (use start position as caret) setActualCaretPosition(s); inputMetadataRef.current.prev = [s, e, dir]; } }); }, [value, isFocused]); useEffect(() => { if (previousValue === undefined) { return; } if ( value !== previousValue && previousValue.length < maxLength && value.length === maxLength ) { onComplete?.(value); } }, [maxLength, onComplete, previousValue, value]); // Track active slot changes const previousActiveSlot = useRef<number | null>(null); useEffect(() => { const activeSlotIndex = isFocused && actualCaretPosition !== null ? actualCaretPosition : null; if (activeSlotIndex !== previousActiveSlot.current) { previousActiveSlot.current = activeSlotIndex; onActiveSlotChange?.(activeSlotIndex); } }, [isFocused, actualCaretPosition, onActiveSlotChange]); const pwmb = usePasswordManagerBadge({ containerRef, inputRef, pushPasswordManagerStrategy, isFocused, }); /** Event handlers */ const _changeListener = (e: ChangeEvent<HTMLInputElement>) => { const newValue = e.currentTarget.value.slice(0, maxLength); if (newValue.length > 0 && regexp && !regexp.test(newValue)) { e.preventDefault(); return; } const maybeHasDeleted = typeof previousValue === 'string' && newValue.length < previousValue.length; if (maybeHasDeleted) { // Since cutting/deleting text doesn't trigger // selectionchange event, we'll have to dispatch it manually. // NOTE: The following line also triggers when cmd+A then pasting // a value with smaller length, which is not ideal for performance. document.dispatchEvent(new Event('selectionchange')); } onChange(newValue); }; const _focusListener = () => { if (inputRef.current) { const start = Math.min(inputRef.current.value.length, maxLength - 1); const end = inputRef.current.value.length; inputRef.current?.setSelectionRange(start, end); setMirrorSelectionStart(start); setMirrorSelectionEnd(end); } setIsFocused(true); }; // Fix iOS pasting const _pasteListener = (e: ClipboardEvent<HTMLInputElement>) => { const input = inputRef.current; if ( !pasteTransformer && (!initialLoadRef.current.isIOS || !e.clipboardData || !input) ) { return; } const _content = e.clipboardData.getData('text/plain'); const content = pasteTransformer ? pasteTransformer(_content) : _content; e.preventDefault(); const start = inputRef.current?.selectionStart; const end = inputRef.current?.selectionEnd; const isReplacing = start !== end; const newValueUncapped = isReplacing ? value.slice(0, start ?? 0) + content + value.slice(end ?? 0) // Replacing : value.slice(0, start ?? 0) + content + value.slice(start ?? 0); // Inserting const newValue = newValueUncapped.slice(0, maxLength); if (newValue.length > 0 && regexp && !regexp.test(newValue)) { return; } if (input) { input.value = newValue; onChange(newValue); const _start = Math.min(newValue.length, maxLength - 1); const _end = newValue.length; input.setSelectionRange(_start, _end); setMirrorSelectionStart(_start); setMirrorSelectionEnd(_end); } }; /** Styles - dynamic styles that can't be converted to Tailwind */ const dynamicInputStyle: CSSProperties = { width: pwmb.willPushPWMBadge ? `calc(100% + ${pwmb.PWM_BADGE_SPACE_WIDTH})` : '100%', clipPath: pwmb.willPushPWMBadge ? `inset(0 ${pwmb.PWM_BADGE_SPACE_WIDTH} 0 0)` : undefined, fontSize: 'var(--root-height)', }; /** Rendering */ const renderedInput = ( <input autoComplete={props.autoComplete || 'one-time-code'} {...props} data-input-otp data-input-otp-placeholder-shown={value.length === 0 || undefined} data-input-otp-mss={mirrorSelectionStart} data-input-otp-mse={mirrorSelectionEnd} inputMode={inputMode} pattern={regexp?.source} aria-placeholder={placeholder} className="pointer-events-auto absolute inset-0 -z-10 flex h-full border-0 border-transparent bg-transparent text-center font-mono text-transparent tabular-nums leading-none tracking-[-.5em] caret-transparent opacity-100 shadow-none outline-none" style={dynamicInputStyle} maxLength={maxLength} value={value} ref={inputRef} onPaste={(e) => { _pasteListener(e); props.onPaste?.(e); }} onChange={_changeListener} onMouseOver={(e) => { setIsHoveringInput(true); props.onMouseOver?.(e); }} onMouseLeave={(e) => { setIsHoveringInput(false); props.onMouseLeave?.(e); }} onKeyDown={(e) => { // Track arrow key navigation to ensure active slot updates correctly if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { // Use requestAnimationFrame to check selection after browser has processed the key requestAnimationFrame(() => { const input = inputRef.current; if (input && document.activeElement === input) { const s = input.selectionStart; const end = input.selectionEnd; if (s !== null && end !== null) { // Update actual caret position - use start position as caret setActualCaretPosition(s); } } }); } props.onKeyDown?.(e); }} onFocus={(e) => { _focusListener(); props.onFocus?.(e); }} onBlur={(e) => { setIsFocused(false); props.onBlur?.(e); }} /> ); const setSelection = (index: number) => { const input = inputRef.current; if (!input || props.disabled) { return; } // Clamp index to valid range const clampedIndex = Math.max(0, Math.min(index, maxLength - 1)); // Focus the input if not already focused if (document.activeElement !== input) { input.focus(); } // Set selection to the clicked slot // If there's a character at that position, select it; otherwise just position the caret const hasChar = value[clampedIndex] !== undefined; const start = clampedIndex; const end = hasChar ? clampedIndex + 1 : clampedIndex; input.setSelectionRange(start, end); setMirrorSelectionStart(start); setMirrorSelectionEnd(end); setIsFocused(true); }; const contextValue: RenderProps = { slots: Array.from({ length: maxLength }).map((_, slotIdx) => { const isActive = isFocused && mirrorSelectionStart !== null && mirrorSelectionEnd !== null && ((mirrorSelectionStart === mirrorSelectionEnd && slotIdx === mirrorSelectionStart) || (slotIdx >= mirrorSelectionStart && slotIdx < mirrorSelectionEnd)); const char = value[slotIdx] !== undefined ? value[slotIdx] : null; const placeholderChar = value[0] !== undefined ? null : (placeholder?.[slotIdx] ?? null); return { char, placeholderChar, isActive, hasFakeCaret: isActive && char === null, }; }), isFocused, isHovering: !props.disabled && isHoveringInput, setSelection, }; const renderedChildren = render !== undefined ? ( render(contextValue) ) : ( <OTPInputContext.Provider value={contextValue}> {children} </OTPInputContext.Provider> ); return ( <> {noScriptCSSFallback !== null && ( <noscript> <style>{noScriptCSSFallback}</style> </noscript> )} <div ref={containerRef} className={cn( 'relative', props.disabled ? 'cursor-default' : 'cursor-text', containerClassName )} > {renderedChildren} <div className="absolute inset-0">{renderedInput}</div> </div> </> ); }; // ---------------- Root ---------------- type InputOTPProps = Omit<ComponentProps<typeof OTPInput>, 'children'>; export const inputSlotVariants = cva('block text-center', { variants: { size: { sm: 'h-4 w-3 text-sm', md: 'h-5 w-4 text-base', lg: 'h-6 w-5 text-lg', xl: 'h-7 w-6 text-xl', }, }, defaultVariants: { size: 'md', }, }); export const InputOTP: FC<InputOTPProps> = ({ className, render, ...props }) => ( <OTPInput containerClassName="relative flex items-center gap-2 has-disabled:opacity-50" className={cn('disabled:cursor-not-allowed', className)} render={render!} {...props} /> ); // ---------------- Group ---------------- export const InputOTPGroup = ({ className, ...props }: ComponentProps<'div'>) => ( <div className={cn('z-10 flex items-center gap-3', className)} {...props} /> ); // ---------------- Slot ---------------- type InputOTPSlotProps = Omit<ButtonProps, 'variant' | 'label'> & { index: number; }; export const InputOTPSlot: FC<InputOTPSlotProps> = ({ index, className, onClick, onKeyDown, ...props }) => { const inputOTPContext = useContext(OTPInputContext); const { char, isActive } = inputOTPContext?.slots[index] ?? {}; const { setSelection } = inputOTPContext ?? {}; const handleClick = (e: MouseEvent<HTMLButtonElement>) => { setSelection?.(index); onClick?.(e); }; const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelection?.(index); } onKeyDown?.(e); }; return ( <Button isSelected={isActive} variant="input" color="custom" tabIndex={-1} className={cn('relative z-10 px-2!', isActive && 'ring-4!', className)} onClick={handleClick} onKeyDown={handleKeyDown} label={null} {...props} > {/* value */} <span className="relative z-10 flex h-6 w-4 items-center justify-center"> {char} </span> </Button> ); }; // ---------------- Separator ---------------- export const InputOTPSeparator: FC<ComponentProps<'div'>> = (props) => ( <div aria-hidden className="z-0 table h-0.5 w-3 rounded-full bg-border text-text/50" {...props} > <MinusIcon /> </div> ); export const InputIndicator: FC<ComponentProps<'div'>> = (props) => ( <div data-indicator className="absolute top-0 z-0 h-full w-auto rounded-xl bg-text/20 ring-4 ring-text/20 transition-[left,width] duration-300 ease-in-out [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-2xl motion-reduce:transition-none" {...props} /> );

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server