Anatomy of a React component
My vision for how every React component should look
421
8 min.
The SSCF standard defines requirements for the semantics, accessibility, and logic of contact forms.
form element to collect and submit related data. It provides form semantics, keyboard behavior, and a browser submission mechanism.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.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".id and a meaningful name. id associates the field with its label; name identifies the field in the submitted data set.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.required only when the scenario genuinely requires the field. Do not use it for optional fields.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.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".
aria-invalid="true" on the field.aria-describedby. State the problem and how to correct it, such as the required email format.aria-invalid="true" before an error has actually been identified. Update or remove the state after the value is corrected.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>required, type="email", min, max, and pattern when they fit the scenario. Add custom validation only for rules that are actually needed.300 ms is appropriate for expensive or network-based checks after typing pauses; it does not replace immediate validation on blur or before submission.disabled does not replace error messages.16px to avoid automatic page zoom on focus.autocomplete values for personal data, such as name, email, and tel.: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.
type="tel" with autocomplete="tel" for a phone number. inputmode="tel" can also suggest an appropriate on-screen keyboard on mobile devices.+ 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.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.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> by default. It provides built-in keyboard and platform support.<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.<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.accept attribute only suggests files in the file picker; it is not a security check.<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.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.min, max, step, and initial value values. Show the current value as text when the slider position alone is not clear enough.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.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;
}Tab and Shift+Tab; do not remove the visible focus indicator.<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.Space toggles a focused checkbox. The native control already exposes its state and does not need duplicate ARIA attributes.<label>. Use aria-label or aria-labelledby only when there is no visible label or it is insufficient.aria-label or aria-labelledby.<label> so the whole caption toggles it. Place a policy link outside the label to avoid nesting an interactive link inside it.role="status" region with aria-live="polite" is suitable for dynamic status updates.disabled, and the form state can be exposed with aria-busy="true".role="status" region. Changes to text inside a disabled button are not always reliably announced by assistive technologies.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;
}disabled values, or interface constraints.POST over HTTPS. Apply CSRF protection, rate limiting, idempotency, and other safeguards according to the threat model and how the handler is authenticated.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.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.
<form> correctly and that <fieldset> and <legend> group only related fields.id, an appropriate type where applicable, and a name if its value must be submitted.required constraints, field formats, autocomplete behavior, and the distinction between a label and an optional placeholder.Tab, Shift+Tab, Space for checkboxes, and keyboard behavior in native and custom selects.My vision for how every React component should look
421
8 min.
The SST standard defines requirements for the semantics, accessibility, and logic of tabs
62
7 min.
The SSF-U standard defines requirements for the semantics, accessibility, and logic of fullscreen
248
2 min.
The SSA standard defines requirements for the semantics, accessibility, and logic of accordion
258
2 min.
An Analysis of Critical Web Design Mistakes. Why Sliders, Autoplay, and Slow-Loading Pages Reduce Conversion Rates and Rankings on Google
232
2 min.
The SSP standard defines requirements for the semantics, accessibility, and logic of pagination
240
1 min.