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.
Enforcing a Minimum and Maximum Span
The ordering rule is often only half the requirement. A hotel may demand at least a one-night stay, a trial subscription may cap the range at 30 days, and a reporting tool may refuse ranges wider than a fiscal year to keep queries cheap. These are still cross-field rules — the constraint lives in the distance between the two endpoints — so they belong in the same comparator rather than in two disconnected per-field checks.
Compute the span in whole days from the ISO strings and layer the bounds on top of the ordering test. Because both values are YYYY-MM-DD, constructing dates with the Date.UTC epoch avoids the daylight-saving hour drift that would otherwise make a “7-day” span occasionally measure 6.96 days and round wrong.
const MS_PER_DAY = 86_400_000;
const MIN_NIGHTS = 1;
const MAX_NIGHTS = 30;
/** Whole-day span between two ISO 'YYYY-MM-DD' strings, DST-safe. */
function spanInDays(startIso: string, endIso: string): number {
const [sy, sm, sd] = startIso.split('-').map(Number);
const [ey, em, ed] = endIso.split('-').map(Number);
// Date.UTC sidesteps local-zone offsets, so the divisor is exact.
const start = Date.UTC(sy, sm - 1, sd);
const end = Date.UTC(ey, em - 1, ed);
return Math.round((end - start) / MS_PER_DAY);
}
function rangeMessage(startIso: string, endIso: string): string | null {
if (startIso > endIso) return 'The start date must be on or before the end date.';
const nights = spanInDays(startIso, endIso);
if (nights < MIN_NIGHTS) return `The stay must be at least ${MIN_NIGHTS} night.`;
if (nights > MAX_NIGHTS) return `The stay cannot exceed ${MAX_NIGHTS} nights.`;
return null; // null means the pair is valid
}
Returning a nullable message rather than a boolean lets the caller feed a specific string into setCustomValidity for each distinct failure, so a screen-reader user hears “at least 1 night” instead of a vague “invalid range.” Wire it into the existing validateRange by replacing the start > end test with const message = rangeMessage(start, end) and branching on whether message is null.
Mirroring the Bound to Native min/max
Scripting the comparator does not mean abandoning the browser’s built-in enforcement. On every change, copy the committed value of one field into the other’s constraint attribute — set endInput.min = startInput.value and startInput.max = endInput.value. The date picker then greys out impossible days before the user can even select them, turning an error you must report into an interaction you prevent.
Keep the JavaScript comparator as the source of truth regardless. Native min/max on type="date" is unenforced when the field is empty, is trivially bypassed by pasting or by programmatic value changes, and produces a generic rangeUnderflow/rangeOverflow message you cannot customize. Treat the mirrored attributes as a UX affordance that narrows the target, and keep setCustomValidity as the authoritative gate that runs on submit.
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.
How do I compare dates that include a time, not just a day?
Switch the inputs to type="datetime-local", which also yields a lexically sortable string
(YYYY-MM-DDTHH:mm), so the same raw string comparison stays exact within a single time zone.
Only parse to Date if you must compare values that were captured in different offsets, and
then normalise both to UTC before comparing so a 09:00 start in one zone is not judged against a 09:00 end
in another.
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.