Choosing a period
createRangeField — <tz-range-field> in Angular — is one field for a whole period: a trigger that reads 18/09/2026 – 24/09/2026, and a panel holding everything needed to change it.
Everything below is one option, with a working example under it. Each is independent: what you see running is the code above it, nothing else.
What it is for
title
Written above the panel, and read out as the field's own label. A picker with no subject is one the reader infers from whatever happens to sit beside it, and two on the same screen are then told apart by position alone.
createRangeField(element, { timeZone: 'Europe/Paris', title: 'Travel dates' });timeZone
The only one with no default. Every date in the field means a moment in this zone, and a period counted in it: ask for seven days across the October change in Paris and the answer is 169 hours, not 168.
createRangeField(element, { timeZone: 'Pacific/Auckland' });locale
What Intl writes: month names, the order of the figures, the first day of the week unless you override it. It does not translate the widget's own words — those are messages.
createRangeField(element, { timeZone: 'Europe/Paris', locale: 'ja-JP' });value
A period the field opens on, worked out by the application rather than picked. Both ends are instants.
createRangeField(element, {
timeZone: 'Europe/Paris',
showTime: true,
value: {
start: Temporal.Instant.from('2026-09-21T07:00Z'),
end: Temporal.Instant.from('2026-09-25T16:00Z'),
},
});There is nothing else to pass. A period is two moments: whether it is whole days is read off them — both landing on a day's first instant — rather than declared. A value with hours in it says so by having them.
Days, or times
showTime
false — the default — makes a period whole days: the midnight that opens the first to the midnight after the last, the exclusive end explained in What you get back.
createRangeField(element, { timeZone: 'Europe/Paris', showTime: false });true gives every chosen day an hour, midnight unless the screen says otherwise. This field has no All day switch: whole days are what two midnights already say, so there is nothing left for the reader to declare. createDateTimeRange is the one widget that still offers that switch, and it carries the answer as allDay beside the two ends — take it when a back end wants the flag told to it rather than read off the value.
createRangeField(element, { timeZone: 'Europe/Paris', showTime: true });defaultTimes
The hours a newly chosen day gets. A value handed in keeps its own, so a screen can open on a period it computed and still offer office hours for anything picked afterwards.
createRangeField(element, {
timeZone: 'Europe/Paris',
showTime: true,
defaultTimes: { start: '09:00', end: '18:00' },
});timeLayout
Two ways of asking for an hour. 'select' is the default: an hour menu and a minute menu, because most of the time an hour is chosen outright and a menu is two clicks.
createRangeField(element, { timeZone: 'Europe/Paris', showTime: true, timeLayout: 'select' });'input' puts an arrow above and below the figures. It suits nudging a time already close to right, not choosing one from nothing.
createRangeField(element, { timeZone: 'Europe/Paris', showTime: true, timeLayout: 'input' });minuteStep
What the minute menu offers. 5 by default; the minute already held is always in the list, whatever the step, or a value handed in would disappear from the control meant to show it.
createRangeField(element, {
timeZone: 'Europe/Paris', showTime: true, timeLayout: 'select', minuteStep: 30,
});stepMinutes
What one press of the time arrows moves. Separate from minuteStep, which is the menu: the two controls answer different questions.
createRangeField(element, {
timeZone: 'Europe/Paris', showTime: true, timeLayout: 'input', stepMinutes: 15,
});snapMinutes
Rounds whatever arrives — typed, chosen, or handed in — onto a grid. A half-hour booking system has nothing to do with 10:07.
createRangeField(element, { timeZone: 'Europe/Paris', showTime: true, snapMinutes: 30 });How long it may be
openEnded
Lets a period stop at one end — the >= with no < that most searches are. Each field carries a cross: emptying From says until, emptying To says from. Off by default, because a booking form must not take a stay that never ends.
createRangeField(element, { timeZone: 'Europe/Paris', openEnded: true });maxSpan
The longest the period may be. Move one end past it and the other end follows rather than the click being refused — a refusal leaves the reader guessing which end was wrong.
createRangeField(element, { timeZone: 'Europe/Paris', maxSpan: { days: 14 } });minSpan
The same, the other way: a stay of at least two nights.
createRangeField(element, { timeZone: 'Europe/Paris', minSpan: { days: 2 } });Both take the short forms below as well: maxSpan: '3d'.
The shortcuts
presets
Named ranges down the side of the panel, shortest first, so a reader scanning the column can stop as soon as it overshoots.
createRangeField(element, {
timeZone: 'Europe/Paris',
presets: ['thisQuarterHour', 'thisHour', 'today', 'last7Days', 'thisQuarter'],
});| shorter than a day | thisQuarterHour · lastHour · thisHour · nextHour |
| days | yesterday · today · tomorrow · last7Days · last14Days · last30Days · next7Days · next30Days |
| calendar units | thisWeek · lastWeek · thisMonth · lastMonth · thisQuarter · lastQuarter · nextQuarter · thisYear |
The four shortest are two moments, not two dates — the quarter hour that is running is 11:00 to 11:15 on one particular day — and they are rounded on the zone's clock rather than on the epoch, or a zone offset by a quarter of an hour would be rounded to somebody else's.
presets: [] removes the column entirely:
Your own are { name, label, range }, and may return either shape:
presets: [
'today',
{
name: 'lastFiveMinutes',
label: 'Last 5 minutes',
range: (today, { now, timeZone }) => ({ start: now.subtract({ minutes: 5 }), end: now }),
},
]showPresets
Whether the column is drawn, without touching the list. presets: [] empties it, and then something else has to remember what was in it to put it back.
createRangeField(element, { timeZone: 'Europe/Paris', showPresets: false });The arrows
shift
The arrows beside the field, and how far one press moves the period. They are a fast way to pick: a step, and you are somewhere else.
Off by default, because a field meaning one chosen period has nothing to step through. Without them, dates are chosen in the calendar or typed — the field loses nothing else.
createRangeField(element, { timeZone: 'Europe/Paris' }); // no arrows
createRangeField(element, { timeZone: 'Europe/Paris', shift: false });true draws them and follows what is being chosen: an hour where the hours are on screen, a day otherwise — a day-only field stepped by an hour would turn 22/09/2026 into 22/09/2026 01:00 – 23/09/2026 01:00, over controls that cannot show or change an hour.
createRangeField(element, { timeZone: 'Europe/Paris', shift: true });A number is minutes
15 is a quarter of an hour, 60 an hour, 1440 a day. Seconds are not offered: an arrow that moves a booking by a second is an arrow nobody presses.
shift: 15 // a quarter of an hour
shift: 60 // an hour
shift: 1440 // a dayAnything a number cannot say is said in full, in whatever shape the business needs:
shift: { days: 1, minutes: 30 }
shift: { months: 1, hours: 1, minutes: 45 }
shift: '45mn' // the short form, still accepted25mn 25min 25m | twenty-five minutes |
1h | an hour |
3d 3j | three days |
2w 2s | two weeks |
6mo | six months |
m is minutes and never months: mo says months, and a screen that read 6m as six months would be wrong by a factor of forty-odd thousand.
Months move as months
A period of whole months stepped by whole months has its end recomputed: added to both ends, three months from 1 July – 30 September gives 1 October – 30 December, and the fourth quarter ends on the 31st. Months are not all the same length, so the last day is asked for rather than carried along.
A list, and the reader picks
A small button between the arrows shows the step, and each press moves to the next one. The labels are yours — a step has no name the library could invent.
shift: [
{ step: 15, label: '15 min' },
{ step: 60, label: '1 h' },
{ step: 1440, label: '1 day' },
]A list of one shows the step without handing it over: the button reads it and does not take a press.
The same list, inside the panel
Open the field below. The list is a column beside the calendar, where the shortcuts used to be — a screen read by comparing asks how far to travel more often than it asks for a named range. The arrows stand either side of the two fields, which is what they move.
createRangeField(element, {
timeZone: 'Europe/Paris',
months: 1,
showTime: true,
showPresets: false,
shift: [
{ step: 15, label: '15 min' },
{ step: 60, label: '1 hour' },
{ step: 1440, label: '1 day' },
{ step: 10080, label: '1 week' },
],
});It opens on the quarter of an hour, because that is what the shape of this field can take. Tick one day below and watch three things happen at once: the second field goes, the step moves to 1 day, and the two shorter steps are refused — an hour inside a single day turns 22/09/2026 into 22/09/2026 01:00 – 23/09/2026 01:00, over controls the same setting has just taken off the screen. They are shown and refused rather than hidden: a list that loses entries when a box is ticked reads as a fault.
And with showStep: false the column goes away, and the panel narrows to the width of the calendar alone.
Shortcuts do not change the step
A shortcut computes a value; a step moves one. They used to touch — the shortcut just pressed decided what an arrow moved by — and that was one mechanism too many: the arrows changed meaning under the reader's hand depending on what they had pressed a moment earlier.
showStep
Whether the step is offered at all — the button beside the field, and the column inside the panel. true by default, which means both appear whenever shift is a list. false hides them: the step is the developer's, and the reader only moves.
Putting the column away narrows the panel to the width of the calendar alone.
Whether the two fields share a line is not declared anywhere — the width says it. One month leaves 412px where two fields need 512, so they wrap and stack; two months leave 723 and they sit side by side, which halves the head.
createRangeField(element, { timeZone: 'Europe/Paris', shift: [ … ], showStep: false });singleDay
One field instead of two, and a click means that whole day — its first instant to the next day's. The value is a period either way, so a screen can turn this on and off without what it is bound to ever changing shape.
createRangeField(element, { timeZone: 'Europe/Paris', singleDay: true });Shortcuts that need more than a day are left out of the column while it is on, and come back when it is off — presets itself is not touched.
Crossing over keeps the start day. Coming back to two fields the end is armed, not the start: the reader has their day already and is switching precisely to add an end, so their next click should extend rather than begin again.
<!-- the checkbox is yours; the field changes shape under it -->
<tz-range-field [(value)]="period" [singleDay]="oneDay()" />The calendar inside
The months and the years
The month above the grid is a button. Press it for the twelve months of the year, press again for a decade — and press a year, then a month, to come back down. The arrows move by a screenful of whatever is shown: a month among days, a year among months, a decade among years.
It is one picker for the whole calendar, whatever it is showing. With two months side by side the choice sets the first and the second follows: they are one run of months, not two calendars that could drift apart.
Nothing to turn on
The single calendar has had this since the start. The range one carried a plain caption instead, so the only way out of September was the arrows, one month at a time — fifteen presses to reach March of last year.
preset — a shortcut that stays a shortcut
A shortcut was resolved at the moment of the click and its name thrown away. Store that filter, reopen it four days later, and it is a fixed window that has quietly stopped meaning the last seven days. This is the thing an analytics filter has to get right, and it was the one thing the widget could not do.
The name now travels beside the period, and handing it back resolves it again:
createRangeField(element, {
presets: ['last7Days', 'thisMonth'],
onChange: (value, from) => save({ ...value, preset: from.preset }),
});
// Later, from whatever was saved:
field.update({ preset: saved.preset, value: { start: saved.start, end: saved.end } });from.preset is the shortcut's name, or null once a reader has chosen days themselves — whatever those days happen to coincide with. Handed back, the name wins over the value beside it: it is the more specific statement of the two.
In Angular it is a model, so [(preset)] binds both ways:
<tz-range-field [(value)]="period" [(preset)]="preset" />The period is still two moments
The name is kept beside the value, not inside it. A form holding a period holds two instants and nothing else, and a back end that knows nothing about shortcuts goes on receiving exactly what it received before.
clearable
Whether the panel offers a Clear. true, because a field that cannot be emptied is a filter nobody can take off. It empties the whole period, which is what someone starting again means — the cross inside each date belongs to openEnded, where one end genuinely may be nothing.
createRangeField(element, { timeZone: 'Europe/Paris', clearable: false });blockAcrossDisabled
Whether a period may run over a day isDateDisabled refuses. true: a screen that greys the weekends and then takes a stay across one has greyed them for nothing. The refusal is said in the panel, and rangeSpansBlockedMessage puts your own words there.
createRangeField(element, {
timeZone: 'Europe/Paris',
isDateDisabled: (date) => date.dayOfWeek > 5,
rangeSpansBlockedMessage: 'Closed at weekends.',
});Under the calendar, the panel writes how long the period is and the zone it is read in:
5d · Europe/Paris
7d 1h · Europe/Paris ← seven days across the October changeThat second line is the whole reason this library exists: seven days across the change in Paris are 169 hours, not 168. It was written in the guide and never shown to the reader. periodSummary, in the message catalogue, rewrites it.
It is announced, not only drawn
Choosing a start, choosing an end, a shortcut firing, a span clamped by maxSpan — all of it goes through a polite live region in the panel, so a screen reader hears what the widget has just done. Each day cell carries its full date and where it falls in the period; the grid says which day is today.
months
How many months stand side by side. Two suits a period that usually crosses one boundary; one suits a narrow screen.
createRangeField(element, { timeZone: 'Europe/Paris', months: 2 });weekNumbers
A column of ISO week numbers down the left, for the people who plan in them.
createRangeField(element, { timeZone: 'Europe/Paris', weekNumbers: true });firstDayOfWeek
1 is Monday through 7 Sunday. Left out, the locale decides — Sunday in the United States, Monday in France — and so do the week-long shortcuts, so the grid and This week never disagree.
createRangeField(element, { timeZone: 'America/New_York', locale: 'en-US' });Set it only where a business disagrees with its own locale:
min and max
The window that can be chosen at all. Outside it the days are there, greyed, rather than absent: a calendar that simply stops gives the reader no way to tell a limit from a bug.
A day greys the days beyond it. A moment does that and holds the hour — "nothing after 18:00 today", which a booking screen says often and which was inexpressible while the bounds were dates. Past the bound a moment is brought inside rather than refused: someone typing 19:00 against a ceiling of 18:00 means as late as they are allowed.
createRangeField(element, { max: Temporal.Instant.from('2026-09-25T16:00:00Z') });createRangeField(element, {
timeZone: 'Europe/Paris',
min: Temporal.PlainDate.from('2026-09-01'),
max: Temporal.PlainDate.from('2026-12-31'),
});isDateDisabled
Anything the two bounds cannot express. Called for every day drawn, so keep it a lookup rather than a request.
createRangeField(element, {
timeZone: 'Europe/Paris',
// Weekends are not working days.
isDateDisabled: (date) => date.dayOfWeek > 5,
});renderCell
A short line under a day — a price, places left, full — plus your own classes, a tooltip, and the power to rule the day out. It runs for every cell on every repaint.
createRangeField(element, {
timeZone: 'Europe/Paris',
renderCell: ({ date, outside }) => {
if (outside) return;
const price = 80 + (date.day % 7) * 15;
return date.dayOfWeek > 5
? { note: 'full', disabled: true, title: 'No rooms left' }
: { note: `€${price}`, className: price > 140 ? 'peak' : undefined };
},
});The note is text, never HTML: a price coming from a booking system is data, and a grid that renders data as markup is one injection away from being someone else's page.
The field itself
disabled
true freezes the whole field. An object freezes one end — a stay whose arrival is settled and whose departure is still open:
createRangeField(element, {
timeZone: 'Europe/Paris',
value: { start: Temporal.Instant.from('2026-09-21T07:00Z'), end: null },
openEnded: true,
disabled: { start: true },
});mode
'popup' by default — a panel hanging from the field. 'dialog' centres it over a backdrop, which is what a small screen wants.
createRangeField(element, { timeZone: 'Europe/Paris', mode: 'dialog' });placeholder
What the closed field reads when nothing is chosen. Left out, it names what it wants:
one day → Date
a period → Start date – End dateThe separator is the one a filled field uses, so the two states are the same sentence with and without the figures — Start date – End date, then 23/09/2026 – 24/09/2026. Look at the two below without touching them: the first asks for one date, the second for two. They both read Choose a range before, one sentence over two different questions.
The words are in the message catalogue, so they follow the page's language like the rest. Say placeholder yourself and it wins — the screen knows its own words.
createRangeField(element, { timeZone: 'Europe/Paris', placeholder: 'Any dates' });ariaLabel
For a field with no title and no visible label of its own — a filter in a toolbar, say. It is read out and never drawn.
createRangeField(element, { timeZone: 'Europe/Paris', ariaLabel: 'Filter by date' });format
Imposes a pattern on both the writing and the typing, over whatever the locale would do.
createRangeField(element, {
timeZone: 'Europe/Paris', showTime: true, format: 'yyyy-MM-dd HH:mm',
});mask
On by default: separators appear as the figures are typed, and never while deleting — a mask that fights the backspace key is worse than none.
createRangeField(element, { timeZone: 'Europe/Paris', mask: false });displayWith
The last word on what the closed field reads. Everything else — locale, format — is a way of not having to write this.
createRangeField(element, {
timeZone: 'Europe/Paris',
displayWith: (value, timeZone) =>
!value.start || !value.end
? 'Choose your nights'
: `${value.start.toZonedDateTimeISO(timeZone).day} → ${value.end.toZonedDateTimeISO(timeZone).day} of the month`,
});fieldIcon and fieldIconSide
Each field carries a calendar, before the text by default. Pass a node of your own, or null for none.
createRangeField(element, {
timeZone: 'Europe/Paris',
fieldIcon: icon('clock'), // or a node of your own, or null
fieldIconSide: 'end',
});The drawings are the library's own — a 24 by 24 box, a two-unit stroke, round caps, the rules the Lucide and Feather families follow — so they sit beside those icons without looking borrowed. They are not taken from either: a library that pulls in an icon set makes every consumer carry it, and one that asks for it makes every consumer install it before a field will render.
They are drawn in currentColor at 1em, so they take the weight of the text beside them and follow it into a dark theme without being told, and they never take a click. icon('calendar') from @tzslot/dom gives you the same node elsewhere; 'clock', 'chevronLeft', 'chevronRight' and 'x' are there too.
labels
What is written above each field and between them: the words from the messages by default, a node of your own, or nothing.
createRangeField(element, {
timeZone: 'Europe/Paris',
labels: { start: null, end: null, between: '»' },
});labels: { start: 'Arrival', end: 'Departure', between: '→' }The word is still read out to a screen reader whatever is drawn.
messages
Every word the library says itself, in one object. EN and FR ship with it; override the few you want rather than writing the bundle out.
import { EN } from '@tzslot/dom';
createRangeField(element, {
timeZone: 'Europe/Paris',
confirm: true,
messages: { ...EN, apply: 'Search', cancel: 'Never mind' },
});today and now
The day the grid rings, and the moment the sub-day shortcuts count from. Both exist so a test does not drift with the clock — and so a screen showing somebody else's day can say so.
createRangeField(element, {
timeZone: 'Europe/Paris',
today: Temporal.PlainDate.from('2026-12-24'),
now: Temporal.Instant.from('2026-12-24T11:07:00Z'),
});confirm
Holds everything until Apply is pressed, for a search that costs something to run. Cancel leaves the value where it was.
createRangeField(element, { timeZone: 'Europe/Paris', confirm: true });onChange, onOpen, onClose
onChange hands you the value each time it settles — with confirm: true, that means on Apply, not on every click inside the panel. The line under every example on this page is an onChange.
createRangeField(element, {
timeZone: 'Europe/Paris',
onChange: (value) => console.log(value.start, value.end),
onOpen: () => console.log('panel open'),
onClose: () => console.log('panel closed'),
});The panel, top to bottom
Two fields, From and To. A click in the calendar fills the one that is armed — the one with the ring — and nothing else. That is the difference between this and a plain range calendar: correcting the end does not throw away the start and make you pick both again.
Left alone the familiar flow survives. A click on the first field fills the start and arms the end, so a fresh period is still two clicks. It is only when you put the cursor in a field yourself that the calendar stops moving on: you armed that one, so that one is what a click changes.
Both fields are typed into. A period ending in February 2028 is a line of text, not eighteen presses of an arrow, and the calendar follows what is typed: type 03/02/2028 and the grid is already there for the next click. The pattern is never shown as a placeholder — a field explaining its own format before anything is typed is a field asking a question instead of inviting an answer.
The two mornings a year
On the morning the clocks go back, an hour happens twice, and a field reading 02:30 could be either of them. Both are named rather than numbered, because "summer time" is something a person can answer and +02:00 is something they have to work out.
createRangeField(element, {
timeZone: 'Europe/Paris',
showTime: true,
today: Temporal.PlainDate.from('2026-10-25'),
});The menus offer the hour twice and star the second — 02 and 02* — with a line under the field naming the reading in force: summer, or * winter when the starred one is the answer, which is the moment the mark needs explaining. Naming both inside the list would widen the menu to the longest word in the language, on every ordinary day of the year as much as on this one. Either way the closed field says which was chosen:
25/10/2026 00:00 – 25/10/2026 02:30 (winter)On the morning an hour is skipped, it cannot be landed on at all: the arrows step over it and a time typed into the gap settles on the first moment that exists.