Validating a Date Range So the Start Date Falls Before the End Date
A date range is invalid the moment the chosen start date is later than the end date, yet a naive per-field validator never catches it because each input is individually well-formed — this recipe enforces the cross-field rule, updates the validity of both inputs, and announces the failure accessibly so the user knows exactly which date to fix.
When to Use This Recipe
Use this whenever two date inputs are logically coupled and one must not exceed the other: booking check-in/check-out, report date ranges, subscription start/end, project timelines, or any “from / to” pair. The defining characteristic is that neither field is wrong in isolation — the error lives in the relationship between them, which is exactly the situation covered by Cross-Field Validation Strategies.
Choose this pattern when:
- Both endpoints are user-editable (if the end date is fixed, a simple
minattribute is enough). - Editing either field can resolve or introduce the error, so both must be re-validated on each change.
- You need the failure to be programmatically announced, not just colored red — the same accessibility bar applied to cross-field password confirmation.
If you only have a single date with a static lower or upper bound, prefer the native min/max attributes documented in HTML5 Input Types & Attributes — no scripting required.
Minimal Working Implementation
Parse both values into Date objects (or compare the ISO strings directly, since <input type="date"> yields lexicographically sortable YYYY-MM-DD), then write the cross-field verdict onto both fields with setCustomValidity. Following the site’s <form novalidate> house style, the form’s own submit handler calls reportValidity() to surface anything left unresolved.
const form = document.querySelector<HTMLFormElement>('#range-form')!;
const startInput = form.querySelector<HTMLInputElement>('#start-date')!;
const endInput = form.querySelector<HTMLInputElement>('#end-date')!;
const liveRegion = form.querySelector<HTMLElement>('#range-error')!;
const MESSAGE = 'The start date must be on or before the end date.';
function validateRange(): boolean {
const start = startInput.value;
const end = endInput.value;
// Don't flag an incomplete pair — only validate once both are present.
if (!start || !end) {
clearRangeError();
return true;
}
// ISO 'YYYY-MM-DD' strings sort correctly, so a string compare is exact.
const invalid = start > end;
if (invalid) {
// Mark BOTH inputs so either can carry focus and an inline message.
startInput.setCustomValidity(MESSAGE);
endInput.setCustomValidity(MESSAGE);
startInput.setAttribute('aria-invalid', 'true');
endInput.setAttribute('aria-invalid', 'true');
liveRegion.textContent = MESSAGE; // announced via aria-live="assertive"
} else {
clearRangeError();
}
return !invalid;
}
function clearRangeError(): void {
startInput.setCustomValidity('');
endInput.setCustomValidity('');
startInput.setAttribute('aria-invalid', 'false');
endInput.setAttribute('aria-invalid', 'false');
liveRegion.textContent = '';
}
// Re-validate the pair whenever EITHER endpoint changes.
startInput.addEventListener('change', validateRange);
endInput.addEventListener('change', validateRange);
form.addEventListener('submit', (e) => {
if (!validateRange() || !form.reportValidity()) {
e.preventDefault();
// Focus the start field so the user lands on the editable cause.
(startInput.validationMessage ? startInput : endInput).focus();
}
});
The accessible markup pre-renders the message container so aria-describedby always points at a stable id and there is no layout shift when the error appears:
<form id="range-form" novalidate>
<label for="start-date">Start date</label>
<input type="date" id="start-date" aria-describedby="range-error" aria-invalid="false" />
<label for="end-date">End date</label>
<input type="date" id="end-date" aria-describedby="range-error" aria-invalid="false" />
<p id="range-error" class="error-message" role="alert" aria-live="assertive"></p>
<button type="submit">Save range</button>
</form>
Parameter & Option Reference
| Parameter / Option | Type | Default | Purpose |
|---|---|---|---|
| comparison operator | > vs >= |
> |
> allows a same-day range (start equals end); switch to >= to require a strictly later end. |
| value source | ISO string vs Date |
ISO string | <input type="date"> returns sortable YYYY-MM-DD; parse to Date only if you need time-of-day or arithmetic. |
setCustomValidity target |
both inputs | both | Marking both lets focus and inline messaging attach to either field. |
aria-live politeness |
polite / assertive |
assertive |
A relationship error blocks progress, so assertive is appropriate; use polite if you validate live on every keystroke. |
| trigger event | change / input |
change |
change fires when the picker commits a date; use input only if users type the date manually and you want live feedback. |
| empty-pair handling | boolean |
treat as valid | Avoids flagging the range before the user has filled both endpoints. |
Verification Steps
A Playwright check that both inputs report the same custom validity:
test('inverted range blocks both fields', async ({ page }) => {
await page.getByLabel('Start date').fill('2026-08-10');
await page.getByLabel('End date').fill('2026-08-01');
await page.getByRole('button', { name: 'Save range' }).click();
for (const label of ['Start date', 'End date']) {
await expect(page.getByLabel(label)).toHaveJSProperty(
'validationMessage',
'The start date must be on or before the end date.',
);
}
});
Edge Cases & Failure Modes
Stale validity after the user fixes one field. Because both inputs carry setCustomValidity(MESSAGE), fixing one field but forgetting to re-run the comparator leaves the other field still flagged, blocking submit with no visible cause. The fix is to bind the validator to the change event of both inputs (as above) and always clear validity on both in the valid branch, never just on the field that changed.
Time zones and Date parsing. If you convert the strings with new Date('2026-08-10'), the value is parsed as UTC midnight, which can shift a day in negative-offset zones and make an equal range look inverted. Comparing the raw ISO strings sidesteps this entirely; reach for Date only when you genuinely need duration math, and then construct local dates explicitly.
Equal dates rejected by accident. A single-day booking has identical start and end values. Using start >= end silently rejects that legitimate case. Default to > so same-day ranges pass, and only tighten to >= when the domain truly forbids a zero-length range.
Frequently Asked Questions
Why mark both inputs invalid instead of just the end date?
The error is a property of the pair, not one field, and the user might choose to fix it by moving the
start date earlier rather than the end date later. Marking both keeps either field eligible to receive
focus and an inline message, and ensures reportValidity() surfaces the problem regardless of
which input the user reaches first.
Can I do this with native attributes alone?
Partly. You can set the end input's min attribute to the current start value (and the
start input's max to the end value) on every change, which lets the browser enforce the
bound. But you still need a script to keep those attributes in sync, so a single comparator that writes
setCustomValidity on both fields is clearer and gives you full control over the message.
Should I validate on every keystroke or only on change?
For a native date picker, change is ideal because the value only commits once a full date
is selected. If users type the date by hand you may prefer input for live feedback, but then
switch the live region to aria-live="polite" so a half-typed year does not trigger a barrage
of assertive announcements.
Related Guides
- Cross-Field Validation Strategies — the parent pattern for rules that span two or more inputs.
- Cross-Field Password Confirmation Logic — the same dual-field validity and live-region technique applied to a password match.
- HTML5 Input Types & Attributes — native
min/maxbounds for the simpler single-date case. - Focus Management After Validation Failure — routing focus to the right endpoint when the range is rejected.