UIPackage

Input

React control
Edit on GitHub

Text input — single-line. Three sizes, three variants (outlined / filled / borderless), error / warning status, prefix / suffix nodes, addonBefore / addonAfter, allow-clear, password toggle, and char count. Same sizing and ring treatment as the rest of the form primitives.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://react.uipkge.dev/r/react/input.json

Or with the named registry: npx shadcn@latest add @uipkge-react/input

Examples

Props

Name Type / Values Default Required
size Size optional
variant Variant optional
status Status optional
prefix

Leading content. A node wins over the `prefixIcon` shorthand.

React.ReactNode optional
suffix React.ReactNode optional
prefixIcon

Shorthand for an icon at the start/end (e.g. `prefixIcon={<Mail />}`).

React.ReactNode optional
suffixIcon React.ReactNode optional
addonBefore React.ReactNode optional
addonAfter React.ReactNode optional
allowClear boolean optional
showCount boolean optional
showPasswordToggle boolean optional
className

Wrapper className. Falls through to the bordered control, not the <input>.

string optional

Dependencies

Used by

Files (2)

  • components/ui/input/Input.tsx 9.5 kB
    'use client'
    
    import * as React from 'react'
    import { X, Eye, EyeOff } from 'lucide-react'
    import { cn } from '@/lib/utils'
    
    type Size = 'small' | 'middle' | 'large'
    type Variant = 'outlined' | 'filled' | 'borderless'
    type Status = 'error' | 'warning'
    
    export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'prefix'> {
      size?: Size
      variant?: Variant
      status?: Status
      /** Leading content. A node wins over the `prefixIcon` shorthand. */
      prefix?: React.ReactNode
      suffix?: React.ReactNode
      /** Shorthand for an icon at the start/end (e.g. `prefixIcon={<Mail />}`). */
      prefixIcon?: React.ReactNode
      suffixIcon?: React.ReactNode
      addonBefore?: React.ReactNode
      addonAfter?: React.ReactNode
      allowClear?: boolean
      showCount?: boolean
      showPasswordToggle?: boolean
      /** Wrapper className. Falls through to the bordered control, not the <input>. */
      className?: string
    }
    
    const sizeClasses: Record<Size, string> = {
      small: 'h-8 text-xs',
      middle: 'h-9 text-base md:text-sm',
      large: 'h-11 text-base',
    }
    
    const Input = React.forwardRef<HTMLInputElement, InputProps>(
      (
        {
          size = 'middle',
          variant = 'outlined',
          status,
          prefix,
          suffix,
          prefixIcon,
          suffixIcon,
          addonBefore,
          addonAfter,
          allowClear,
          showCount,
          showPasswordToggle,
          className,
          type = 'text',
          disabled,
          readOnly,
          maxLength,
          value,
          defaultValue,
          onChange,
          onFocus,
          onBlur,
          id,
          'aria-invalid': ariaInvalid,
          ...rest
        },
        ref,
      ) => {
        const innerRef = React.useRef<HTMLInputElement | null>(null)
        // Merge the forwarded ref with our internal ref (needed for focus()).
        const setRefs = React.useCallback(
          (node: HTMLInputElement | null) => {
            innerRef.current = node
            if (typeof ref === 'function') ref(node)
            else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = node
          },
          [ref],
        )
    
        const isControlled = value !== undefined
        const [internal, setInternal] = React.useState<string>(
          defaultValue != null ? String(defaultValue) : '',
        )
        const currentValue = isControlled ? String(value ?? '') : internal
    
        const [focused, setFocused] = React.useState(false)
        const [hovered, setHovered] = React.useState(false)
        const [passwordVisible, setPasswordVisible] = React.useState(false)
    
        const isPassword = type === 'password'
        const hasPrefix = !!prefix || !!prefixIcon
        const hasSuffix = !!suffix || !!suffixIcon
        const hasAddonBefore = !!addonBefore
        const hasAddonAfter = !!addonAfter
    
        const hasPasswordToggle = !!showPasswordToggle && isPassword
        const hasCount = !!showCount && maxLength != null
        const hasRightConfig = !!allowClear || hasPasswordToggle || hasCount
    
        const currentLength = currentValue.length
        const showClear = !!allowClear && currentValue.length > 0 && (focused || hovered) && !disabled && !readOnly
        const computedType = !isPassword ? type : passwordVisible ? 'text' : 'password'
    
        const wrapperRounded =
          hasAddonBefore && hasAddonAfter
            ? 'rounded-none'
            : hasAddonBefore
              ? 'rounded-l-none rounded-r-md'
              : hasAddonAfter
                ? 'rounded-r-none rounded-l-md'
                : 'rounded-md'
    
        const variantMap: Record<Variant, string> = {
          outlined: 'border-input bg-transparent shadow-xs',
          filled: 'border-transparent bg-muted/50 shadow-none',
          borderless: 'border-transparent bg-transparent shadow-none',
        }
        const statusMap: Record<Status, string> = {
          error:
            'border-destructive focus-within:border-destructive focus-within:ring-destructive/20 dark:focus-within:ring-destructive/40',
          warning: 'border-[var(--warning)] focus-within:border-[var(--warning)] focus-within:ring-[var(--warning)]/20',
        }
    
        const wrapperClasses = cn(
          'flex w-full items-center gap-1.5 overflow-hidden border transition-[color,box-shadow] outline-none',
          sizeClasses[size],
          variantMap[variant],
          status ? statusMap[status] : '',
          !status ? 'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]' : '',
          disabled ? 'pointer-events-none opacity-50 cursor-not-allowed bg-muted/30' : '',
          'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
          wrapperRounded,
          className,
        )
    
        function addonClasses(position: 'before' | 'after') {
          const roundedClass =
            position === 'before' ? 'rounded-l-md rounded-r-none border-r-0' : 'rounded-r-md rounded-l-none border-l-0'
          return cn(
            'flex items-center bg-muted px-3 text-sm text-muted-foreground border border-input',
            roundedClass,
            sizeClasses[size],
          )
        }
    
        const sidePad =
          size === 'small' ? { l: 'pl-2', r: 'pr-2' } : size === 'large' ? { l: 'pl-3', r: 'pr-3' } : { l: 'pl-2.5', r: 'pr-2.5' }
    
        const hasLeft = hasPrefix
        const hasRight = hasSuffix || hasRightConfig
        const inputPadding =
          !hasLeft && !hasRight
            ? cn(sidePad.l, sidePad.r)
            : hasLeft && !hasRight
              ? cn('pl-0', sidePad.r)
              : !hasLeft && hasRight
                ? cn(sidePad.l, 'pr-0')
                : 'px-0'
    
        function emit(next: string) {
          if (!isControlled) setInternal(next)
        }
    
        function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
          emit(e.target.value)
          onChange?.(e)
        }
    
        function handleClear() {
          const node = innerRef.current
          if (node) {
            // Use the native setter so a controlled parent's onChange fires with a
            // real event (React tracks the value on the DOM node's prototype).
            const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
            setter?.call(node, '')
            node.dispatchEvent(new Event('input', { bubbles: true }))
            node.focus()
          }
          emit('')
        }
    
        function togglePassword() {
          setPasswordVisible((v) => !v)
          innerRef.current?.focus()
        }
    
        return (
          <div className="flex w-full">
            {hasAddonBefore && <div className={addonClasses('before')}>{addonBefore}</div>}
    
            <div
              className={wrapperClasses}
              data-uipkge=""
              data-slot="input"
              aria-invalid={ariaInvalid}
              onMouseEnter={() => setHovered(true)}
              onMouseLeave={() => setHovered(false)}
              onClick={() => innerRef.current?.focus()}
            >
              {hasPrefix && (
                <span className={cn('text-muted-foreground pointer-events-none shrink-0 select-none', sidePad.l)}>
                  {prefixIcon ?? prefix}
                </span>
              )}
    
              <input
                id={id}
                ref={setRefs}
                value={currentValue}
                type={computedType}
                disabled={disabled}
                readOnly={readOnly}
                maxLength={maxLength}
                onChange={handleChange}
                onFocus={(e) => {
                  setFocused(true)
                  onFocus?.(e)
                }}
                onBlur={(e) => {
                  setFocused(false)
                  onBlur?.(e)
                }}
                className={cn(
                  'w-full min-w-0 flex-1 bg-transparent outline-none',
                  'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground',
                  'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
                  'disabled:cursor-not-allowed',
                  inputPadding,
                )}
                {...rest}
              />
    
              {/* Built-in actions render first, then the user's suffix, so the
                  slotted content is always rightmost. */}
              <div className={cn('flex shrink-0 items-center gap-1', sidePad.r)}>
                {showClear && (
                  <button
                    type="button"
                    aria-label="Clear input"
                    className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 shrink-0 rounded p-0.5 transition-colors focus-visible:ring-1 focus-visible:outline-none"
                    onMouseDown={(e) => e.preventDefault()}
                    onClick={handleClear}
                  >
                    <X className="size-4" aria-hidden="true" />
                  </button>
                )}
    
                {hasPasswordToggle && !disabled && !readOnly && (
                  <button
                    type="button"
                    aria-label={passwordVisible ? 'Hide password' : 'Show password'}
                    aria-pressed={passwordVisible}
                    className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 shrink-0 rounded p-0.5 transition-colors focus-visible:ring-1 focus-visible:outline-none"
                    onMouseDown={(e) => e.preventDefault()}
                    onClick={togglePassword}
                  >
                    {passwordVisible ? <Eye className="size-4" aria-hidden="true" /> : <EyeOff className="size-4" aria-hidden="true" />}
                  </button>
                )}
    
                {hasCount && (
                  <span className="text-muted-foreground pointer-events-none text-xs select-none">
                    {currentLength}/{maxLength}
                  </span>
                )}
    
                {hasSuffix && (
                  <span className="text-muted-foreground pointer-events-none select-none">{suffixIcon ?? suffix}</span>
                )}
              </div>
            </div>
    
            {hasAddonAfter && <div className={addonClasses('after')}>{addonAfter}</div>}
          </div>
        )
      },
    )
    Input.displayName = 'Input'
    
    export { Input }
  • components/ui/input/index.ts 0 kB
    export { Input, type InputProps } from './Input'

Raw manifest: https://react.uipkge.dev/r/react/input.json