Components Controls & Input DateRangePicker

DateRangePicker

Two-month range picker with start/end selection and live hover-range preview.

Live demo

01

Interactive playground

Click a start day, then an end day. Hover (or arrow-key focus) between clicks to preview the band.

July 2026 August 2026
July 2026
MoTuWeThFrSaSu
August 2026
MoTuWeThFrSaSu

Start: β€” Β· End: β€”

Pre-filled range

Opens with a committed selection β€” useful for editing an existing booking.

March 2026 April 2026
March 2026
MoTuWeThFrSaSu
April 2026
MoTuWeThFrSaSu

Start: 2026-03-08 Β· End: 2026-03-19

Bounded (min / max)

Only days inside the 5th–25th March window are selectable; everything else is disabled.

March 2026 April 2026
March 2026
SuMoTuWeThFrSa
April 2026
SuMoTuWeThFrSa

Start: β€” Β· End: β€”

Implementation

02
DateRangePicker.svelte
<script lang="ts">
  import DateRangePicker from '$lib/components/DateRangePicker.svelte';
  let range = $state<{ start: string | null; end: string | null }>({ start: null, end: null });
</script>
​
<DateRangePicker bind:value={range} min="2026-01-01" max="2026-12-31" />

The picker keeps the start and end as a single bindable { start, end } object of ISO strings. The first click sets the start, the second sets the end, and a click before the current start quietly restarts the selection. While only the start is committed, the day under the pointer (or keyboard focus) feeds a derived preview span so the in-range band updates live before you commit the second endpoint.

Logic explainer

03

What Does It Do? (Plain English)

DateRangePicker shows two months of a calendar side by side and lets a reader pick a
span of dates rather than a single day. The first click drops a start marker; the
second click drops an end marker; everything in between fills with a coloured band.
While only the start is set, the day under the pointer (or keyboard focus) is treated
as a provisional end so the band previews live before you commit. Clicking a day
that sits before the current start quietly throws the selection away and starts again
from that earlier day β€” the behaviour people expect from booking widgets.

The selected span is exposed as one bindable object, { start, end }, where each value
is an ISO yyyy-mm-dd string or null. Optional min / max bounds disable days
outside a window. The component carries its own calendar-grid maths, so it has no date
library dependency and copies cleanly into any project.

How It Works (Pseudo-Code)

state value      = { start, end }      // committed, bindable
state previewISO = null                // day under pointer / focus
state anchorISO  = left month's first day

derive effectiveEnd:
    if value.start AND not value.end AND previewISO -> previewISO
    else -> value.end

derive span:                           // ordered [lo, hi] or null
    a, b = value.start, effectiveEnd
    if not a or not b -> null
    return a <= b ? [a, b] : [b, a]

on click(day):
    if disabled(day) -> ignore
    if no start OR already have end -> value = { start: day, end: null }   // fresh range
    else if day < start            -> value = { start: day, end: null }   // reset earlier
    else                           -> value = { start, end: day }          // commit end

dayState(day):
    day == start -> 'start'
    day == end   -> 'end'
    lo < day < hi (from span) -> 'in-range'
    else -> 'none'

The Core Concept: One Range, Two Sources of "End"

The trick that makes the live preview feel right is the derived effectiveEnd. There is
only ever one committed value, but the displayed range pulls its second endpoint from
two places depending on state:

Phase value.start value.end Displayed end comes from
Idle null null nothing β€” no band
Picking set null previewISO (hover/focus)
Committed set set value.end

Because span is $derived from effectiveEnd, the band, the in-range shading and the
rounded endpoint corners all recompute automatically on every hover or focus move β€” no
manual DOM updates, no imperative repaint.

CSS Animation Strategy

The visual range is pure CSS class toggling, not JavaScript-driven styling. Each day
button gets one of four classes β€” --start, --end, --in-range, or none β€” and the
stylesheet does the rest: endpoints use the accent colour with the inner corners squared
off (border-top-right-radius: 0 on the start, the mirror on the end) so a continuous
band reads as one shape. Hover and selection transitions are short background-color
fades, and the entire transition set is removed under prefers-reduced-motion: reduce.
Theme colours are CSS custom properties on the root, flipped wholesale inside a
prefers-color-scheme: dark block.

State Flow Diagram

        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  click day A   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚   IDLE     β”‚ ─────────────► β”‚  START SET        β”‚
        β”‚ start=null β”‚                β”‚ start=A, end=null β”‚
        β”‚ end=null   β”‚ ◄───────────── β”‚                   β”‚
        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  click B<A     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β–²          (resets to B)        β”‚
              β”‚                               β”‚ hover/focus C
              β”‚                               β–Ό
              β”‚                       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚   click new day       β”‚  PREVIEW          β”‚
              β”‚   (start set)         β”‚ band = [A..C]     β”‚
              β”‚ ◄──────────────────── β”‚ (uncommitted)     β”‚
              β”‚                       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚                               β”‚ click Bβ‰₯A
              β”‚                               β–Ό
              β”‚                       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              └────────────────────── β”‚  COMMITTED        β”‚
                  click any day        β”‚ start=A, end=B    β”‚
                  starts fresh range   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Props Reference

Prop Type Default Description
value { start: string | null; end: string | null } { start: null, end: null } Bindable selected range as ISO yyyy-mm-dd strings (or null).
min string | null null Earliest selectable ISO date (inclusive); earlier days are disabled.
max string | null null Latest selectable ISO date (inclusive); later days are disabled.
initialMonth string | null null ISO date whose month opens on the left panel. Falls back to value.start, then today.
weekStartsOn 0 | 1 1 First column of the week: 0 = Sunday, 1 = Monday.

Edge Cases

Case Behaviour
Click a day earlier than the current start Selection resets β€” that earlier day becomes the new start, end cleared.
Hover with no start committed No preview band appears; effectiveEnd stays null.
min later than max Every day fails at least one bound and is disabled; nothing is selectable (caller's mis-config).
Selecting the same day twice Start and end collapse to one day; span is [d, d], shown as a single endpoint.
Arrow-key past the visible months The anchor month auto-shifts so the focused day stays on screen, then focus follows it.
Disabled day clicked or arrowed onto Click is ignored; the button is disabled so it cannot receive focus or commit.
initialMonth omitted with an existing value.start The left panel opens on the start's month rather than today.

Dependencies

Zero external dependencies. Pure Svelte 5 runes, scoped CSS, and inline SVG chevrons.
All calendar maths (month grids, weekday offset, ISO formatting, bound comparison) is
implemented locally with the native Date object so the component stays copy-paste
portable β€” no date-fns, no dayjs, no icon library.

File Structure

src/lib/components/
  DateRangePicker.svelte      ← component (logic + scoped styles + doc header)
  DateRangePicker.md          ← this explainer (rendered in the page shell)
  DateRangePicker.test.ts     ← vitest + @testing-library/svelte behaviour tests
src/routes/daterangepicker/
  +page.svelte                ← demo page (playground, pre-filled, bounded variants)

API

04
PropTypeDefaultDescription
value{ start: string | null; end: string | null }{ start: null, end: null }Bindable selected range as ISO yyyy-mm-dd strings (or null).
minstring | nullnullEarliest selectable ISO date (inclusive). Earlier days are disabled.
maxstring | nullnullLatest selectable ISO date (inclusive). Later days are disabled.
initialMonthstring | nullnullISO date whose month opens on the left panel (falls back to value.start, then today).
weekStartsOn0 | 11First column of the week: 0 = Sunday, 1 = Monday.