timescape
v0.9.1
Published
A flexible, headless date and time input library for JavaScript. Provides tools for building fully customizable date and time input fields, with support for libraries like React, Preact, Vue, Svelte and Solid.
Maintainers
Readme
timescape
A powerful, headless library that elegantly fills the void left by HTML's native
<input type="time"> and
<input type="date">.
timescape is a toolkit for creating custom date and time input components. It helps you handle date and time
data easily while giving you full control over the design and presentation. timescape supports multiple
libraries, including React, Vue, Preact, Svelte, Solid, and native JavaScript.
Key features such as accessibility and keyboard navigation are at the core of timescape, allowing you to
focus on creating user-centric date and time inputs that integrate seamlessly into your projects.
See Storybook or check out the examples of how to use it + StackBlitz ⚡ for more demonstrations.
Features
- 🧢 Headless Architecture: You control the UI –
timescapehandles the logic. - 🧩 Framework Compatibility: Adapters for React (17+), Preact (10), Vue (3), Svelte (3, 4 and 5), and Solid (1+).
- ⚙ Flexible API: Hooks (or equivalents) return getters for seamless component integration. Order of inputs (i.e. format) is completely up to you by just rendering in the order you prefer.
- 👥 Accessibility: Every field is an ARIA
spinbuttonwith livearia-valuenow,aria-valueminandaria-valuemaxinside arole="group"root, plus keyboard navigation and manual typing. - ⏰ Date and time flexibility: Fields from years down to milliseconds, min/max dates and 24/12 hour clock formats.
- 🪶 Lightweight: ~5.3 kB min+gzip for the core, ~6 kB including a framework adapter. No runtime dependencies.
- 🔀 Enhanced input fields: A supercharged
<input type="date/time">, offering additional flexibility. - 🤳 Touch device support: Use it on any device, including touch devices.
[!IMPORTANT] Upgrading from 0.8? The integrations moved to controlled/uncontrolled props in 0.9, which is a breaking change. See MIGRATION-v0.9.md for per-framework examples.
Installation
# pnpm
pnpm add timescape
# yarn
yarn add timescape
# npm
npm install --save timescapeExamples
import { useTimescape } from "timescape/react";
import { useState } from "react";
function App() {
// Controlled example (`null` is the empty date)
const [date, setDate] = useState<Date | null>(new Date());
const { getRootProps, getInputProps } = useTimescape({
date,
onDateChange: (nextDate) => {
console.log("Date changed to", nextDate);
setDate(nextDate);
},
});
// Or uncontrolled with defaultDate
// const { getRootProps, getInputProps } = useTimescape({
// defaultDate: new Date(),
// onDateChange: (nextDate) => console.log("Date changed to", nextDate),
// });
return (
<div className="timescape" {...getRootProps()}>
<input {...getInputProps("days")} />
<span>/</span>
<input {...getInputProps("months")} />
<span>/</span>
<input {...getInputProps("years")} />
<span> </span>
<input {...getInputProps("hours")} />
<span>:</span>
<input {...getInputProps("minutes")} />
<span>:</span>
<input {...getInputProps("seconds")} />
</div>
);
}import { useTimescape } from "timescape/preact";
import { useState } from "preact/hooks";
function App() {
// Controlled example (`null` is the empty date)
const [date, setDate] = useState<Date | null>(new Date());
const { getRootProps, getInputProps } = useTimescape({
date,
onDateChange: (nextDate) => {
console.log("Date changed to", nextDate);
setDate(nextDate);
},
});
// Or uncontrolled with defaultDate
// const { getRootProps, getInputProps } = useTimescape({
// defaultDate: new Date(),
// onDateChange: (nextDate) => console.log("Date changed to", nextDate),
// });
return (
<div className="timescape" {...getRootProps()}>
<input {...getInputProps("years")} />
<span>/</span>
<input {...getInputProps("months")} />
<span>/</span>
<input {...getInputProps("days")} />
</div>
);
}<template>
<div class="timescape" :ref="registerRoot()">
<input :ref="registerElement('years')" />
<span>/</span>
<input :ref="registerElement('months')" />
<span>/</span>
<input :ref="registerElement('days')" />
</div>
<!-- Controlled: update the date through v-model or state -->
<button @click="date = new Date()">Change date</button>
</template>
<script lang="ts" setup>
import { useTimescape } from "timescape/vue";
import { ref, watch } from "vue";
// Controlled example
const date = ref(new Date());
const { registerElement, registerRoot } = useTimescape({
date,
onDateChange: (nextDate) => {
console.log("Date changed to", nextDate);
date.value = nextDate;
},
});
// Or uncontrolled with defaultDate
// const { registerElement, registerRoot } = useTimescape({
// defaultDate: new Date(),
// onDateChange: (nextDate) => console.log("Date changed to", nextDate),
// });
</script><script lang="ts">
import { createTimescape } from "timescape/svelte";
import { writable } from "svelte/store";
// Controlled example with Svelte store (pass the store itself, not its value)
const date = writable<Date | null>(new Date());
const { inputProps, rootProps } = createTimescape({
date,
onDateChange: (nextDate) => {
console.log("Date changed to", nextDate);
date.set(nextDate);
},
});
// Or uncontrolled with defaultDate
// const { inputProps, rootProps } = createTimescape({
// defaultDate: new Date(),
// onDateChange: (nextDate) => console.log("Date changed to", nextDate),
// });
</script>
<div class="timescape" use:rootProps>
<input use:inputProps={'days'} />
<span>/</span>
<input use:inputProps={'months'} />
<span>/</span>
<input use:inputProps={'years'} />
</div>
<!-- Update controlled date -->
<button on:click={() => date.set(new Date())}>Change date</button>import { createSignal } from "solid-js";
import { useTimescape } from "timescape/solid";
function App() {
// Controlled example
const [date, setDate] = createSignal(new Date());
const { getInputProps, getRootProps } = useTimescape({
date: date(),
onDateChange: (nextDate) => {
console.log("Date changed to", nextDate);
setDate(nextDate);
},
});
// Or uncontrolled with defaultDate
// const { getInputProps, getRootProps } = useTimescape({
// defaultDate: new Date(),
// onDateChange: (nextDate) => console.log("Date changed to", nextDate),
// });
return (
<div class="timescape" {...getRootProps()}>
<input {...getInputProps("years")} />
<span>/</span>
<input {...getInputProps("months")} />
<span>/</span>
<input {...getInputProps("days")} />
</div>
);
}import { TimescapeManager } from "timescape";
const container = document.createElement("div");
document.body.appendChild(container);
container.innerHTML = `
<div class="timescape" id="timescape-root">
<input data-type="days" placeholder="dd" />
<span>/</span>
<input data-type="months" placeholder="mm" />
<span>/</span>
<input data-type="years" placeholder="yyyy" />
</div>
`;
const timeManager = new TimescapeManager();
timeManager.date = new Date();
timeManager.on("changeDate", (nextDate) => {
console.log("Date changed to", nextDate);
});
timeManager.registerRoot(document.getElementById("timescape-root")!);
timeManager.registerElement(container.querySelector('[data-type="days"]')!, "days");
timeManager.registerElement(container.querySelector('[data-type="months"]')!, "months");
timeManager.registerElement(container.querySelector('[data-type="years"]')!, "years");Options
timescape supports both controlled and uncontrolled modes:
- Controlled: Use
dateprop andonDateChangecallback to manage state externally - Uncontrolled: Use
defaultDatefor initial value, component manages state internally
Pass null for an empty controlled date, and undefined only to opt out of
controlled mode -- the same distinction React Aria and MUI draw. Which mode an
input is in is decided on its first render, so a controlled input that reports
an empty date stays controlled.
While the user is mid-edit -- a cleared segment, a partially typed value -- the input keeps that editing state. The parent owns the date, the input owns the edit: writing the same date back (because you rejected the change, or have not answered yet) leaves the edit alone, while writing a different date replaces what is on screen.
type Options = {
date?: Date | null; // For controlled mode, `null` is the empty date
defaultDate?: Date | null; // For uncontrolled mode
onDateChange?: (date: Date | null) => void; // Called on any date change
minDate?: Date | $NOW; // see more about $NOW below
maxDate?: Date | $NOW;
hour12?: boolean;
wrapAround?: boolean;
digits?: "numeric" | "2-digit";
snapToStep?: boolean;
wheelControl?: boolean;
disallowPartial?: boolean;
};| Option | Default | Description |
| ----------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| date | undefined | The current date value for controlled mode. When provided, you must handle updates via onDateChange. Use null for an empty value; undefined means uncontrolled. |
| defaultDate | undefined | The initial date for uncontrolled mode. Component manages state internally. |
| onDateChange | undefined | Callback fired when the date changes, with null when the date is empty or incomplete. Required for controlled mode, optional for uncontrolled. |
| minDate | undefined | The minimum date that the user can select. $NOW is a special value that represents the current date and time. See more below |
| maxDate | undefined | The maximum date that the user can select. $NOW is a special value that represents the current date and time. See more below |
| hour12 | false | If set to true, the time input will use a 12-hour format (with AM/PM). If set to false, it will use a 24-hour format. |
| digits | '2-digit' | Controls the display of the day and month in the date input. 'numeric' displays as 1-12 for month and 1-31 for day, while '2-digit' displays as 01-12 for month and 01-31 for day. This follows Intl.DateTimeFormat convention. |
| wrapAround | false | If set to true, the time input will wrap around from the end of one period (AM/PM or day) to the beginning of the next. |
| snapToStep | false | If set to true, the input value will snap to the nearest step when the user uses arrow keys to increment/decrement values. Can be further adjust by using the step attribute |
| wheelControl | false | If set to true, the user can use the mouse wheel or touchpad to increment/decrement values. |
| disallowPartial | false | If true, the input requires fully completed dates and times. By default partial dates are allowed, similar to native HTML input behavior. |
$NOW value
$NOW is a convenience value you can use for minDate and maxDate. It represents the current date and time
at the moment of the user's interaction, dynamically adjusting to always reflect the current datetime value.
This means you don't need to manually update it, as it always keeps itself current.
$NOW is exported as a constant for better type safety. By doing so, it eliminates the need for casting it
as const, which would be required if $NOW were simply a string."
It can be imported from the package like so:
import { $NOW } from "timescape";
// or from a specific module
import { $NOW } from "timescape/react";
// Svelte import names prohibit a $ prefix, so it's renamed to NOW there
import { NOW } from "timescape/svelte";placeholder on input elements
The placeholder attribute on the input elements is supported and will be used to display the placeholder
text. Usually it's to indicate the expected format of the input, e.g. yyyy/mm/dd
step on input elements
The step attribute for input elements
is supported and will be used to increment/decrement the values when the user uses the arrow keys. The default
value is 1, but you can set it to any value you want. Also see snapToStep if you want to snap
to the nearest step.
ref and autofocus on inputs
In React and Preact, getInputProps takes a second argument to keep your own ref and to focus a field on
mount:
const inputRef = useRef<HTMLInputElement | null>(null);
<input {...getInputProps("days", { ref: inputRef, autofocus: true })} />;getInputProps already returns a ref callback, so passing your own through this option is the way to get
hold of the element. The other integrations don't take these options: in Vue, Solid and Svelte you attach your
own ref or bind:this alongside registerElement/inputProps.
Preventing default keydown behavior
By default, timescape intercepts keydown events to enhance input behavior. If you want to handle keydown
events yourself and prevent the default processing, you can do so by attaching your event handler during the
capturing phase and calling preventDefault:
<input
onKeyDownCapture={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
/>Custom AM/PM Controls
While timescape provides getInputProps("am/pm") for a standard input field, you may want to use custom
controls like select dropdowns, buttons, or checkboxes for AM/PM selection. All hooks/functions return an
ampm object with the following methods:
ampm.value; // Current value: "am" | "pm" | undefined
ampm.set(value); // Set to "am" or "pm"
ampm.toggle(); // Toggle between AM and PM
ampm.getSelectProps(); // Returns props for binding to a `<select>` elementExample with React
import { useTimescape } from "timescape/react";
import { useState } from "react";
function CustomAmPmExample() {
const [date, setDate] = useState(new Date());
const { getInputProps, getRootProps, ampm } = useTimescape({
date,
hour12: true,
onDateChange: setDate,
});
return (
<div {...getRootProps()}>
<input {...getInputProps("hours")} />
<span>:</span>
<input {...getInputProps("minutes")} />
{/* Example 1: Select with getSelectProps() */}
<select {...ampm.getSelectProps()}>
<option value="am">AM</option>
<option value="pm">PM</option>
</select>
{/* Example 2: Toggle button */}
<button onClick={ampm.toggle}>{ampm.value === "am" ? "☀️ AM" : "🌙 PM"}</button>
{/* Example 3: Checkbox */}
<input type="checkbox" checked={ampm.value === "pm"} onChange={ampm.toggle} />
{/* Example 4: Radio buttons */}
<label>
<input type="radio" checked={ampm.value === "am"} onChange={() => ampm.set("am")} />
AM
</label>
<label>
<input type="radio" checked={ampm.value === "pm"} onChange={() => ampm.set("pm")} />
PM
</label>
</div>
);
}Ranges
timescape supports ranges for the date/time inputs. This means a user can select a start and end. This is
useful for things like booking systems, where you want to allow the user to select a range of dates.
This is achieved by using two timescape instances, one for the start and one for the end. You can set their
options independently, and they return the respective options and update functions in the from and to
objects.
Example usage (this works similar for all supported libraries):
import { useTimescapeRange } from "timescape/react";
import { useState } from "react";
// Use `createTimescapeRange` for Svelte
// Controlled example
const [fromDate, setFromDate] = useState(new Date("2000-01-01"));
const [toDate, setToDate] = useState(new Date());
const { getRootProps, from, to } = useTimescapeRange({
from: {
date: fromDate,
onDateChange: setFromDate,
},
to: {
date: toDate,
onDateChange: setToDate,
},
});
// Or uncontrolled with defaultDate
// const { getRootProps, from, to } = useTimescapeRange({
// from: { defaultDate: new Date("2000-01-01") },
// to: { defaultDate: new Date() },
// });
return (
<div {...getRootProps()}>
<div>
<input {...from.getInputProps("days")} />
<span>/</span>
<input {...from.getInputProps("months")} />
<span>/</span>
<input {...from.getInputProps("years")} />
</div>
<div>
<input {...to.getInputProps("days")} />
<span>/</span>
<input {...to.getInputProps("months")} />
<span>/</span>
<input {...to.getInputProps("years")} />
</div>
</div>
);The helper that registers the shared root element is named per framework: getRootProps (React, Preact,
Solid), registerRangeRoot (Vue) and rootProps (Svelte). The from and to objects expose the same input
helpers and ampm object as a single instance does.
In vanilla JS you tie two managers together yourself with marry:
import { marry, TimescapeManager } from "timescape";
const from = new TimescapeManager(new Date("2000-01-01"));
const to = new TimescapeManager(new Date());
// `from` becomes the minimum of `to` and `to` the maximum of `from`, and focus
// wraps from the last `from` field into the first `to` field (and back).
const divorce = marry(from, to);
// Untie them again – this also clears the bounds they set on each other
divorce();Vanilla API
The integrations are thin wrappers around TimescapeManager, which you can also use directly (see the
vanilla JS example above).
| Member | Description |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| date | Getter and setter for the current date. The setter also accepts a timestamp or a date string, the getter returns undefined while the date is still incomplete. |
| registerRoot(element) | Registers the root element, which handles focus management and gets role="group". |
| registerElement(element, type, autofocus?) | Registers an input for a DateType: "years", "months", "days", "hours", "minutes", "seconds", "milliseconds" or "am/pm". |
| on(event, callback) | Subscribes to an event and returns an unsubscribe function. See below |
| focusField(index) | Focuses the field at the given index in registration order. Negative indices count from the end, so -1 is the last field. |
| resync() | Re-registers all known elements, e.g. after the inputs were moved or re-rendered. |
| remove() | Tears down all listeners and observers. |
All options from the options table are plain properties on the manager and can be assigned at any
time, e.g. manager.hour12 = true.
Events
manager.on("changeDate", (date: Date | undefined) => {}); // date changed (`undefined` if incomplete)
manager.on("focusWrap", (direction: "start" | "end") => {}); // focus moved past the first or last fieldListeners run in subscription order. Returning STOP_EVENT_PROPAGATION from a listener keeps the remaining
listeners for that event from running:
import { STOP_EVENT_PROPAGATION } from "timescape";
manager.on("focusWrap", () => STOP_EVENT_PROPAGATION);Anatomy & styling
The component is designed to be as un-opinionated as possible, so it doesn't come with any styling out of the box. You can style it however you want, but here are some tips to get you started.
This is how it could look like:
A typical anatomy of a timescape component may look like this:
HTML
<div class="timescape">
<!-- Date inputs -->
<input />
<span class="separator">/</span>
<input />
<span class="separator">/</span>
<input />
<span class="separator"> </span>
<!-- Time inputs -->
<input />
<span class="separator">:</span>
<input />
<span class="separator">:</span>
<input />
</div>CSS
/**
* Root element
*/
.timescape {
display: flex;
align-items: center;
gap: 1px;
width: fit-content;
border: 1px solid #b2b2b2;
padding: 5px;
user-select: none;
border-radius: 10px;
}
.timescape:focus-within {
outline: 1px solid #8f47d4;
border-color: #8f47d4;
}
/**
* Date and time input elements
*/
.timescape input {
/* This is an important style, as it ensures that the inputs have
the same width regardless of the number of characters they contain. */
font-variant-numeric: tabular-nums;
height: fit-content;
/* These are handled by the `:focus` selector */
border: none;
outline: none;
cursor: default;
user-select: none;
box-sizing: content-box;
/* For touch devices where input fields are not set to readonly */
caret-color: transparent;
/* For the calculation of the input width these are important */
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.timescape input:focus {
background-color: #8f47d4;
color: #fff;
border-radius: 6px;
padding: 2px;
}
/**
* Separator elements
*/
.timescape .separator {
font-size: 80%;
color: #8c8c8c;
margin: 0;
}