tanghoong design
  1. Design system
  2. Forms

05 — Forms

Forms

Inputs, selects, choices and validation. Every field has a real label, every control clears 44 px, and every error is announced — not just coloured.

A placeholder is not a label It vanishes the moment someone types, it is invisible to most screen readers as a name, and it fails at low contrast by design. Every field on this page has a real <label for>. Use .vh to hide one visually if the layout truly implies it — never delete it.

Text fields

One shared shape for input, textarea and select. Wrap each in .field, which stacks label, control and hint with the right gaps.

Anatomy

We only use this to address the proposal.

A reply lands within one business day.

<div class="field">
  <label class="label" for="email">Work email</label>
  <input class="input" id="email" type="email"
         inputmode="email" autocomplete="email"
         aria-describedby="email-hint">
  <p class="hint" id="email-hint">A reply lands within one business day.</p>
</div>

Wire the hint with aria-describedby or a screen reader never reads it. Set inputmode and autocomplete — they are what give a phone the right keyboard and a one-tap fill.

Textarea and select

Textareas resize vertically only — horizontal resize breaks the layout. .textarea--code switches to the mono stack for anything the user will paste rather than write.

Input group — prefix and suffix
https:// .tanghoong.com
MYR

The group holds the border and the focus state; the input inside is borderless. That is what keeps the ring around the whole control rather than half of it.

Sizes and states

Validation

The hook is aria-invalid="true", not a class. The same attribute a screen reader uses to say "invalid entry" is the one that turns the field red, so the two can never drift apart.

Error state

Enter a full email address, including the domain.

<input class="input" id="email" type="email"
       aria-invalid="true" aria-describedby="email-err">
<p class="error" id="email-err">Enter a full email address.</p>

Say what to do, not what went wrong. "Enter a full email address" beats "Invalid input". Move focus to the first invalid field on submit, and never clear what the user typed.

Checkboxes and radios

The control keeps its native accent-color, so it inherits platform focus, forced-colors and high-contrast behaviour for free. What we style is the 44 px row around it.

Standard
Notify me about
Environment

Group related choices in a <fieldset> with a <legend>. Without it a screen reader reads three options with no idea what question they answer.

Boxed — a selectable card

The selected state uses :has(input:checked) — no JavaScript, and the styling can never fall out of sync with the actual checked state.

Switches

A switch applies immediately. A checkbox applies on submit. Choosing the wrong one is the most common form mistake, and users notice: they will wait for a Save button that never comes.

Switch
<label class="switch">
  <input type="checkbox" checked>
  <span class="switch__track"></span>
  <span class="sm">Auto-deploy on merge to main</span>
</label>

The input is visually hidden but still focusable and still in the tab order — that is what makes the switch keyboard-operable with Space.

Range and file

Range and file drop

Anything below this goes to the human review queue.

Form layout

One column by default — it is faster to complete and never ambiguous about reading order. Split to two only for genuinely paired fields (first/last name, city/postcode).

Two-column grid with a full-width span
Project enquiry

Tell me enough to reply usefully. Fifteen minutes on a call usually beats a long form.

Primary action first in the DOM, and it is the one the Enter key hits. .form-actions wraps on a narrow screen rather than squeezing the buttons.

Do Mark optional fields, not required ones — most fields are required, so the asterisks become noise and the reader stops seeing them.
Don't Validate on every keystroke. Validate on blur, and again on submit. Turning a field red while someone is still halfway through typing is hostile.