Skip to content

Instantly share code, notes, and snippets.

@Bloody-Badboy
Created May 22, 2026 03:29
Show Gist options
  • Select an option

  • Save Bloody-Badboy/cddbe2c780bab8e8bac8531d4ec30a32 to your computer and use it in GitHub Desktop.

Select an option

Save Bloody-Badboy/cddbe2c780bab8e8bac8531d4ec30a32 to your computer and use it in GitHub Desktop.
Reusable MUI TextField wrappers for formatted numeric input and masked text input with validation, control, and seamless third-party integration.
import { IMaskInput, IMaskInputProps } from 'react-imask';
import * as React from 'react';
import { TextField, TextFieldProps } from '@mui/material';
type MaskedInputProps = IMaskInputProps<HTMLInputElement>;
const MaskInputCustom = React.forwardRef<HTMLInputElement, MaskedInputProps>(
function MaskInputCustom(props, ref) {
return <IMaskInput {...props} inputRef={ref} />;
},
);
type MaskedTextFieldProps = Omit<TextFieldProps, 'onChange'> & {
value?: string;
onChange?: (value: string) => void;
maskInputProps?: MaskedInputProps;
};
const MaskedTextField = React.forwardRef<HTMLDivElement, MaskedTextFieldProps>(
function MaskedTextField(props, ref) {
const {
value: valueProp,
onChange,
maskInputProps: maskInputPropsProp,
slotProps,
...rest
} = props;
const isControlled = valueProp !== undefined && onChange !== undefined;
const [fallbackValue, setFallbackValue] = React.useState(valueProp);
const value = isControlled ? valueProp : fallbackValue;
const setValue = isControlled ? onChange : setFallbackValue;
const maskInputProps: MaskedInputProps = {
onAccept: (value) => {
setValue(value);
},
value,
...maskInputPropsProp,
};
return (
<TextField
{...rest}
ref={ref}
value={value ?? ''}
slotProps={{
...slotProps,
input: {
inputComponent: MaskInputCustom,
...slotProps?.input,
},
htmlInput: { ...slotProps?.htmlInput, ...maskInputProps },
}}
/>
);
},
);
export default MaskedTextField;
import * as React from 'react';
import { NumericFormat, type NumericFormatProps } from 'react-number-format';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import {
IconButton,
type IconButtonProps,
InputAdornment,
Stack,
TextField,
type TextFieldProps,
} from '@mui/material';
const NumericFormatCustom = React.forwardRef<HTMLInputElement, NumericFormatProps>(
function NumericFormatCustom(props, ref) {
const { decimalSeparator, thousandSeparator, value, ...rest } = props;
return (
<NumericFormat
{...rest}
value={value ?? ''} // We can't ever pass null to value because it breaks the shrink state of the label, so we pass empty string instead
getInputRef={ref}
thousandSeparator={thousandSeparator ?? ''}
decimalSeparator={decimalSeparator ?? '.'}
/>
);
},
);
type NumberInputProps = Omit<TextFieldProps, 'onChange'> & {
hideActionButtons?: boolean;
max?: number;
min?: number;
numericFormatProps?: NumericFormatProps;
onChange?: (value: number | null) => void;
step?: number;
value?: number | null;
};
const NumberInput = React.forwardRef<HTMLDivElement, NumberInputProps>(
function NumberInput(props, ref) {
const {
disabled = false,
hideActionButtons = false,
max = Infinity,
min = -Infinity,
numericFormatProps: numericFormatPropsProp,
onChange,
size,
slotProps,
step = 1,
value: valueProp,
...rest
} = props;
const isControlled = valueProp !== undefined && onChange !== undefined;
// We use an internal state when the component is uncontrolled
const [fallbackValue, setFallbackValue] = React.useState(valueProp);
const value = isControlled ? valueProp : fallbackValue;
const setValue = isControlled ? onChange : setFallbackValue;
const increment = () => {
// If we increment when the input is empty, we consider the previous value to be 0
const newValue = (value != null && !Number.isNaN(value) ? value : 0) + step;
if (newValue > max) {
return;
}
setValue(newValue);
};
const decrement = () => {
// If we decrement when the input is empty, we consider the previous value to be 0
const newValue = (value != null && !Number.isNaN(value) ? value : 0) - step;
if (newValue < min) {
return;
}
setValue(newValue);
};
const numericFormatProps: NumericFormatProps = {
// We set a default to avoid displaying floating point errors when using a decimal step
decimalScale: 5,
// react-number-format doesn't provide min or max props so we do the checking here instead
isAllowed: ({ floatValue }) => {
if (floatValue == null) {
return true;
}
return floatValue >= min && floatValue <= max;
},
// Only add the min, max and step html attributes when the value isn't the default one
max: max !== Infinity ? max : undefined,
min: min !== -Infinity ? min : undefined,
step: step !== 1 ? step : undefined,
// Allow to increment with ArrowUp and decrement with ArrowDown
onKeyDown: (event) => {
if (event.key === 'ArrowUp') {
increment();
} else if (event.key === 'ArrowDown') {
decrement();
}
},
onValueChange: ({ floatValue }) => {
// When incrementing or decrementing, the value prop is already up to date
// so we make sure the value needs to be updated to prevent an unnecessary re-render
if (floatValue === value) {
return;
}
setValue(floatValue ?? null);
},
value,
...numericFormatPropsProp,
};
const commonAdornmentButtonProps: IconButtonProps = {
edge: 'end',
sx: { p: size !== 'small' ? '1px' : 0 },
};
return (
<TextField
{...rest}
ref={ref}
value={value ?? ''} // We can't ever pass null to value because it breaks the shrink state of the label, so we pass empty string instead
disabled={disabled}
size={size}
slotProps={{
...slotProps,
input: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
inputComponent: NumericFormatCustom as any,
endAdornment: !hideActionButtons && (
<InputAdornment position="end">
<Stack>
<IconButton
aria-label="NumberInput"
disabled={disabled || (value ?? 0) + step > max}
onClick={increment}
{...commonAdornmentButtonProps}
>
<KeyboardArrowUpIcon fontSize={size} />
</IconButton>
<IconButton
aria-label="NumberInput"
disabled={disabled || (value ?? 0) - step < min}
onClick={decrement}
{...commonAdornmentButtonProps}
>
<KeyboardArrowDownIcon fontSize={size} />
</IconButton>
</Stack>
</InputAdornment>
),
...slotProps?.input,
},
// @ts-expect-error The type should be React.ComponentProps<typeof inputComponent> but instead
// it is hard-coded to InputBaseComponentProps
htmlInput: { ...slotProps?.htmlInput, ...numericFormatProps },
}}
/>
);
},
);
export default NumberInput;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment