How to make beautiful inputs on the web (like Stripe and Xero)
Every piece of software takes user input in some capacity. Right now I’m typing this article into Typora, and earlier I was scheduling my upcoming week in Google Calendar. Prior to getting to that point, I had to authenticate into my computer with my user account’s password1. If I wasn't the only user on my computer, then in addition to a password I'd also need to provide a username. Here's an example of what that looks like on Windows 10:
This sign in form demonstrates two things which we might easily take for granted:
- Firstly, individual inputs rarely exist in isolation and are generally composed together as a form. Forms allow users to enter structured data.
- Secondly, there are often requirements around how data is formatted. Passwords are sensitive information, and so their display should be censored to guard against third parties looking over our user's shoulder.
Password fields are not the only place where we might want to apply some special display rules. Number inputs, for instance, feel really nice to use when they intelligently add thousands separators to break up the number in order to assist with readability. Small data display improvements like that have a disproportionately large impact on the overall user experience. They delight users, aid productivity, and enhance clarity. Data entry errors become immediately obvious to the user when we format their data well.
In addition to helping assist against typos, formatting data in an appropriate fashion can also help prevent users from typing the correct value into the wrong location. Expedia famously managed to boost their annual profit by $12m simply by deleting an optional field — some users thought the “Company” input was for the name of their bank, and would then go on to enter their bank’s address instead of their home address which would cause issues with credit card verification later on in the payment flow.
The macro task of form management is pretty easy these days: pick a library you like (my favorite is Formik) and it’ll probably do everything you need it to. The micro task of ensuring each input within that form supports your user in accomplishing their goals, however, is much harder and there isn’t much out there.
Even multi-billion dollar companies struggle with their inputs.
Case study: Wix
Wix is a website builder and hosting service with a number of interesting value adds. One of the features they offer on their platform is a simple quoting and invoicing system which slots in alongside the website you’ve purchased from them. The interface for creating a quote on Wix has a lot of inputs on it, but we’re going to focus on the “discount” field, which gives you the ability to provide your prospective customer with a percentage-based discount. For this experiment, we’re going to initially type in a discount of 50.1% and then amend it down to 30.1% once we realize that 50.1% is a bit high. Here's the sequence of actions we'll take, along with observations at each step:
Instruction | Screenshot |
---|---|
Initially, the discount is zero. | |
Focusing the field highlights the zero. | |
Typing in a ‘5’ replaces the zero. But my caret has been placed at the start of the input… | |
…oddly enough, typing a ‘0’ places it at the end of the input and keeps my caret at the start… | |
…typing a decimal point places it at the start of the input… | |
…and then typing another digit moves the decimal point to the end of the input, along with my extra digit. |
I successfully typed in my discount of 50.1%, but the journey getting there was bizarre. If I were to hit backspace at any point during that process, the character at the end of the input would be deleted (again, even though my caret’s at the beginning of the input) but the thing that really makes this a strange input is what happens when you need to come back to the field in order to make an adjustment.
So, I have “50.1” in my input. If I focus the input and hit backspace with the caret at the beginning of the input, all is well and that final “1” will be deleted. But if I instead place my caret at the end of the input (i.e. right after the character I’m trying to delete), then the backspace key doesn’t do anything. If I move my caret to be after the ‘5’, then hitting backspace successfully deletes the first character but then my caret jumps to the end of the input instead of staying at the beginning.
Now if I enter a ‘3’, the new digit is correctly placed at the start of the input despite my caret being at the end (and so now, I have given my customer a discount of 30.1%), and you might be forgiven for assuming that Wix might be keeping track of the caret’s “actual” position somewhere under the hood — but that isn’t the case at all. If I manually put my caret at the end of the input and try to type a ‘5’ in order to bring my discount to 30.15%, my new ‘5’ gets added to the start of the input and now my field is in an error state because I’m not supposed to enter a discount greater than 100%.
If I type in ‘30.15’ right from the default value, then that works fine despite the caret being positioned strangely but it doesn’t seem possible to add that second decimal point after you’ve dirtied and blurred the field for the first time. You also can’t easily delete your last decimal point — and if you try to do it another way like by selecting the last decimal point and then hitting backspace, that doesn’t work either because Wix will helpfully select the first character of the input for you instead.
And if you aren’t coming at it from that initial ‘0’ and instead are editing a zero that’s been manually entered into the form, things get weirder still. You can’t delete the zero (because the backspace key doesn’t work for the last character of this input), and if you type at the end of the input your character will be prepended to the 0 (so instead of getting ‘02’, you get ‘20’) and typing before the 0 winds up appending your input (so instead of getting ‘20’, you get ‘02’).
I have no idea why this input is so strange — especially given that it isn’t doing anything fancy with respect to formatting the number you’ve input. It’s just a plain numerical input that shows an error when its value is outside of the range between 0 and 100.
And to be clear, this isn’t just a Wix thing. The Internet is littered with inputs like this one which just don’t work the way you expect them to.
How to make a great input
I’m going to show some code for a good number input, and the underlying concepts at play can be applied to almost any other situation where you want to format user input in some manner. The specific constraints I’m imposing here are:
- Our number input should work as a reasonable user would expect
- Backspace works anywhere in the field
- The text caret is positioned consistently
- Copy/paste and keyboard navigation works well
- The number the user has entered is displayed with thousands separators (e.g. “1000” is displayed as “1,000”)
- Our number input works with some other packages we might be using — Formik and MUI
Building the MVP
To start with, we need some packages.
$ npx create-react-app number-input$ cd number-input$ yarn add react-text-mask text-mask-addons @material-ui/core formik$ yarn add -D --save @types/react-text-mask
Material UI has a confusing component hierarchy when it comes to inputs, but simply stated, InputBase
is a lightweight wrapper around the base HTML input
element, and then the *Input
family of components (such as OutlinedInput
) wrap InputBase
in order to apply visual styles. Their TextField
component is the highest level in the chain, and is responsible for rendering the appropriate *Input
component alongside other useful components such as FormHelperText
if you provided helper text or error text for your input. We want to override the lowest-level component, InputBase
, as in order to accomplish our goals we need to have control over the actual underlying input
node, and working through layers of indirection makes accomplishing that a lot harder.
import type { InputBaseComponentProps } from '@material-ui/core'; const NumberInput: React.FC<InputBaseComponentProps> = () => { return null; // @todo} export default NumberInput;
As our component is overriding the InputBase
component from Material UI, we want to accept all of the props which InputBase
ordinarily takes so we can handle whatever the developer has provided us with. In general, the best practice when swapping out components or composing components is to ensure that developer expectations are preserved. Silently ignoring props like event handlers violates the principle of least surprise.
Importing our dependencies brings us here:
import { createNumberMask } from 'text-mask-addons';import MaskedInput from 'react-text-mask';import type { InputBaseComponentProps } from '@material-ui/core'; const NumberInput: React.FC<InputBaseComponentProps> = () => { return null; // @todo} export default NumberInput;
The text-mask-addons
package is written by the authors of text-mask
, which is the underlying package which react-text-mask
wraps. Inside text-mask-addons
is a very useful factory function for generating masks which only accept numbers. Masks are arrays of letters and regexes which text-mask
uses to enforce a certain "shape" of input. Here, it doesn't make sense to reinvent the wheel so we're going to use their convenient factory method.
import { createNumberMask } from 'text-mask-addons';import MaskedInput from 'react-text-mask';import type { InputBaseComponentProps } from '@material-ui/core'; const NumberInput: React.FC<InputBaseComponentProps> = (props) => { const mask = createNumberMask({ allowDecimal: true, prefix: '', }); return <MaskedInput {...props} mask={mask} />;} export default NumberInput;
By rendering a MaskedInput
and spreading the incoming InputBaseComponentProps
, we get fairly close to where we want to be but things still aren't perfect. While text-mask
handles our caret positioning well, when we try to wire up an onChange
handler the only mechanism we have for pulling the text field's value is by accessing event.target.value
--which will be the raw text value of the input; or, more plainly, our masked value. Abstracting away the domain-specific knowledge of how the value is masked by the input is our ultimate goal here, so that we can keep things simple elsewhere in the code.
Converting a masked value such as 1,000
into its unmasked equivalent of 1000
is quite straightforward in Javascript: we simply need to extract all of the digits from the string. We can do this simply with a function like the following:
const unmaskNumber = (value: string) => { const matches = value.match(/(\d|\.)+/g); if (matches === null) { return ''; } return matches.join('');}
Instead of using a regex and then joining all the matches together, you could also write a for loop which iterates over each character and builds up the string iteratively. The regex also captures the period character to handle the case where our input accepts decimal numbers2.
We could, in theory, export this function and simply call unmaskNumber(event.target.value)
within the onChange
handler we pass to NumberInput
. The code would look like this:
import { TextField } from '@material-ui/core'; const App = () => { const [value, setValue] = useState(''); return ( <TextField InputProps={{ inputComponent: NumberInput }} value={value} onChange={(e) => setValue(unmaskNumber(e.target.value))} /> );}
Fixing the leaky abstraction
If you're happy calling unmaskNumber
everywhere then there's nothing further to do. We're done here. You can use the TextField
component from formik-material-ui
as a drop-in replacement for Material UI's stock TextField
component and have the whole thing working within a formik
form. Done.
Doing that, however, means you're working with an incredibly leaky abstraction. Code using NumberInput
should not need to know internal implementation details of how NumberInput
presents its data to the user. Setting the target.value
property of the event object before bubbling it up out of NumberInput
doesn't work because changing that property will clobber whatever the user has typed into the field.
There are two ways of solving this:
- Create a synthetic event object of your own, and pass that up the chain from
NumberInput
toTextField
. This way, you can setevent.target.value
to whatever you want without clobbering the value of the field. - Wrap React's synthetic event object with a
Proxy
. For anything other thanevent.target
you would return the value stored on React's event object, and you would special case the value property to return the result ofunmaskNumber
.
At the time of writing, Proxy
objects are supported in 96.3% of browsers. This is likely high enough to the point where you are happy to go ahead with using proxies in production. IE11 makes up a good portion of the userbase which can't use proxies, and for what it's worth not even Microsoft supports IE11 in their modern web applications such as Teams. Vue 3 is also contemplating dropping support for IE11.
If you do need to support legacy browsers, however, then there's no option but to create your own "synthetic" synthetic event. There is no particularly clean way of doing this, and code which interacts with event objects has a very real chance of breaking if it is expecting certain properties to exist on the change event which you haven't mirrored properly. Formik
, for instance, pulls a number of props out of event.target
which might surprise you--like outerHTML
.
Which option you opt for depends on your use case. The implementation using a custom synthetic event object might look like the following:
// the rest is unchangedreturn ( <MaskedInput {...props} mask={mask} onChange={(e) => { const unmaskedValue = unmaskNumber(e.currentTarget.value); // @ts-expect-error props.onChange?.({ target: { value: unmaskedValue, id: e.target.id, // ... other fields }, // ... other fields }); }} />);
Refs
Material UI's TextField
passes the InputBase
component an inputRef
prop which it expects to be attached to the node rendered by InputBase
. This is so the imperative focus()
and blur()
methods work on the TextField
instance.
The ref
returned by MaskedInput
returns a MaskedInput
instance, and you can pull the underlying input
node by accessing the inputElement
property:
return ( <MaskedInput ... ref={(instance) => { inputRef(instance ? instance.inputElement : null); }}>);
This sort of mapping behavior is necessary in a wide variety of situations. If you want to use a Stripe Elements input in place of InputBase
, there are similar concerns around the ref object and massaging Material UI's classes
prop into the shape that Elements expects its classes
prop to be in.
What if your form state saves a number?
So far, we have been sending a string to the consumer of NumberInput
through the onChange
event. There's not a whole lot stopping us from directly passing a number down the value
prop of TextField
, and converting the result of unmaskNumber
from a string via Number.parseFloat
but doing so will introduce subtle usability bugs. For instance the following sequence:
- Type
2.
- Press the left arrow key to move the caret to the position before the decimal
- Type any other digit
Will result in the decimal point disappearing from the input--which isn't what we want at all! The issue is that the numbers 2.
and 2
are stored the same on the machine; so when we type our TextField
doesn't end up updating. The decimal point gets held on to by the MaskedInput
because our NumberInput
therefore also doesn't end up updating.
Once you change from 2.
to 22.
, however, NumberInput
does update and it can only "see" the leading digits. The decimal point gets lost because the actual value of the input
node gets set to 22.toString()
. How do we solve this problem?
The key thing to note here is that the reason we're able to type a decimal in the first place is because the input
node is essentially keeping track of its own value
state. While the component is controlled, the way in which we're threading our number down results in the input acting in an uncontrolled fashion once we type the decimal point in.
If we want to preserve the decimal point once our received value
prop changes, we can simply build upon this idea. Before calling our onChange
handler, we'll first save the unmasked value (in string form) in some piece of local state within the NumberInput
component. Then, when we render NumberInput
we'll sometimes use that local piece of state as the MaskedInput
's value
, and sometimes we'll use the value stored within our value
prop.
The way we decide to switch between the two options is based on whether they parse to the same number. If the value
prop parses to a different number, then we'll pass that down to MaskedInput
and if they're equal we'll pass the local state down.
In the case of our disappearing decimal, what happens is:
- The user types
2.
. Thevalue
prop is2
and our local state is the string2.
. - The user types a
1
before the decimal point.- Before firing
onChange
, the local state is set to the content of the text field: the string22.
onChange
is called with the unmasked input content, which is the string22.
- The parent component saves
Number.parseFloat(e.target.value)
into its form state. This is the number22
.
- Before firing
- The
TextField
and therefore ourNumberInput
rerenders.- The
value
prop is the string22
and our local state is the string22.
- Because they parse to the same value, the
MaskedInput
receives22.
as itsvalue
. - The user's decimal point has been preserved!
- The
The reason you want to fall back to the value
prop when the prop and state don't parse to the same number is to handle cases where the value
has changed outside of the NumberInput
. This could happen if your user has refetched the document they're looking at, or if there are some kind of state dependencies in play: consider a form containing "price", "quantity", and "unit price" inputs where modifying either of "price" or "quantity" changes "unit price", and changing "unit price" modifies "price."
The final code
The complete code sample looks like this:
import { createNumberMask } from 'text-mask-addons';import MaskedInput from 'react-text-mask';import type { InputBaseComponentProps } from '@material-ui/core'; const unmaskNumber = (value: string) => { const regex = /(\d|\.)+/g; const matches = value.match(regex); if (matches === null) { return ''; } return matches.join('');}; const NumberInput: React.FC<InputBaseComponentProps> = (props) => { const { inputRef, value: valueProp, onChange, type, ...other } = props; const [rawValue, setRawValue] = useState(valueProp); const numberMask = createNumberMask({ allowDecimal: true, prefix: '', }); // If these two floats are different it means that the user has entered a '.' const currentValue = Number.parseFloat(valueProp) !== Number.parseFloat(rawValue) ? valueProp : rawValue; const ref = useCallback( (instance: MaskedInput | null) => { inputRef(instance ? instance.inputElement : null); }, [inputRef], ); return ( <MaskedInput {...other} ref={ref} mask={numberMask} value={currentValue} onChange={(e) => { const numericValue = unmaskNumber(e.currentTarget.value); setRawValue(numericValue); onChange?.(createSyntheticChangeEvent(e, numericValue); }} /> );}; function createSyntheticChangeEvent( baseEvent: React.ChangeEvent<HTMLInputElement>, value: string,): React.ChangeEvent<HTMLInputElement> { const syntheticEvent = { target: { value }, // ... }; // @ts-expect-error Treat this like a normal ChangeEvent return syntheticEvent;} export default NumberInput;
The end result of this is a component which formats numbers beautifully, without any of the surrounding code needing to be concerned. It behaves in an expected manner when users manipulate their caret, hit backspace, or really anything else. Because it's so turnkey, you can even drop it right into a formik
form and immediately get nice-to-haves like error state, validation, and loading state.
As an extension, you may wish to support additional props which allow for customization of the number mask. An allowDecimal
prop, for instance, could be passed directly to the createNumberMask
factory and would allow you to opt-out of decimal support in instances where you only want to take in integers.
Final thoughts
Outside of numeric inputs, there are a variety of other situations where you also want to be taking in data in a special format. You might not always need to strip out the mask--email addresses for instance are impossible to use if you start stripping out the '@' or '.' symbols--but in many cases you don't care about the formatting. GST numbers in New Zealand are another example of a situation where you want the data formatted for the user, and stored as a plain sequence of digits in your database.
My favorite all-time input I've helped create with this collection of packages is the Kwotimation phone number input. If you'd like to see that in action, click here, add that tile to your cart as a sample, and then go to checkout. There's a phone number input inside the checkout UI which you can toy around with--it formats all local and international Australia/New Zealand phone numbers without a hitch. It's awesome.
Building custom inputs like this delights users and helps them avoid making mistakes when entering data, and once you've got over the hump of building your first one the next one is easy. I encourage you to take a look over the software you're building and identify areas where you can spruce up your inputs.
- Actually, my Apple Watch unlocked it for me--so I didn't need to type in a password. If you can avoid taking input directly from the user, that's generally the superior approach as it completely removes any possible room for user error.↩
- This approach won't work for all locales. Countries such as Finland use a comma instead of a period as the decimal separator which means that you need to add some additional logic to handle the user's settings. The
Intl
object can be used for this purpose.↩