SSCF - Single Standard for Contact Forms

SSCF - Single Standard for Contact Forms
Standards

38

16 min.

Definitions

The SSCF standard defines requirements for the semantics, accessibility, and logic of contact forms.

Structure and Semantics

Form element and grouping fields

  • Use the native form element to collect and submit related data. It provides form semantics, keyboard behavior, and a browser submission mechanism.
  • Group related fields with fieldset and legend. Use fieldset only when fields form a meaningful logical group; there is no need to wrap every field individually.
  • legend names the group and is placed as the first child of fieldset.
  • To submit data using POST, set method="post" explicitly on form and provide the handler address through action. For forms that include files, also set enctype="multipart/form-data".
  • Integration secrets and third-party service keys must not be placed in client-side code. Send data to a secure server-side handler, which validates it and communicates with external systems.

Names and attributes of form controls

  • Give every submitted field a unique id and a meaningful name. id associates the field with its label; name identifies the field in the submitted data set.
  • Set an appropriate type on input, such as text, email, tel, checkbox, file, number, or range. select and textarea do not have a type attribute; their element determines the interaction type.
  • Add required only when the scenario genuinely requires the field. Do not use it for optional fields.
  • Set autocomplete to appropriate tokens, such as name, email, tel, given-name, or family-name. This helps the browser offer saved values and identifies the purpose of the field.
  • placeholder is optional. It can show a short format example, but it does not replace a persistent label or serve as the only explanation of a field.
  • Associate a visible label with a field using matching for and id values, or wrap the field in the label. Activating the label moves focus to its associated control.

Example of a semantic form structure:

<form action="/en/contact" method="post" enctype="multipart/form-data" data-contact-form>
  <fieldset>
    <legend>Contact details</legend>

    <div class="form-field">
      <label for="name">Name</label>
      <input
        id="name"
        name="name"
        type="text"
        autocomplete="name"
        placeholder="For example, Anna"
        required
      />
    </div>

    <div class="form-field">
      <label for="email">Email address</label>
      <input
        id="email"
        name="email"
        type="email"
        autocomplete="email"
        placeholder="name@example.com"
        required
      />
    </div>

    <div class="form-field">
      <label for="message">Message</label>
      <textarea
        id="message"
        name="message"
        placeholder="Briefly describe your question"
        required
      ></textarea>
    </div>
  </fieldset>

  <div class="form-consent">
    <label class="form-consent__label">
      <input id="data-consent" name="dataConsent" type="checkbox" required />
      <span>Consent to data processing</span>
    </label>
    <a href="/en/policy" target="_blank" rel="noopener noreferrer">
      Data processing terms (opens in a new tab)
    </a>
  </div>

  <button class="form-submit" type="submit">Submit</button>
  <p role="status" aria-live="polite" data-form-status></p>
</form>

The checkbox and its label text are wrapped in label, so activating the text also toggles the control. The a element is intentionally outside the label: the link remains a separate interactive element. Open an internal page in a new tab only when there is a reason to do so, and make that behavior clear. Set rel="noopener noreferrer" when using target="_blank".

Field States and Validation

Visual states

  • The default state shows a regular field without error or active styling.
  • The focus state is visibly distinct and preserves a visible indicator for keyboard navigation.
  • Do not indicate an error through color alone: provide a text message and, for custom validation, set aria-invalid="true" on the field.
  • Associate the error with its field using aria-describedby. State the problem and how to correct it, such as the required email format.
  • Do not set aria-invalid="true" before an error has actually been identified. Update or remove the state after the value is corrected.
  • A success state does not need to be shown on every field. If shown, communicate its meaning with text or an additional indicator, not only a green border.

Example of a field with an error message:

<div class="form-field form-field--error">
  <label for="email">Email address</label>
  <input
    id="email"
    name="email"
    type="email"
    autocomplete="email"
    aria-invalid="true"
    aria-describedby="email-error"
    required
  />
  <p id="email-error">Enter an email address in a valid format.</p>
</div>

When to validate and whether to disable submission

  • Use native HTML constraints such as required, type="email", min, max, and pattern when they fit the scenario. Add custom validation only for rules that are actually needed.
  • Client-side validation provides immediate feedback, but repeat all important checks on the server: client data can be modified or submitted without using the interface.
  • Avoid showing an error before the user has interacted with a field, moved focus away from it, or tried to submit the form. This prevents errors from appearing for fields that have not yet been completed.
  • Simple local checks usually run during input, on blur, or on submission without an extra delay. A debounce of about 300 ms is appropriate for expensive or network-based checks after typing pauses; it does not replace immediate validation on blur or before submission.
  • By default, keep the submit button available so the user can trigger validation and learn what needs correction. Disabling it for every invalid value can hide why it is unavailable and make the form harder to fix.
  • If a project deliberately disables the button until conditions are met, explain those conditions nearby and keep the fields and errors available for correction. disabled does not replace error messages.
  • When submission is attempted with multiple errors, show a clear summary or field-level messages; when needed, move focus to the first invalid field.

Safari and autofill

  • In Safari on iPhone, set the text size of the focused form control itself to at least 16px to avoid automatic page zoom on focus.
  • Do not disable page zoom through viewport restrictions. Users must retain the ability to zoom using browser controls.
  • Do not disable autofill without a specific reason. Set accurate autocomplete values for personal data, such as name, email, and tel.
  • If the browser's autofill styling conflicts with the design, set readable foreground and background colors with sufficient contrast for :autofill and :-webkit-autofill. Do not hide the system style at the cost of legible content or focus.

Base field and autofill styles:

.form-field input,
.form-field select,
.form-field textarea,
.phone-field input {
  font-size: 16px;
}

.form-field input:focus-visible,
.form-field select:focus-visible,
.form-field textarea:focus-visible,
.phone-field input:focus-visible {
  outline: 2px solid #2457d6;
  outline-offset: 2px;
}

.form-field [aria-invalid="true"] {
  border-color: #b42318;
}

.form-field input:autofill,
.form-field input:-webkit-autofill,
.phone-field input:autofill,
.phone-field input:-webkit-autofill {
  -webkit-text-fill-color: #161616;
  box-shadow: 0 0 0 1000px #fff inset;
}

Replace the example colors with interface tokens whose contrast has been checked. The 16px size applies to the text inside the field; changing the label or placeholder size does not change the input text size.

Field Types and Mobile Input

Phone number

  • Use type="tel" with autocomplete="tel" for a phone number. inputmode="tel" can also suggest an appropriate on-screen keyboard on mobile devices.
  • International phone numbers do not have one fixed digit length. Validation that requires “at least 11 digits” or “exactly 11 digits” is not correct across countries and formats.
  • Validate a number against the selected country's numbering plan, and normalize it to an agreed format such as E.164 for transmission. A mask controls presentation; it does not prove that a number exists or is reachable.
  • Repeated digits alone are not a reliable indicator of an invalid number. Do not use such heuristics as a universal input ban; handle spam protection separately on the server.
  • Changing the selected country updates both the dial code and the display format. Typing or pasting an international number must not lose digits or duplicate the country code.
  • Do not universally block + or country-code digits from manual entry: users may type or paste an international number. If the selected country code is fixed in the field, normalize a repeated code instead of adding it twice.
  • If the selected country code cannot be edited, place the initial caret position after the code while preserving the ability to select and replace the number, paste an international number, and change the country. This refers to caret placement on focus, not automatically setting autofocus when the form loads. For Russia, the national prefix 8 and international prefix +7 are not interchangeable masks; normalize input to the selected format.
  • The initial country can come from user settings, the site locale, or an explicit user choice. IP-based detection requires a separate service, can be inaccurate, and must not prevent manual country selection or number entry.
  • Preserve the expected caret position when typing, pasting, selecting, or deleting digits in the middle of a number. Do not manually move the caret after every input event when the library already manages formatting.
  • Entering a phone number does not prove ownership. Verifying reachability or ownership requires a separate process, such as a verification code.

React implementation used in the project

In the React project, use react-international-phone instead of @react-input/mask for an international phone field. The supplied useMask({ mask, replacement }) example belongs to @react-input/mask and is not the phone library's API. Use PhoneInput, which formats the number, supports country selection, and manages caret position. IP-based country detection is not built into the package and does not validate a number or replace user choice.

import { useState } from 'react';
import { PhoneInput } from 'react-international-phone';
import 'react-international-phone/style.css';

export const PhoneField = () => {
  const [phone, setPhone] = useState('');

  return (
    <div className="phone-field">
      <label htmlFor="phone">Phone</label>
      <PhoneInput
        defaultCountry="ru"
        forceDialCode
        value={phone}
        onChange={(phone) => setPhone(phone)}
        inputProps={{
          id: 'phone',
          name: 'phone',
          type: 'tel',
          autoComplete: 'tel',
          inputMode: 'tel',
          required: true,
        }}
      />
    </div>
  );
};

react-international-phone handles input and formatting, but its current documentation no longer presents it as a phone-number validation library. Use a dedicated numbering-plan library, such as google-libphonenumber, together with server-side validation.

import { PhoneNumberUtil } from 'google-libphonenumber';

const phoneUtil = PhoneNumberUtil.getInstance();

const isValidPhone = (phone) => {
  try {
    return phoneUtil.isValidNumber(phoneUtil.parseAndKeepRawInput(phone));
  } catch (error) {
    return false;
  }
};

Checking format and numbering-plan validity does not confirm that a number is active or belongs to the submitter.

Select, textarea, and file uploads

  • Use a native <select> by default. It provides built-in keyboard and platform support.
  • Use a custom list only when a native <select> cannot meet a real requirement. Implement an accessible name, expanded and selected states, keyboard interaction, and predictable focus management using the appropriate ARIA pattern.
  • Preserve the ability to resize a <textarea> unless it breaks the layout. If vertical resizing needs to be constrained, use resize: vertical or another justified limit; unconditional resize: none can make long text harder to enter.
  • Define the allowed file types, maximum size, and number of files. The accept attribute only suggests files in the file picker; it is not a security check.
  • Revalidate file type and size on the server. Do not trust only a MIME header or filename; restrict allowed extensions and handle filenames and storage safely.
  • After selection, show a list of attachments with each filename and size. Where needed, show upload progress, errors, and a way to remove each file.

Range slider

  • Use a native <input type="range"> to select a value within a range when its behavior fits the task. It already provides semantics, focus, keyboard interaction, and pointer input.
  • A related type="number" field can be provided when users need to enter an exact value. Keep the range, step, and current value synchronized from one source of state.
  • For a “slider + number field” pair, submit one canonical value. Do not accidentally include the same parameter twice in the form data.
  • Give the slider an accessible label and meaningful min, max, step, and initial value values. Show the current value as text when the slider position alone is not clear enough.
  • A native slider supports keyboard and pointer input. A custom control must separately implement pointer, touch, keyboard, focus, accessible naming, and value exposure to assistive technology.
  • cursor: pointer can indicate that the slider is interactive. Use cursor: grab and cursor: grabbing only when the control actually supports dragging; the cursor does not replace touch or keyboard support.
  • A native control handles track clicks according to the browser implementation. A custom slider must move to the chosen point with both mouse and touch without interfering with page scrolling.

Example of a paired numeric field and slider:

<div class="form-field">
  <label for="budget-range">Budget</label>
  <input
    id="budget-range"
    type="range"
    min="0"
    max="100000"
    step="5000"
    value="50000"
    aria-describedby="budget-current-value"
  />
  <output id="budget-current-value" for="budget-range">50,000 USD</output>

  <label for="budget-value">Exact amount, USD</label>
  <input
    id="budget-value"
    name="budget"
    type="number"
    min="0"
    max="100000"
    step="5000"
    value="50000"
    inputmode="numeric"
    required
  />
</div>

Both controls update the same interface value. Only the numeric field has a name in this example, so the value is submitted once.

Example of synchronization using native controls:

const rangeInput = document.querySelector('#budget-range');
const numberInput = document.querySelector('#budget-value');
const budgetOutput = document.querySelector('#budget-current-value');

const formatBudget = (value) => new Intl.NumberFormat('en-US').format(Number(value)) + ' USD';

const updateBudgetOutput = () => {
  budgetOutput.value = formatBudget(rangeInput.value);
};

rangeInput.addEventListener('input', () => {
  numberInput.value = rangeInput.value;
  updateBudgetOutput();
});

numberInput.addEventListener('change', () => {
  if (!numberInput.validity.valid) return;

  rangeInput.value = numberInput.value;
  numberInput.value = rangeInput.value;
  updateBudgetOutput();
});

The slider updates the numeric field while it moves. A typed number is synchronized with the slider after input is committed and only when it passes the min, max, and step constraints.

input[type="range"] {
  cursor: pointer;
}

input[type="range"]:active {
  cursor: grabbing;
}

Accessibility and Keyboard Interaction

  • All form controls are available in a predictable order using Tab and Shift+Tab; do not remove the visible focus indicator.
  • A native <select> follows the keyboard conventions of the operating system and browser. A custom list must implement a consistent keyboard model, including moving between options and selecting one.
  • Pressing Space toggles a focused checkbox. The native control already exposes its state and does not need duplicate ARIA attributes.
  • Every field needs an accessible name, usually provided by <label>. Use aria-label or aria-labelledby only when there is no visible label or it is insufficient.
  • Add ARIA to supply a missing name, state, or relationship—not because every input and button needs an ARIA attribute. Prefer native semantics over restating a role.
  • Make errors distinguishable by more than color. Keep the message visible and programmatically associated with its field; when needed, announce form-level changes without moving focus unexpectedly.
  • Button text normally provides its accessible name. Give icon-only buttons a clear name using aria-label or aria-labelledby.
  • A checkbox can be wrapped together with its label text in <label> so the whole caption toggles it. Place a policy link outside the label to avoid nesting an interactive link inside it.
  • Whether a checkbox is required depends on the form scenario and product requirements; a policy link alone does not mean that every consent checkbox should be mandatory.
  • Keep error and success messages concise, clear, and easy to find. A separate role="status" region with aria-live="polite" is suitable for dynamic status updates.

Submission and Result States

Submission states

  • Before submission, the button has a clear action label such as “Submit.”
  • Prevent duplicate submissions while a network request is in progress. The button may temporarily receive disabled, and the form state can be exposed with aria-busy="true".
  • Show the operation status separately from the button, for example in a role="status" region. Changes to text inside a disabled button are not always reliably announced by assistive technologies.
  • After confirmed success, show a message such as “Form submitted.” Reset the form only after server confirmation; preserve entered values on failure unless the user requests clearing them.
  • Do not automatically hide the success message after an arbitrary five seconds. If the button returns to its initial label on a timer, keep the message available long enough to be read.
  • Hiding a status message automatically is not spam protection. Rate limits, duplicate-request protection, and abuse checks belong on the server.

If the button is actually disabled, make its visual state distinct. Cursor and opacity are only visual cues; the reason submission is unavailable must still be understandable in text.

.form-submit:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}

Server-side processing and integrations

  • Validate all fields on the client before submission and repeat the same critical checks on the server. The server must not trust validity flags, disabled values, or interface constraints.
  • Send data using POST over HTTPS. Apply CSRF protection, rate limiting, idempotency, and other safeguards according to the threat model and how the handler is authenticated.
  • If data must be delivered to multiple systems, the browser sends it to the application's own server-side handler. Do not include Bitrix, Directus, Telegram, or other integration secrets in page JavaScript.
  • Promise.allSettled() reports the results of several independent promises, but it does not make them an atomic transaction or undo completed actions after a partial failure. The server defines what counts as accepted, how failed deliveries are retried, and how duplicates are prevented.
  • Report the outcome of the primary action to the user. Show success only after the server confirms the system's acceptance criterion, such as saving the request or reliably placing it in a queue.
  • On failure, explain the next step without clearing entered values. Associate any field-level errors returned by the server with the relevant controls.

Example of submitting a form to a server-side handler:

const form = document.querySelector('[data-contact-form]');
const submitButton = form.querySelector('[type="submit"]');
const formStatus = form.querySelector('[data-form-status]');
let isSubmitting = false;

form.addEventListener('submit', (event) => {
  event.preventDefault();

  if (isSubmitting || !form.reportValidity()) return;

  isSubmitting = true;
  submitButton.disabled = true;
  form.setAttribute('aria-busy', 'true');
  formStatus.textContent = 'Submitting…';

  fetch(form.action, {
    method: 'POST',
    body: new FormData(form),
    headers: { Accept: 'application/json' },
  })
    .then((response) => {
      if (!response.ok) throw new Error('Form submission failed');

      form.reset();
      formStatus.textContent = 'Your form was submitted successfully.';
    })
    .catch(() => {
      formStatus.textContent = 'The form could not be submitted. Check the connection and try again.';
    })
    .then(() => {
      isSubmitting = false;
      submitButton.disabled = false;
      form.removeAttribute('aria-busy');
    });
});

This example assumes that the markup contains data-contact-form, a submit button, and a status region. A production handler also needs to handle server errors, timeouts, and retries; client-side code must not call external integrations directly.

Testing

  • Verify that the form uses <form> correctly and that <fieldset> and <legend> group only related fields.
  • Give every field a persistent label, a unique id, an appropriate type where applicable, and a name if its value must be submitted.
  • Check native required constraints, field formats, autocomplete behavior, and the distinction between a label and an optional placeholder.
  • Make default, focus, and error states distinguishable; do not communicate an error through color alone, and associate it with explanatory text.
  • Avoid showing errors before interaction without a reason; validate at the appropriate events and again on submission.
  • Make it clear why submission did not proceed. A disabled button must not be the only indication of an error or required action.
  • Test mouse, touch, and keyboard operation, including Tab, Shift+Tab, Space for checkboxes, and keyboard behavior in native and custom selects.
  • On iPhone, verify that focusing a field does not automatically zoom the page and that touch devices show an appropriate keyboard.
  • Ensure autofilled values retain readable text, sufficient contrast, focus, and a visible field state.
  • Test phone numbers for the selected country and international format, including paste, deletion, caret movement, country changes, and numbers with repeated digits.
  • If IP-based country detection is used, test it as an approximate initial value with manual selection and a working fallback.
  • Test custom selects, the phone country selector, and custom sliders with a keyboard and assistive technology.
  • For files, test allowed types, size, count, list display, removal, server-side checks, and upload failure scenarios.
  • Keep the number field and slider synchronized during typing, dragging, boundary values, and step changes; submit the value in the agreed format.
  • During submission, test repeated activation, network and server errors, value preservation on failure, clearing after success, and status announcements.
  • If submission triggers multiple integrations, test partial failures and ensure retries do not create duplicates.

SSCF Checklist

CategoryMain requirementsPurpose
Semantics<form>, <fieldset>, <legend>, label, appropriate control typesClear structure and native behavior
DataUnique id values, name for submitted values, scenario-based requiredCorrect field submission
AutofillAccurate autocomplete tokens; placeholder used only as a hintFaster, clearer form completion
ValidationClient-side feedback and repeated server-side validationFind errors without trusting the client
StatesDefault, focus, error, loading, and result; do not rely on color aloneMake the form's current state understandable
PhoneCountry-aware formatting, normalization, separate validity checksSupport national and international numbers
Specialized fieldsNative select and range, accessible textarea, server-validated filesPredictable data entry
AccessibilityLabels, error associations, keyboard support, appropriate ARIA semanticsMake the form operable and understandable in different ways
SubmissionPOST to the server, protected integrations, partial-failure handlingReliable delivery and clear results

Similar categories:

Similar articles

  • Anatomy of a React component

    My vision for how every React component should look

    421

    8 min.

  • SST - Single Standard for Tabs

    The SST standard defines requirements for the semantics, accessibility, and logic of tabs

    62

    7 min.

  • SSF-U - Single Standard for Fullscreen

    The SSF-U standard defines requirements for the semantics, accessibility, and logic of fullscreen

    248

    2 min.

  • SSA - Single Standard for Accordion

    The SSA standard defines requirements for the semantics, accessibility, and logic of accordion

    258

    2 min.

  • Bad Practices for Websites

    An Analysis of Critical Web Design Mistakes. Why Sliders, Autoplay, and Slow-Loading Pages Reduce Conversion Rates and Rankings on Google

    232

    2 min.

  • SSP - Single Standard for Pagination

    The SSP standard defines requirements for the semantics, accessibility, and logic of pagination

    240

    1 min.

  • All articles

Contact me