shadimage
turbopills-ui
componentmasked-inputsource: ok · refreshed

Masked Input

Masked input with presets (tel/date) and optional calendar picker.

npx shadcn add "https://ui.turbopills.com/r/masked-input.json"
free

Where it goes

calendar

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

buttonpopovercalendar

Files2 · source cached 10/7/2026, 6:21:50 PM

registry/turbopills/lib/styles.tsx
import { cn } from "@/lib/utils";

export const optionStyles = {
  base: "h-15 inline-flex items-center justify-between w-full bg-background border rounded-lg p-4 cursor-pointer transition-all",
  textSelectionDisabled: "select-none",
  hover: "hover:ring-1 hover:ring-border",
  outlineOnSelect: "ring-2 ring-primary hover:ring-2 hover:ring-primary",
  fillOnSelect: "bg-primary/10",
  shadowOnSelect: "shadow-md shadow-primary/25",
  disabled: "opacity-50 cursor-not-allowed",
};

export const getOptionClassName = ({
  selected = false,
  highlightOnHover = true,
  showOutlineOnSelect = true,
  showFillOnSelect = false,
  showShadowOnSelect = false,
  disabled = false,
  allowTextSelection = false,
}: {
  selected?: boolean;
  highlightOnHover?: boolean;
  showOutlineOnSelect?: boolean;
  showFillOnSelect?: boolean;
  showShadowOnSelect?: boolean;
  disabled?: boolean;
  allowTextSelection?: boolean;
}) =>
  cn(
    optionStyles.base,
    !allowTextSelection && optionStyles.textSelectionDisabled,
    highlightOnHover && optionStyles.hover,
    selected && showOutlineOnSelect && optionStyles.outlineOnSelect,
    selected && showFillOnSelect && optionStyles.fillOnSelect,
    selected && showShadowOnSelect && optionStyles.shadowOnSelect,
    disabled && optionStyles.disabled,
  );

export const fieldHeaderStyles = {
  container: "mb-3 space-y-1",
  title: "text-3xl leading-9 font-medium",
  titleSm: "text-xl leading-6 font-medium",
  hint: "text-muted-foreground",
  hintSm: "text-sm text-muted-foreground",
};

export const inputStyles = {
  wrapper:
    "relative flex items-center w-full border rounded-2xl bg-background hover:border-primary transition-[color,border-color] overflow-hidden focus-within:ring-1 focus-within:ring-primary focus-within:border-primary",
  wrapperDefault: "h-[60px]",
  wrapperSm: "h-10 !rounded-lg",
  input:
    "flex-1 w-full bg-transparent border-0 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
  inputDefault: "px-5 py-4 text-md placeholder:text-md",
  inputSm:
    "h-[38px] p-2 text-sm font-light placeholder:text-sm placeholder:font-light",
  suffix: "flex items-center pl-2 pr-5 text-muted-foreground text-sm",
  error: "border-destructive focus-within:outline-destructive/20 focus-within:border-destructive",
  autofill:
    "[&:has(input:-webkit-autofill)]:bg-[#e8f0fe]",
  inputAutofill:
    "autofill:[box-shadow:inset_0_0_0_9999px_#e8f0fe] autofill:[-webkit-text-fill-color:var(--color-foreground)]",
};

export const getInputWrapperClassName = ({
  size = "md",
  hasError = false,
}: {
  size?: "md" | "sm";
  hasError?: boolean;
}) =>
  cn(
    inputStyles.wrapper,
    inputStyles.autofill,
    size === "sm" ? inputStyles.wrapperSm : inputStyles.wrapperDefault,
    hasError && inputStyles.error,
  );

export const getInputClassName = ({
  size = "md",
  hasSuffix = false,
}: {
  size?: "md" | "sm";
  hasSuffix?: boolean;
}) =>
  cn(
    inputStyles.input,
    inputStyles.inputAutofill,
    size === "sm" ? inputStyles.inputSm : inputStyles.inputDefault,
    hasSuffix && "pr-0",
  );
registry/turbopills/ui/masked-input.tsx
"use client";

import * as React from "react";
import { InputMask, InputMaskProps } from "@react-input/mask";
import { CalendarIcon } from "lucide-react";

import { cn } from "@/lib/utils";
import {
  fieldHeaderStyles,
  inputStyles,
  getInputWrapperClassName,
  getInputClassName,
} from "@/lib/styles";

import { Button } from "@/components/ui/button";
import {
  Popover,
  PopoverAnchor,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";

const formatDate = (date: Date): string => {
  const month = String(date.getMonth() + 1).padStart(2, "0");
  const day = String(date.getDate()).padStart(2, "0");
  const year = date.getFullYear();
  return `${month}/${day}/${year}`;
};

const parseDate = (str: string): Date | undefined => {
  if (!str || str.length < 10) return undefined;
  const match = str.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
  if (!match) return undefined;
  const [, month, day, year] = match;
  const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
  if (
    date.getFullYear() !== parseInt(year) ||
    date.getMonth() !== parseInt(month) - 1 ||
    date.getDate() !== parseInt(day)
  ) {
    return undefined;
  }
  return date;
};

export type MaskPreset = "tel" | "date" | "custom";

export interface MaskedInputProps
  extends Omit<
    React.InputHTMLAttributes<HTMLInputElement>,
    "size" | "onChange" | "value"
  > {
  title?: string;
  hint?: string;
  size?: "md" | "sm";
  suffix?: React.ReactNode;
  errorMessage?: string;
  wrapperClassName?: string;
  value?: string;
  onChange?: (value: string) => void;

  preset?: MaskPreset;
  mask?: string;
  replacement?: InputMaskProps["replacement"];
  separate?: boolean;
  showMask?: boolean;
  calendarCaptionLayout?:
    | "label"
    | "dropdown"
    | "dropdown-months"
    | "dropdown-years";
}

const MASK_PRESETS: Record<
  Exclude<MaskPreset, "custom">,
  {
    mask: string;
    placeholder: string;
    replacement: InputMaskProps["replacement"];
    separate?: boolean;
  }
> = {
  tel: {
    mask: "(___) ___-____",
    placeholder: "(___) ___-____",
    replacement: { _: /\d/ },
    separate: true,
  },
  date: {
    mask: "MM/DD/YYYY",
    placeholder: "MM/DD/YYYY",
    replacement: { M: /\d/, D: /\d/, Y: /\d/ },
  },
};

export const MaskedInput = React.forwardRef<HTMLInputElement, MaskedInputProps>(
  (
    {
      title,
      hint,
      size = "md",
      suffix,
      errorMessage,
      className,
      wrapperClassName,
      disabled,
      value,
      onChange,
      preset = "custom",
      mask,
      placeholder,
      replacement,
      separate,
      showMask = true,
      calendarCaptionLayout = "dropdown",
      onFocus,
      ...props
    },
    ref,
  ) => {
    const inputId = React.useId();
    const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);

    const presetConfig = preset !== "custom" ? MASK_PRESETS[preset] : null;

    const resolvedMask = mask ?? presetConfig?.mask;
    const resolvedPlaceholder = placeholder ?? presetConfig?.placeholder;
    const resolvedReplacement = replacement ?? presetConfig?.replacement;
    const resolvedSeparate = separate ?? presetConfig?.separate;

    const isDateInput = preset === "date";
    const hasSuffix = !!suffix || isDateInput;

    const [internalValue, setInternalValue] = React.useState(value ?? "");
    const isControlled = value !== undefined;
    const currentValue = isControlled ? value : internalValue;

    const [selectedDate, setSelectedDate] = React.useState<Date | undefined>(
      () => {
        if (!isDateInput || !currentValue) return undefined;
        return parseDate(currentValue);
      },
    );
    const [month, setMonth] = React.useState<Date | undefined>(selectedDate);

    React.useEffect(() => {
      if (!isDateInput) return;

      const parsed = parseDate(currentValue);
      setSelectedDate(parsed);
      if (parsed) {
        setMonth(parsed);
      }
    }, [currentValue, isDateInput]);

    const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      if (!isControlled) {
        setInternalValue(newValue);
      }
      onChange?.(newValue);
    };

    const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
      const input = e.target as HTMLInputElement;

      setTimeout(() => {
        const val = input.value;
        const hasDigit = /\d/.test(val);

        if (!hasDigit) {
          if (isDateInput) {
            input.setSelectionRange(0, 0);
          } else if (preset === "tel") {
            input.setSelectionRange(1, 1);
          }
        }
      }, 50);

      onFocus?.(e);
    };

    const handleDateSelect = (date: Date | undefined) => {
      setSelectedDate(date);
      if (date) {
        const formatted = formatDate(date);
        if (!isControlled) {
          setInternalValue(formatted);
        }
        onChange?.(formatted);
      }
      setIsPopoverOpen(false);
    };

    const inputElement = (
      <InputMask
        ref={ref}
        id={inputId}
        disabled={disabled}
        aria-invalid={!!errorMessage}
        aria-describedby={errorMessage ? `${inputId}-error` : undefined}
        className={cn(getInputClassName({ size, hasSuffix }), className)}
        value={currentValue}
        onChange={handleInputChange}
        onFocus={handleFocus}
        showMask={showMask}
        mask={resolvedMask}
        placeholder={resolvedPlaceholder}
        replacement={resolvedReplacement}
        separate={resolvedSeparate}
        type="text"
        {...props}
      />
    );

    const inputWrapperClassName = cn(
      getInputWrapperClassName({ size, hasError: !!errorMessage }),
      disabled && "opacity-50 cursor-not-allowed",
      wrapperClassName,
    );

    return (
      <div className="space-y-1">
        {(title || hint) && (
          <div className={fieldHeaderStyles.container}>
            <label htmlFor={inputId}>
              {title && <p className={size === "sm" ? fieldHeaderStyles.titleSm : fieldHeaderStyles.title}>{title}</p>}
              {hint && <p className={size === "sm" ? fieldHeaderStyles.hintSm : fieldHeaderStyles.hint}>{hint}</p>}
            </label>
          </div>
        )}

        {isDateInput ? (
          <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
            <PopoverAnchor asChild>
              <div className={inputWrapperClassName}>
                {inputElement}

                <PopoverTrigger asChild>
                  <Button
                    type="button"
                    variant="ghost"
                    size="icon"
                    disabled={disabled}
                    className="mr-3 ml-1 size-6 p-0"
                  >
                    <CalendarIcon className="size-4" />
                    <span className="sr-only">Select date</span>
                  </Button>
                </PopoverTrigger>
              </div>
            </PopoverAnchor>

            <PopoverContent
              className="w-auto overflow-hidden p-0"
              align="end"
              sideOffset={8}
            >
              <Calendar
                mode="single"
                selected={selectedDate}
                month={month}
                onMonthChange={setMonth}
                captionLayout={calendarCaptionLayout}
                onSelect={handleDateSelect}
              />
            </PopoverContent>
          </Popover>
        ) : (
          <div className={inputWrapperClassName}>
            {inputElement}
            {suffix && (
              <div className={inputStyles.suffix}>{suffix}</div>
            )}
          </div>
        )}

        {errorMessage && (
          <p
            id={`${inputId}-error`}
            role="alert"
            className="text-sm text-destructive"
          >
            {errorMessage}
          </p>
        )}
      </div>
    );
  },
);

MaskedInput.displayName = "MaskedInput";

More from turbopills-ui

Browse all components in this library →