What's Included
index.html: the demo page and the markup to copyassets/style.css: component styles, plain CSS custom properties for coloursassets/script.js: readable, commented source with anonReadyguardRange selection in two clicks, with a live preview of the span between the start and wherever the pointer or the keyboard focus currently is
A staggered fill that runs from the start date to the end date when the range closes, and a damped pop on the endpoint that lands
Month navigation that slides the grid in the direction of travel and staggers the day cells from the leading edge
One month, or two side by side above 720px; below that it renders one, whatever
data-monthsasks forA presets column (today, last 7 days, last 30 days, this month), plus Clear and Apply
Minimum and maximum dates and individually blocked days, read live from
data-attributesrole="dialog"popover overrole="grid"months whose cells aregridcellbuttons under a roving tabindexOne
changeevent on the component root carryingstartandendas ISO date strings
Quick Start
1. Add to your <head>:
Code snippet omitted: it ships with the download.
2. Add before the closing </body> tag:
Code snippet omitted: it ships with the download.
3. Copy the component markup from index.html into your page.
The script finds the component with [data-daterange] and everything inside it by data-daterange-* attribute, never by class name, so you can restyle or rename any class without touching the JavaScript. The parts it looks for:
| Attribute | What it marks | Required |
|---|---|---|
data-daterange |
the component root; carries every option below | yes |
data-daterange-field |
the button that opens the popover | yes |
data-daterange-value |
the element whose text becomes the selected range | no |
data-daterange-popover |
the panel, with role="dialog" and the hidden attribute |
yes |
data-daterange-track |
the empty element the months are rendered into | yes |
data-daterange-title |
a live region announcing the visible months | no |
data-daterange-summary |
the "N days selected" line | no |
data-daterange-nav="prev" / "next" |
the month arrows | no |
data-daterange-preset="today|last7|last30|month" |
a quick-range button | no |
data-daterange-action="clear" / "apply" |
the footer buttons | no |
data-daterange-part |
any block that should fade up a frame after the panel | no |
Only the root, the field, the popover and the track are required; a picker with no presets, no footer and no summary is the same component with less markup.
The toolbar strip at the top of index.html and the .stage wrapper around the component are demo furniture. They are not part of what you bought: delete them and drop the .drp block into your own page.
Options
Every option is a data- attribute on the root, and every one is read live: change it with element.dataset.months = '1' and the calendar re-renders in place. Nothing needs a JavaScript API.
| Attribute | Values | Default | What it does |
|---|---|---|---|
data-months |
1, 2 |
1 |
How many months to show. Two only fit above 720px; below that one is rendered regardless |
data-first-day |
0-6 |
1 |
Which weekday the grid starts on. 1 is Monday, 0 is Sunday |
data-min |
YYYY-MM-DD |
none | Earliest selectable date. Earlier days are blocked and the back arrow stops at its month |
data-max |
YYYY-MM-DD |
none | Latest selectable date |
data-disabled |
comma-separated YYYY-MM-DD |
none | Individual days to block inside the allowed span |
data-start |
YYYY-MM-DD |
none | The range's start when the page loads |
data-end |
YYYY-MM-DD |
none | The range's end when the page loads |
data-open |
true |
absent | Opens the popover on load without moving focus |
Code snippet omitted: it ships with the download.
Keyboard & Accessibility
| Key | What it does |
|---|---|
Enter / Space on the field |
Opens the calendar and moves focus into the grid |
ArrowDown on the field |
Opens the calendar |
ArrowLeft / ArrowRight |
Moves the day focus back and forward one day |
ArrowUp / ArrowDown |
Moves the day focus back and forward one week |
Home / End |
Jumps to the first and last day of the focused week |
PageUp / PageDown |
Moves the view back and forward one month |
Enter / Space on a day |
Sets the start, or closes the range if a start is already set |
Tab / Shift+Tab |
Cycles between the presets, the arrows, the focused day and the footer buttons |
Escape |
Closes the calendar and returns focus to the field |
- Roles and states: the field is a
buttonwitharia-haspopup="dialog"andaria-expanded; the panel isrole="dialog"with anaria-label; each month is arole="grid"labelled with its month and year, withrole="row"rows,role="columnheader"weekday headers carrying full day names, androle="gridcell"buttons carryingaria-label(the full date),aria-selectedon the two endpoints andaria-current="date"on today. - Roving tabindex: exactly one day cell is in the tab order at a time, so
Tabnever walks 42 buttons. The arrow keys move which one it is, and moving past the edge of the visible months changes the view and keeps focus on the day. - A blocked day carries
aria-disabled="true"rather than thedisabledattribute, so the arrow keys can still travel over it; it simply does not respond toEnteror a click, and its number is ruled through rather than faded to something unreadable. - The visible month names are the on-screen labels; the header also holds a visually hidden
aria-live="polite"line, so a screen reader hears the new months after a jump. - Focus is trapped inside the popover while it is open, moved into the grid when it opens, and returned to the field when it closes. The page behind is not locked: a popover anchored to a field is not a modal, and locking the document under one is the wrong behaviour.
- Clicking anywhere outside the popover closes it without moving focus.
prefers-reduced-motion: the same states, with no tweening. The panel appears in place, months change instantly and the range fill is simply there. Nothing is disabled and nothing is hidden.
Themes
Ships in two: light (the default) and dark. They are one design at two token values. Set the attribute on <body>, or on the component root itself when only this component should flip:
Code snippet omitted: it ships with the download.
Nothing else changes: same markup, same script, and nothing in assets/script.js ever reads the theme name. The demo's toggle and the ?variant=dark URL parameter only set that attribute.
Every colour, radius and shadow the component uses is a custom property in the body[data-variant="light"] and body[data-variant="dark"] blocks at the top of assets/style.css. To wear your own brand, re-value this short list in both blocks:
| Property | What it paints |
|---|---|
--raised |
the field and the popover surface |
--ground / --ground-2 |
the page behind, and the disabled Apply button |
--ink / --ink-2 / --ink-3 |
day numbers, preset labels, weekday initials and meta |
--line / --line-strong |
hairlines and the field border |
--accent / --accent-ink |
the selected endpoints, the primary button and the focus ring |
--accent-text |
the today mark and the primary button's hover |
--range-bg / --range-ink |
the wash across the range and the numbers inside it |
--day-hover |
a day cell under the pointer |
--radius / --radius-sm |
the panel, and the controls and day cells |
--day-size |
the width and height of one day cell (on .drp, not the theme block) |
Keep --range-bg a solid hex rather than a translucent colour: the day numbers sit on top of them, and a translucent background makes the contrast unmeasurable for both an audit tool and a browser's own high-contrast pass.
How It Works
Opening in two beats. The panel animates first with a short power3.out on opacity, y and a small scale, and everything marked data-daterange-part fades up behind it on a 0.06s delay with a 0.035s stagger. The panel is animated on opacity, never autoAlpha: autoAlpha writes visibility: hidden on the same frame, and an element that is not visible yet cannot take focus, so the grid would never receive it. Presence is controlled by the hidden attribute instead, set in the close tween's onComplete so an invisible panel is never left covering the page.
The fill sweep. Each day cell is three layers: a wash, a cap, and the number inside the cap. Closing a range collects the cells between the endpoints in date order and runs one gsap.fromTo across their wash layers, scaling each from its left edge with a stagger sized to the length of the range. The number lives inside the cap rather than beside it so the accent background is a real ancestor of the text; a sibling layer is invisible to a contrast audit and to a browser's forced-colours mode.
Month navigation. A month change rebuilds the grid and then animates it: the track slides in from the direction of travel while the day cells fade up in a stagger that starts from the leading edge. Every one of those tweens ends in clearProps, because a cell left holding an inline transform mis-measures the next time the grid is rebuilt, and the panel's own transform is cleared on close so a second open cannot start from where the last close finished.
Dates without a parser. A date is a { y, m, d } record compared as one sortable integer. Date is used only through its UTC constructor, to ask what weekday a day falls on and how many days a month has. Nothing is built by parsing a string: new Date('2026-09-12') is UTC midnight, which is the 11th anywhere west of Greenwich, and that shifts a whole calendar by one column for a large part of the world. The first day of the week is a rotation applied at render time, so Monday-first and Sunday-first run the same code path.
Customisation
- Cell size is
--day-sizeon.drp(32px above 720px, 38px below). Everything in the grid derives from it. - The month and weekday names are the four arrays at the top of
assets/script.js; replace them to ship the component in another language. - The presets are markup, not configuration: delete one, reorder them, or add your own by handling
clickon a new button and setting the range you want. - Easings and durations are the
gsap.to/gsap.fromTocalls inassets/script.js; the sweep's stagger is capped so a long range still finishes quickly. - Colours live in
assets/style.cssas custom properties per theme.
Requirements
- GSAP 3.12+ (core only, no plugins)
- No build step, no framework
Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.