The date-range rule is simple to state and easy to get wrong in the UI: the error appears on the end date, the user fixes it by moving the start date earlier, and the message stays on the end date because only the start field was revalidated.
A range is a relationship between two fields, so it is a group-level rule in the sense of modelling form-level vs field-level errors, and it must be re-evaluated when either member changes. This page, within cross-field dependency logic, implements the rule with duration limits, guides the native pickers with min and max, and places the message where users will look for it.
Context and prerequisites
Decisions to make before writing code:
- Inclusive or exclusive end? A hotel stay’s check-out may equal check-in plus one night and cannot equal check-in; a leave request can start and end on the same day. Decide whether
end === startis valid. - Minimum and maximum duration? “At least 1 night”, “no more than 30 days”, “within the next 12 months”.
- Calendar dates or instants? Most ranges in forms are calendar dates (
YYYY-MM-DD), which compare as strings with no time zone involved — see validating dates across time zones. Ranges with times (a meeting from 14:00 to 15:30) are instants in a named zone. - Where does the error go? Usually on the end date — it is what people adjust — but described on the group so it is heard whichever field has focus.
The core pattern: one range rule, two triggers, guided pickers
type Day = string; // "YYYY-MM-DD"
export interface RangeRule {
allowSameDay: boolean;
minDays?: number;
maxDays?: number;
labels: { start: string; end: string }; // "check-in", "check-out"
}
// Whole days between two calendar dates, computed in UTC so no zone can shift it.
const daysBetween = (a: Day, b: Day) =>
Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 86_400_000);
export function validateRange(start: Day | "", end: Day | "", r: RangeRule):
{ field: "start" | "end"; message: string } | null {
if (!start) return { field: "start", message: `Enter a ${r.labels.start} date.` };
if (!end) return { field: "end", message: `Enter a ${r.labels.end} date.` };
const days = daysBetween(start, end);
if (days < 0 || (days === 0 && !r.allowSameDay)) {
return { field: "end", message: `${cap(r.labels.end)} must be after ${r.labels.start}.` };
}
if (r.minDays !== undefined && days < r.minDays) {
return { field: "end", message: `${cap(r.labels.end)} must be at least ${r.minDays} day(s) after ${r.labels.start}.` };
}
if (r.maxDays !== undefined && days > r.maxDays) {
return { field: "end", message: `Stays can be up to ${r.maxDays} nights. Choose an earlier ${r.labels.end} date.` };
}
return null;
}
const cap = (s: string) => s[0].toUpperCase() + s.slice(1);
// Wiring: run the SAME rule when either input changes, and guide the pickers.
export function wireRange(startEl: HTMLInputElement, endEl: HTMLInputElement, r: RangeRule,
show: (e: ReturnType<typeof validateRange>) => void) {
const run = () => {
// Guide the native picker: end cannot be before start (+1 if same day not allowed).
if (startEl.value) {
const minEnd = addDays(startEl.value, r.allowSameDay ? 0 : Math.max(1, r.minDays ?? 1));
endEl.min = minEnd;
if (r.maxDays !== undefined) endEl.max = addDays(startEl.value, r.maxDays);
}
show(validateRange(startEl.value as Day, endEl.value as Day, r));
};
startEl.addEventListener("change", run);
endEl.addEventListener("change", run);
return () => { startEl.removeEventListener("change", run); endEl.removeEventListener("change", run); };
}
function addDays(d: Day, n: number): Day {
const t = new Date(Date.parse(`${d}T00:00:00Z`) + n * 86_400_000);
return t.toISOString().slice(0, 10);
}
<fieldset aria-describedby="stay-error">
<legend>Your stay</legend>
<label for="checkin">Check-in</label>
<input id="checkin" name="checkin" type="date">
<label for="checkout">Check-out</label>
<input id="checkout" name="checkout" type="date" aria-describedby="stay-error">
<p id="stay-error" class="group-error"></p>
</fieldset>
Step-by-step walkthrough
- Write the rule once, over both values.
validateRange(start, end, rule)decides validity and which field the message belongs to. Neither field’s own validator knows about the other. - Run it when either field changes. Wire
changeon both inputs (or declare both as dependencies in your form library) so fixing the range from either side clears the error — the general mechanism in revalidating dependent fields when a source changes. - Compute days in UTC from calendar strings. Parsing
YYYY-MM-DDT00:00:00Zon both sides makes daylight-saving changes irrelevant to the count. - Guide the picker with
minandmax. Setting the end input’sminfrom the start stops most invalid choices before they happen, and native pickers grey out unavailable days. - Put the message on the group, reference it from the end field. The fieldset describes the error for anyone entering the group, and the end field — the usual thing to change — also references it.
- Respect touch timing for missing values. “Enter a check-out date” belongs after the field is touched or on submit, not the moment the start date is chosen.
Why the error usually belongs to the end date
When a range is invalid, either date could be the “wrong” one, but users almost always treat the start as the anchor: they choose when something begins and then how long it lasts. Placing the message under the end date matches that mental model, and it is where the fix is usually made. The exception is when the start is invalid on its own — in the past, or beyond a booking window — in which case the error is a field error on the start, separate from the range rule. Keeping those two kinds of error distinct avoids a single message trying to describe two different problems.
Failure modes and edge cases
1. Validating only the edited field
If the range rule lives in the end field’s validator, changing the start never re-runs it. Attach the rule to the group, or register the start as a dependency of the end.
2. min not updated when start clears
If the user clears the start date, remove min/max from the end input; stale constraints make a valid end date appear invalid in the picker.
3. Constraints versus messages
min on the end input triggers native rangeUnderflow. If your form uses novalidate with custom messages, map rangeUnderflow to the same range message so users do not see two different sentences for one problem, as in using the Constraint Validation API with custom form state.
4. Schemas
In Zod, the rule is a superRefine on the object that owns both fields, adding the issue at path: ["end"]. Keep the rule function shared so the schema and the live UI give identical messages.
5. Ranges with times
“14:00 to 13:30” on the same day is invalid; “23:00 to 01:00” might mean overnight. Decide whether an end time earlier than the start implies the next day, and show the resolved end date to the user if it does.
Verification checklist
Frequently Asked Questions
Should I auto-adjust the end date when the start changes?
Adjusting silently is surprising; users check the end date and find it changed. A reasonable middle ground: if the end date is empty, pre-select start plus the typical duration; if it is set and now invalid, show the message and let the user choose.
Should a date range use one picker or two inputs?
Range pickers with a two-month calendar are convenient for pointer users but are often hard to operate by keyboard and screen reader. Two labelled date inputs with a clear group label are the more accessible default; a range picker can enhance them.
Where should "start date must be in the future" go?
That is a field-level rule on the start date, independent of the end. Show it under the start field; the range message is only for the relationship between the two.