Register Form Design: 9 Keyboard-Friendly Inputs
Register form design lives or dies in the fields, not the submit button. These nine inputs cover everything a signup screen asks for, from the email box to the
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Input with icon or unit prefix/suffix
- 02 Floating label input
- 03 Date input with auto-slash
- 04 Phone input with country code select
- 05 Address autocomplete style input
- 06 Multi-select combobox
- 07 Mention and hashtag highlight input
- 08 Character counter input
- 09 Input with clear (X) button
The order follows the screen. The email box up top (01) says what it wants with an icon or a unit before you touch it, the name field (02) lifts its label out of the way to save space, and the birthdate (03) and phone number (04) reformat digits into the shape a person reads. Next come the fields you fill by picking from a list, the address (05) and the interests (06). The last three cover what happens while someone writes a bio: mentions and hashtags pick up color as you type (07), the character counter watches the limit (08), and the clear button empties the field in one tap when you want a do-over (09). The three that open a list (04, 05, 06) carry role="listbox" with aria-activedescendant, so the arrow keys genuinely work, and four of the nine answer Escape.
01Input with icon or unit prefix/suffix
An email icon sits on the left and a kg unit on the right, so each field announces what it wants before you type. On focus the 2px border, the icon, and the unit all shift to the same color, and the icon and unit carry aria-hidden="true" with pointer-events: none so they never steal the click.
&__input {
box-sizing: border-box;
width: 100%;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-4;
font-weight: 500;
font-size: clamp(14px, 4.2vw, 16px);
font-family: inherit;
color: $stage-ink;
background: #fff;
transition: border-color $duration $easing;
&--prefix { padding-left: $sp-10; }
&--suffix { padding-right: $sp-8; }
&:focus {
outline: none;
border-color: $color;
& ~ .affix__unit { color: $color; }
}
}
02Floating label input
In the empty field the label rests in the middle of the box; the moment the field takes focus or holds a character, that label lifts and shrinks with translateY(-14px) scale(.78). No JavaScript is involved — just the :focus and :not(:placeholder-shown) pseudo-classes plus the + sibling combinator, fed by a single placeholder=" " in the markup.
&__label {
position: absolute;
left: $sp-4;
top: 50%;
transform: translateY(-50%) scale(1);
transform-origin: left center;
font-size: 16px;
font-weight: 500;
font-family: inherit;
color: rgba(23, 20, 26, .48);
pointer-events: none;
transition: transform $duration $easing, color $duration $easing;
}
&__input:focus + &__label,
&__input:not(:placeholder-shown) + &__label {
transform: translateY(-14px) scale(.78);
color: $color;
}
03Date input with auto-slash
Type digits only and slashes appear after the fourth and sixth digits, so 2026/09/11 assembles itself. There is no calendar to open — the field is a plain text box with inputmode="numeric", which brings up the number pad on a phone, and anything past eight digits is dropped.
function format(raw) {
var digits = raw.replace(/\D/g, '').slice(0, 8);
var out = digits.slice(0, 4);
if (digits.length > 4) out += '/' + digits.slice(4, 6);
if (digits.length > 6) out += '/' + digits.slice(6, 8);
return out;
}
input.addEventListener('input', function () {
input.value = format(input.value);
});
04Phone input with country code select
Pick a country from the flag button and the dial code swaps while the number field rewrites itself in that country's digit grouping. South Korea's +82 groups as 3-4-4, the United States' +1 as 3-3-4, and Japan's +81 as 2-4-4, so the same ten digits read differently depending on where the number lives.
trigger.addEventListener('keydown', function (e) {
if (!open) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openListbox();
}
return;
}
if (e.key === 'ArrowDown') { e.preventDefault(); highlight((active + 1) % options.length); }
else if (e.key === 'ArrowUp') { e.preventDefault(); highlight((active - 1 + options.length) % options.length); }
else if (e.key === 'Enter') { e.preventDefault(); selectCountry(options[active].dataset.code); closeListbox(true); }
else if (e.key === 'Escape') { e.preventDefault(); closeListbox(true); }
else if (e.key === 'Tab') { closeListbox(false); }
});
05Address autocomplete style input
As you type, the suggestion list below filters five fixed addresses down to the matching rows, and the arrow keys plus Enter fill the field. The input carries role="combobox" with aria-autocomplete="list", and the rows come from a static list, so there's no API key to set up.
input.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!open && matches.length) { openList(); setActive(0); return; }
if (open && matches.length) setActive(Math.min(active + 1, matches.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (open) { if (active <= 0) setActive(-1); else setActive(active - 1); }
} else if (e.key === 'Enter') {
if (open && active > -1) { e.preventDefault(); selectOption(active, true); }
} else if (e.key === 'Escape') {
if (open) { e.preventDefault(); closeList(); setActive(-1); }
}
});
06Multi-select combobox
Four rows — design, dev, marketing, planning — stack every pick as a pill-shaped chip inside the field. The list carries aria-multiselectable="true" so a second pick never deselects the first, and each chip arrives through the chip-pop keyframes, overshooting slightly before settling at full size.
@keyframes chip-pop {
0% { transform: scale(.5); opacity: 0; }
70% { transform: scale(1.06); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.multi__listbox { transition: none !important; }
.multi__trigger, .multi__chevron, .multi__check { transition: none; }
.multi__chip { animation: none; }
}
07Mention and hashtag highlight input
The moment you type @name or #tag, that word alone picks up color while the rest of the sentence stays plain. There is no contenteditable here — a textarea with invisible text sits on a backdrop layer of the same width and wrapping rules, so the caret stays where it belongs.
&__backdrop,
&__input {
box-sizing: border-box;
width: 100%;
min-height: 96px;
max-height: 160px;
padding: $sp-3 $sp-4;
border: 2px solid transparent;
border-radius: $r-control;
font-weight: 500;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.5;
font-family: inherit;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
08Character counter input
The number in the corner climbs from 0/80, and once twelve characters remain, both the count and the border turn red. The limit itself lives in the HTML maxlength="80", and the count carries aria-live="polite", so someone who isn't watching the screen still hears the limit approach.
function update() {
var len = area.value.length;
var remaining = MAX - len;
count.textContent = len + '/' + MAX;
if (remaining <= WARN) {
count.classList.add('is-warn');
area.classList.add('is-warn');
} else {
count.classList.remove('is-warn');
area.classList.remove('is-warn');
}
}
area.addEventListener('input', update);
09Input with clear (X) button
Type one character and a round X button surfaces on the right; click it and the field empties while focus stays exactly where it was. With no value the button sits at opacity: 0 with pointer-events: none, fully out of the way, and it only reappears the moment there's something to clear.
&__clear {
position: absolute;
top: 50%;
right: $sp-2;
transform: translateY(-50%) scale(.5);
width: 24px;
height: 24px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, .08);
color: $stage-ink;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing, background $duration $easing;
&:hover, &:focus-visible { background: rgba(23, 20, 26, .16); }
}
Where it breaks — the trap
The break showed up at 320px wide, and it was vertical. Items 07 and 08 both hold a three-row textarea, and with the field's minimum height at 96px the whole card grew 2px taller than the stage in 07 and 3px taller in 08, clipping the bottom border. At the 480px demo size the slack had hidden the problem, so eyes on a wide screen alone could never have caught it. The fix was not to pull the textarea out of the flow but to add an @media (max-width: 340px) block that lowers min-height from 96px to 88px once the viewport narrows past that point. The label and the hint stayed untouched, and shaving those 8px off the field alone cleared the overflow in both items. The sources carrying this rule sit inside the zip, which opens with the archive password ttw779pg.
Accessibility
Under prefers-reduced-motion: reduce, all nine drop their transitions and keep every state value. Border and icon colors switch instantly, the floating label stands in its lifted position, and 06's chips appear without the chip-pop bounce. The three list-openers (04, 05, 06) announce openness through aria-expanded and move a virtual cursor with aria-activedescendant instead of moving focus, and 08's count is read out through aria-live="polite". Focus returns through focus({ preventScroll: true }) because these demos sit in iframes inside the article, and a bare focus() would drag the reader's page down toward them. The meaning of the input attributes used here is documented in the MDN input element reference.
More parts in the same vein live in the forms category, and the parts that shake on invalid input live in the error category.
FAQ
Why build a birthdate field instead of letting the browser open a calendar?
The native calendar is dependable, but it looks different in every browser, and for someone who types fast, opening and dismissing it is slower than typing. This field takes digits only and assembles them into the 2026/09/11 shape, so the format never drifts — no calendar required. On screens that do want a calendar, the typed field and a calendar picker can sit side by side.
Do all nine work without a mouse?
Yes. Items 01, 02, 03, 07, and 08 are ordinary inputs and textareas, so whatever the browser does for free is what they do. The list-openers 04, 05, and 06 move with the arrow keys, commit on Enter, and close on Escape; 04 and 06 wrap around at either end and close the list when you Tab away. In 09, pressing Escape in the field clears the value.
Can I change the limit or the colors in the React version?
The character limit in 08 is a max prop that defaults to 80. The warn threshold of twelve remaining characters is a constant inside the module, so changing it means editing that line. Colors and timing are inlined in each React module, so edit them where they appear; in the vanilla sources the same three knobs sit at the top of each style.scss — $duration, $easing, and $color.