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 === start is 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.
Range rules and example outcomes A start of 1 October and an end of 5 October is valid. An end of 1 October equal to the start fails the minimum of one night with check-out must be after check-in. An end of 28 September before the start fails with check-out must be after check-in. An end of 3 November, 33 nights later, fails the maximum with stays can be up to 30 nights. A missing end date fails with enter a check-out date, reported only after touch or submit. Start End Result 2026-10-01 2026-10-05 valid (4 nights) 2026-10-01 2026-10-01 Check-out must be after check-in. 2026-10-01 2026-09-28 Check-out must be after check-in. 2026-10-01 2026-11-03 Stays can be up to 30 nights. 2026-10-01 (empty) Enter a check-out date. (after touch/submit)

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

  1. 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.
  2. Run it when either field changes. Wire change on 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.
  3. Compute days in UTC from calendar strings. Parsing YYYY-MM-DDT00:00:00Z on both sides makes daylight-saving changes irrelevant to the count.
  4. Guide the picker with min and max. Setting the end input’s min from the start stops most invalid choices before they happen, and native pickers grey out unavailable days.
  5. 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.
  6. 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.

Fixing the range from the start date The user sets check-in to 10 October and check-out to 8 October. The range rule runs and reports check-out must be after check-in, shown on the group and linked from the check-out field. The user then changes check-in to 5 October instead of touching check-out. Because the rule runs on changes to either field, it re-evaluates, finds 3 nights, and clears the message. User Check-in Check-out Range rule set check-out 2026-10-08 (check-in 10th) change → run "Check-out must be after check-in." set check-in 2026-10-05 change → run (same rule) valid: 3 nights, error cleared

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.

Three places the rule shows up The picker constraints set min and max on the end date input from the start date, preventing most invalid choices. The live group message shows the rule's message on the fieldset, linked from the end input, and re-evaluates when either date changes. The schema refinement applies the same rule function at submit, on client and server, adding the issue to the end path. Picker constraints end.min / end.max from start. Prevents most mistakes. Live group message On the fieldset. Runs on either change. Schema refinement Same rule at submit. Issue at path ["end"].

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.


Related

← Cross-Field Dependency Logic