Skip to content

Instantly share code, notes, and snippets.

@Sutil
Last active June 5, 2025 09:50
Show Gist options
  • Save Sutil/5285f2e5a912dcf14fc23393dac97fed to your computer and use it in GitHub Desktop.
Save Sutil/5285f2e5a912dcf14fc23393dac97fed to your computer and use it in GitHub Desktop.
Shandcn UI Money Mask Input - NextJS.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import MoneyInput from "src/components/custom/money-input";
import { Button } from "src/components/ui/button";
import { Form } from "src/components/ui/form";
import * as z from "zod";
const schema = z.object({
value: z.coerce.number().min(0.01, "Required"),
});
export default function PlanForm() {
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
value: 0,
},
mode: "onTouched",
});
function onSubmit(values: z.infer<typeof schema>) {
// handle submit
}
return (
<Form {...form}>
<form
className="flex flex-col gap-8"
onSubmit={form.handleSubmit(onSubmit)}
>
<MoneyInput
form={form}
label="Valor"
name="value"
placeholder="Valor do plano"
/>
<Button type="submit" disabled={!form.formState.isValid}>
Submit
</Button>
</form>
</Form>
);
}
"use client";
import { useReducer } from "react";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../ui/form"; // Shadcn UI import
import { Input } from "../ui/input"; // Shandcn UI Input
import { UseFormReturn } from "react-hook-form";
type TextInputProps = {
form: UseFormReturn<any>;
name: string;
label: string;
placeholder: string;
};
// Brazilian currency config
const moneyFormatter = Intl.NumberFormat("pt-BR", {
currency: "BRL",
currencyDisplay: "symbol",
currencySign: "standard",
style: "currency",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export default function MoneyInput(props: TextInputProps) {
const initialValue = props.form.getValues()[props.name]
? moneyFormatter.format(props.form.getValues()[props.name])
: "";
const [value, setValue] = useReducer((_: any, next: string) => {
const digits = next.replace(/\D/g, "");
return moneyFormatter.format(Number(digits) / 100);
}, initialValue);
function handleChange(realChangeFn: Function, formattedValue: string) {
const digits = formattedValue.replace(/\D/g, "");
const realValue = Number(digits) / 100;
realChangeFn(realValue);
}
return (
<FormField
control={props.form.control}
name={props.name}
render={({ field }) => {
field.value = value;
const _change = field.onChange;
return (
<FormItem>
<FormLabel>{props.label}</FormLabel>
<FormControl>
<Input
placeholder={props.placeholder}
type="text"
{...field}
onChange={(ev) => {
setValue(ev.target.value);
handleChange(_change, ev.target.value);
}}
value={value}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
);
}
@hspotted
Copy link

Funciona muito bem quando estás a introduzir os dados, mas a apagar ele não funciona

@Sutil
Copy link
Author

Sutil commented Jan 15, 2024

Funciona muito bem quando estás a introduzir os dados, mas a apagar ele não funciona

@hspotted, It worked fine here. Let me know what behavior you expect on this.

Screen.Recording.2024-01-15.at.10.14.44.mov

@DaviJat
Copy link

DaviJat commented Feb 27, 2024

Estou usando o componente e ele funciona perfeitamente, mas quando faço form.reset(data) para recuperar os dados, é o único campo que não atualiza

@Sutil
Copy link
Author

Sutil commented Feb 29, 2024

Estou usando o componente e ele funciona perfeitamente, mas quando faço form.reset(data) para recuperar os dados, é o único campo que não atualiza

Não previ este caso. A linha 51 sempre vai atrapalhar.

Vou ter que mudar a implementação

@DaviJat
Copy link

DaviJat commented Feb 29, 2024

Eu consegui contornar a situação aqui, adicionei um useEffect no componente para atualizar o valor, e estou passando o value pelo formulário como uma props do componente:

useEffect(() => {
    if (props.value) {
      setValue((Number(props.value) * 100).toString());
    }
  }, [props.form, props.value]);
const [balanceValue, setBalanceValue] = useState('');

// setBalanceValue usei onde pego os dados da API

<MoneyInput
            form={form}
            value={balanceValue}
            label="Saldo"
            name="balance"
            placeholder={!isDataLoading ? 'Saldo da carteira...' : 'Carregando...'}
          />

@thailonlucas
Copy link

Eu consegui contornar a situação aqui, adicionei um useEffect no componente para atualizar o valor, e estou passando o value pelo formulário como uma props do componente:

useEffect(() => {
    if (props.value) {
      setValue((Number(props.value) * 100).toString());
    }
  }, [props.form, props.value]);
const [balanceValue, setBalanceValue] = useState('');

// setBalanceValue usei onde pego os dados da API

<MoneyInput
            form={form}
            value={balanceValue}
            label="Saldo"
            name="balance"
            placeholder={!isDataLoading ? 'Saldo da carteira...' : 'Carregando...'}
          />

Eu resolvi dessa forma e deixou o código um pouco mais limpo:

useEffect(() => { setValue(initialValue); }, [initialValue]);

Mas isso porque nao passo o value como prop e continuo utilizando apenas o form como fonte de dado.

@edsonbraz
Copy link

edsonbraz commented May 8, 2024

A little late, but here is how I managed the form.reset() outside the component:

const formData = form.watch(name);

useEffect(() => {
  const formValue = moneyFormatter.format(Number(formData));
  if (formValue !== value) {
    setValue(formValue);
  }
}, [formData, value])

@tiagoluizpoli
Copy link

Funciona muito bem quando estás a introduzir os dados, mas a apagar ele não funciona

Estou tendo esse problema, mas apenas quando pelo celular (chrome mobile).

Pra mim, ao clicar backspack ele apaga, mas move o cursor uma casa a esquerda.

WhatsApp.Video.2024-06-03.at.21.02.51.mp4

No pc está impecável.

@ludioao
Copy link

ludioao commented Jul 11, 2024

@tiagoluizpoli

Tenta colocar o attribute inputmode = decimal

@frontandrews
Copy link

frontandrews commented Oct 10, 2024

Valeu pessoal ajudou a dar um norte, vou deixar meu código aqui caso ajude mais alguém.

use client';
import { useEffect, useState } from 'react';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '../ui/form';
import { Input } from '../ui/input';
import { UseFormReturn } from 'react-hook-form';
import { formatCurrency } from '@/lib/utils';

type MoneyInputProps = {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  form: UseFormReturn<any>;
  name: string;
  label: string;
  placeholder?: string;
  value?: string | number;
};

export const MoneyInput = (props: MoneyInputProps) => {
  const [inputValue, setInputValue] = useState<string>(props.value ? formatCurrency(Number(props.value)) : '');

  useEffect(() => {
    const formValue = props.form.getValues(props.name);
    if (formValue && formValue !== inputValue) {
      setInputValue(formatCurrency(Number(formValue)));
    }
  }, [props.form, props.name, props.value, inputValue]);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const rawValue = e.target.value.replace(/\D/g, '');
    const numericValue = (Number(rawValue) / 100).toFixed(2);
    setInputValue(rawValue);
    props.form.setValue(props.name, numericValue, { shouldValidate: true });
  };

  const handleBlur = () => {
    if (inputValue) {
      const formattedValue = formatCurrency(Number(inputValue) / 100);
      setInputValue(formattedValue);
    }
  };

  const handleFocus = () => {
    const currentValue = props.form.getValues(props.name);
    if (currentValue) {
      setInputValue((Number(currentValue) * 100).toString());
    }
  };

  return (
    <FormField
      control={props.form.control}
      name={props.name}
      render={({ field }) => (
        <FormItem className="flex-1">
          <FormLabel>{props.label}</FormLabel>
          <FormControl>
            <Input
              placeholder={props.placeholder}
              type="text"
              value={inputValue}
              onChange={handleChange}
              onBlur={handleBlur}
              onFocus={handleFocus}
              name={field.name}
            />
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />
  );
};
export const formatCurrency = (amount: number, currency: string = 'USD', locale: string = 'en-US'): string => {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currency,
  }).format(amount);
};

@renangasperi
Copy link

Based on the examples here, I did it this way using the formContext, with stronger typing and validation to prevent the field from showing R$ 0.00 when empty, displaying the placeholder instead. It works with reset()

import { useEffect, useReducer } from "react";
import { FieldValues, Path, useFormContext } from "react-hook-form";
import {
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "../ui/form";
import { Input, InputProps } from "../ui/input";

export type InputCurrencyProps<T extends FieldValues> = InputProps & {
  name: Path<T>;
  label?: string;
};

const toCurrency = (value: number) =>
  new Intl.NumberFormat("pt-BR", {
    currency: "BRL",
    style: "currency",
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  }).format(value);

function InputCurrency<T extends FieldValues>({
  name,
  label,
  ...props
}: InputCurrencyProps<T>) {
  const { watch, control } = useFormContext();
  const formValue = watch(name);

  const [value, setValue] = useReducer((_: string, next: string) => {
    if (!next) return "";
    const numericValue = Number(next.replace(/\D/g, "")) / 100;
    return numericValue ? toCurrency(numericValue) : "";
  }, "");

  useEffect(() => {
    setValue(formValue ? toCurrency(formValue) : "");
  }, [formValue]);

  return (
    <FormField
      control={control}
      name={name}
      render={({ field }) => (
        <FormItem>
          {label && <FormLabel>{label}</FormLabel>}
          <FormControl>
            <Input
              id={name}
              type="text"
              {...props}
              {...field}
              onChange={(ev) => {
                const inputValue = ev.target.value;
                setValue(inputValue);
                const numericValue =
                  Number(inputValue.replace(/\D/g, "")) / 100;
                field.onChange(numericValue || 0);
              }}
              value={value}
            />
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />
  );
}

export default InputCurrency;

@luiznegreiros
Copy link

Adding my two cents: I suggest using the currency type in the input field for better usability:

import { cn } from "@/lib/utils";
import * as React from "react";

type InputType = React.HTMLInputTypeAttribute | "currency";

export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  currencyFormat?: Intl.NumberFormat;
  type?: InputType;
}

const defaultCurrencyFormat = new Intl.NumberFormat("pt-BR", {
  style: "currency",
  currency: "BRL",
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
});

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ className, type = "text", currencyFormat, onChange, onFocus, ...props }, ref) => {
    const isCurrency = type === "currency";
    const inputType = isCurrency ? "text" : type;

    const formatCurrency = (value: number) => {
      return (currencyFormat ?? defaultCurrencyFormat).format(value);
    };

    const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
      if (isCurrency) {
        const target = e.currentTarget;
        target.setSelectionRange(target.value.length, target.value.length);
      }
      onFocus?.(e);
    };

    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      if (isCurrency) {
        const target = e.currentTarget;
        const numericValue = Number(target.value.replace(/\D/g, "")) / 100;
        target.value = formatCurrency(numericValue);
      }
      onChange?.(e);
    };

    return (
      <input
        type={inputType}
        className={cn(
          "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm",
          "ring-offset-background file:border-0 file:bg-transparent file:text-sm",
          "file:font-medium file:text-foreground placeholder:text-muted-foreground",
          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
          "focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
          isCurrency && "text-end",
          className,
        )}
        maxLength={isCurrency ? 22 : undefined}
        onFocus={handleFocus}
        onChange={handleChange}
        ref={ref}
        {...props}
      />
    );
  },
);
Input.displayName = "Input";

export { Input };

usage:

              <FormField
                control={form.control}
                name="balance"
                render={({ field }) => (
                  <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0">
                    <FormLabel className="text-right">Balance</FormLabel>
                    <FormControl>
                      <Input type="currency" {...field} className="col-span-3" />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />

@Matheus8174
Copy link

Who is using react-native can do

<Controller
  name="price"
  control={control}
  render={({ field }) => (
    <Input
      ref={field.ref}
      onChangeText={(text) => {
        const digits = text.replace(/\D/g, '');
        const realValue = Number(digits) / 100;

        field.onChange(realValue);
      }}
      value={moneyFormatter.format(field.value)}
      keyboardType="numeric"
      placeholder="R$ 50"
      className="border-gray-200/20 bg-white-100 p-5 max-w-40"
    />
  )}
/>

@ZakHargz
Copy link

ZakHargz commented Jun 5, 2025

How would this work for negative values also?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment