npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

timescape

npm version bundle size test status license

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 – timescape handles 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 spinbutton with live aria-valuenow, aria-valuemin and aria-valuemax inside a role="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 timescape

Examples

Edit on StackBlitz ⚡

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>
  );
}

Edit on StackBlitz ⚡

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>
  );
}

Edit on StackBlitz ⚡

<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>

Edit on StackBlitz ⚡

<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>

Edit on StackBlitz ⚡

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 date prop and onDateChange callback to manage state externally
  • Uncontrolled: Use defaultDate for 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>` element

Example 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 field

Listeners 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">&nbsp;</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;
}