ictus

v1.1.0

Turn a plain <input> into a date or time field that formats as you type. It works with the input and styles you already have, weighs 3.0 kB gzipped, has no runtime dependencies, and comes with an optional React hook.

A short walkthrough. Skip whenever.
| incomplete

Getting started

Install the package yourself, or let your coding agent do it.

Install

npm install ictus

Or prompt your agent

Let your coding agent handle the install and setup. Paste this into Cursor, Claude Code, or any other agent. It finds date and time text fields in your project, asks which ones you want, then installs ictus and wires it up.

Integrate the `ictus` library into this project for masked date and/or time text inputs.

Docs: https://ictus.luca-felix.com
Package: https://www.npmjs.com/package/ictus
Repo: https://github.com/flixlix/ictus

ictus is a headless as-you-type mask for a plain <input>. Use it for day-month-year dates (`apply` / `applyPaste`, or React `useDateFieldMask`) and for 24-hour times (`ictus/time`: `applyTime` / `bindTimeMask`, or React `useTimeFieldMask`). Attach it to an existing text input; keep the host app’s UI.

## 1. Find opportunities first
Scan this repo for places a date or time is entered as text (or should be), for example:
- date / birthdate / DOB / expiry / start/end date inputs
- time / clock / HH:mm or HH:mm:ss inputs
- <input> fields with date or time placeholders or masks (dd.mm.yyyy, mm/dd/yyyy, HH:mm, etc.)
- custom or design-system Input wrappers used for dates or times
- forms that parse free-text dates or times today

Do NOT invent a new date or time field if none belongs here.

Then stop and ask me before changing code:
- If you find none: say so, and ask where I want ictus added (path / component / form).
- If you find exactly one good place: briefly say where and why, and ask for OK to proceed there.
- If you find more than one: list them (file + short reason) and ask whether to do one, several, or all.

## 2. Install (only after I confirm where)
Use this project’s package manager (`pnpm` / `npm` / `yarn` / `bun`, matching the lockfile). Install `ictus`. Follow current docs for the right entry (`ictus`, `ictus/time`, or `ictus/react`).

## 3. Implement in the confirmed place(s)
- Wire the mask per the docs (keydown + paste for vanilla; `inputProps` for React).
- Keep the host app’s Input / styling / labels; only add ictus behavior.
- Match separator and date order (or time precision) to the product locale or existing UX.
- Use `parseDate` / `parseTime` (and status helpers if useful) at the form boundary.
- Leave unrelated pickers alone unless I asked to replace them.

When done, summarize what you installed, what you changed, and how to try it.

When to use a date input mask

Good fit for birthdates, expiry dates, start/end dates, or clock times in a single text input that should format while someone types. Works with your existing input styles, in vanilla JS/TS or React.

Size and speed

ictus is tiny and does a lot. The date core is 3.0 kB gzipped, time is a separate 2.7 kB entry, and the React hooks add 0.9 kB on top. No runtime dependencies, marked side-effect free, and tree-shakeable if you only import what you use. React is an optional peer for ictus/react only.

Import Minified Gzipped
ictus 8.2 kB 3.0 kB
ictus/time 6.8 kB 2.7 kB
ictus/react (excluding React, core, time) 3.0 kB 0.9 kB

It’s fast, too. Each keystroke takes about 0.1–0.3 microseconds. Your screen redraws every 16 milliseconds, so ictus could handle tens of thousands of keystrokes before you’d see a single frame of delay.

Demos

Every field below runs the same mask with different options. Under each one you see the caret position (|) and what the value parses to.

Dot

The default. A day can’t start with 4 to 9, so typing one pads it and jumps ahead: 4 becomes 04.. Months do the same for 2 to 9.



| incomplete

Slash

Users can type any of ., /, or -. The field always writes the separator you configured, here /.



| incomplete

Dash

Same day-month-year order. The year waits for all four digits.



| incomplete

Arrow step

Off by default. With step: 1, click into the day, month, or year and press ArrowUp or ArrowDown to change it. The caret stays put. The other demos ignore arrow keys.



11.12.2026| valid

Time

Same idea for a 24-hour clock: type the digits and the colon lands for you. Hours pad 3–9; minutes pad 6–9. Default precision is HH:mm.



| incomplete

Time with seconds

Pass precision: "second" for HH:mm:ss. Arrow step wraps hours 0–23 and minutes/seconds 0–59.



14:30:00| 14:30:00

formatDate

Going the other way: turn a Date into a masked string. Pick a date and a separator, then load the result into the matching demo above.

Result:

Every keystroke rule

This table is the spec, and the test suite checks every row. Click one to replay it in the field below. | is the caret.



| incomplete

Before Key After
| 4 04.|
| 1 1|
1| . 01.|
13| . 13.|
| . |
04.| . 04.|
04.| 9 04.09.|
3| 9 03.09.|
04.1| 3 04.01.3|
04.|2.2026 1 04.12.|2026
04.|1.2026 2 04.|1.2026
11| 1 11.1|
11.12.| ⌫ 11.12|
14.10.|26 1 14.10.1|26

With your UI library

Same mask as the demos above, wired into common React inputs. Type in each field (try 4122026). Install later with the shadcn CLI, or copy registry/react/.

shadcn Input

useDateFieldMask spread onto a shadcn-style Input.

npx shadcn@latest add flixlix/ictus/date-field-shadcn

Or prompt your agent

Add an ictus-masked date field using the shadcn Input pattern from https://github.com/flixlix/ictus.

Prefer: `npx shadcn@latest add flixlix/ictus/date-field-shadcn`
(requires ictus installed and a shadcn Input in the project).

Source of truth: registry/react/date-field-shadcn.tsx in that repo.
Wire useDateFieldMask from ictus/react; keep the host Input styles. Ask before inventing a new date field.

Base UI

Same hook on Base UI Field + Input.

npx shadcn@latest add flixlix/ictus/date-field-base-ui

Or prompt your agent

Add an ictus-masked date field using Base UI from https://github.com/flixlix/ictus.

Prefer: `npx shadcn@latest add flixlix/ictus/date-field-base-ui`
(installs the component file; also needs `ictus` and `@base-ui/react`).

Source: registry/react/date-field-base-ui.tsx.
Use useDateFieldMask; keep Field/Input composition. Ask before inventing a new date field.

Day Picker + popover

Type a date, or open the calendar and pick one. Both update the same masked value.

npx shadcn@latest add flixlix/ictus/date-field-day-picker

Or prompt your agent

Add an ictus-masked date field with a react-day-picker popover calendar from https://github.com/flixlix/ictus.

Prefer: `npx shadcn@latest add flixlix/ictus/date-field-day-picker`
(needs `ictus`, `@base-ui/react`, and `react-day-picker`).

Source: registry/react/date-field-day-picker.tsx.
Keep typing and calendar selection in sync via useDateFieldMask + formatDate. Import react-day-picker/style.css if the calendar looks unstyled. Ask before inventing a new date field.

API

Most apps need one call: bindDateMask / bindTimeMask for a plain input, or useDateFieldMask / useTimeFieldMask in React. The rest are pure functions on strings, for when you handle events yourself.

apply

The core of ictus. Pass the current value, the selection, and the key that was pressed, and get back the next value and caret. Unknown keys and rejected digits leave the value as it was. Backspace and Delete remove one character (and strip any non-mask junk). Ctrl or Cmd with Backspace or Delete clears a whole group. Shift with Backspace or Delete clears the field. If text is selected, Backspace and Delete remove it and a digit replaces it.

import { apply } from "ictus";

apply({
  value: "1",
  caret: 1,
  key: ".",
  separator: ".", // default
});
// { value: "01.", caret: 3 }
value
The current masked string.
caret
Where the selection starts, or the caret position if nothing is selected.
selectionEnd?
Where the selection ends. Defaults to caret.
key
The KeyboardEvent.key value: a digit, ., /, -, Backspace, or Delete.
ctrlKey? / metaKey?
When either is true with Backspace or Delete, clear a group instead of one character.
shiftKey?
When true with Backspace or Delete, clear the whole value.
separator?
The single character to write between groups. Defaults to ".".
mode?
Field order: "dmy" (default), "mdy", or "ymd".
step?
How much ArrowUp and ArrowDown change the group under the caret. Defaults to 0, which ignores arrows. isDateMaskKey returns false for arrow keys, so handle them yourself when you turn this on:
const step = 1;
if (
  isDateMaskKey(event.key) ||
  (step > 0 && (event.key === "ArrowUp" || event.key === "ArrowDown"))
) {
  event.preventDefault();
  apply({ value, caret, key: event.key, step });
}

applyPaste

Like apply, but for pasted text. It understands 11/12/2026, the dotted and dashed versions, bare digits like 11122026, and ISO 2026-12-11, which it reorders to day-month-year.

import { applyPaste } from "ictus";

applyPaste({
  value: "",
  caret: 0,
  pasted: "2026-12-11",
});
// { value: "11.12.2026", caret: 10 }

parseDate

Turns a finished value into a Date at local midnight. Anything partial or impossible, like 31 February, returns undefined. Any of ., /, and - works as the separator. Pass min and max to also reject days outside a range.

parseDate("11.12.2026"); // local Date
parseDate("11/12/2026"); // same
parseDate("11.12.2026", { min, max });
parseDate("32.01.2020"); // undefined
parseDate("31.02.2020"); // undefined
parseDate("11.12");      // undefined




    

dateStatus

Tells you what feedback to show. Returns "empty", "incomplete", "invalid" (complete, but not a real date), or "valid".

dateStatus("");           // "empty"
dateStatus("11.12");      // "incomplete"
dateStatus("31.02.2020"); // "invalid"
dateStatus("11.12.2026"); // "valid"

formatDate

The reverse of parseDate. Writes a Date as dd.mm.yyyy from its local day, month, and year, with the separator you pass.

formatDate(new Date(2026, 11, 11));
// "11.12.2026"

formatDate(new Date(2026, 11, 11), "/");
// "11/12/2026"

isDateMaskKey

Returns true for keys apply handles. Check it in keydown before calling preventDefault, so arrows, Tab, and shortcuts keep working. Block other printable keys on the input yourself (the demos here do) so letters never land in the field value.

if (!isDateMaskKey(event.key)) {
  if (
    event.key.length === 1 &&
    !event.ctrlKey &&
    !event.metaKey &&
    !event.altKey
  ) {
    event.preventDefault();
  }
  return;
}
event.preventDefault();

bindDateMask

The simplest way to use ictus without a framework. It listens for typing and pasting on an input, applies the mask, and keeps the caret in place. Call the function it returns to remove the listeners.

import { bindDateMask } from "ictus";

const unbind = bindDateMask(input, { separator: "." });
// later: unbind();

applyTime / applyTimePaste

The time mask mirrors apply. Default shape is HH:mm with separator :. Pass precision: "second" for HH:mm:ss. Users may type :, ., or - to commit a group; the field always writes the configured separator.

import { applyTime, applyTimePaste } from "ictus/time";

applyTime({ value: "", caret: 0, key: "9" });
// { value: "09:", caret: 3 }

applyTimePaste({ value: "", caret: 0, pasted: "14:30" });
// { value: "14:30", caret: 5 }

parseTime / timeStatus / formatTime

parseTime returns a TimeValue ({ hours, minutes, seconds }) for a complete clock-valid mask, or undefined. With minute precision, seconds is 0. timeStatus classifies like dateStatus. formatTime accepts a Date or TimeValue.

parseTime("14:30");
// { hours: 14, minutes: 30, seconds: 0 }

timeStatus("24:00"); // "invalid"
formatTime({ hours: 9, minutes: 5, seconds: 0 }, "."); // "09.05"

bindTimeMask

Same wiring as bindDateMask, for time fields.

import { bindTimeMask } from "ictus/time";

const unbind = bindTimeMask(input, {
  separator: ":",
  // precision: "second",
});

Types

type ApplyInput = {
  value: string;
  caret: number;
  selectionEnd?: number;
  key: string;
  separator?: string;
  mode?: "dmy" | "mdy" | "ymd";
  step?: number; // default 0 (off)
};

type ApplyPasteInput = {
  value: string;
  caret: number;
  selectionEnd?: number;
  pasted: string;
  separator?: string;
  mode?: "dmy" | "mdy" | "ymd";
};

type ApplyResult = {
  value: string;
  caret: number;
};

type DateStatus = "empty" | "incomplete" | "invalid" | "valid";

type TimeFieldPrecision = "minute" | "second";

type TimeValue = {
  hours: number;
  minutes: number;
  seconds: number;
};

type TimeStatus = "empty" | "incomplete" | "invalid" | "valid";

Vanilla <input>

Use bindDateMask. If the value lives outside the input, pass getValue and setValue. To handle events entirely yourself, call apply and isDateMaskKey directly.

import { bindDateMask } from "ictus";

const input = document.querySelector("input");
const unbind = bindDateMask(input, {
  separator: ".",
  onValueChange: (value) => console.log(value),
});

// later: unbind();

useDateFieldMask

Import from ictus/react. React is an optional peer dependency, so the core stays dependency-free. Spread inputProps onto your input and the hook handles typing, pasting, and the caret.

parsed
The local Date, or undefined until the value is complete and valid.
status
The same result as dateStatus.
isoValue, hiddenInputProps
The date as YYYY-MM-DD, ready to submit with a form.

Pass value and onValueChange for a controlled field, or defaultValue for an uncontrolled one. onParsedChange fires when the date itself changes. min and max work as in parseDate.

import { useState } from "react";
import { useDateFieldMask } from "ictus/react";

function DateInput() {
  const [value, setValue] = useState("");
  const { inputProps, hiddenInputProps, isoValue, parsed, status } =
    useDateFieldMask({
      value,
      onValueChange: setValue,
      onParsedChange: (date) => console.log(date, isoValue),
    });
  return (
    <>
      <input {...inputProps} />
      <input name="date" {...hiddenInputProps} />
    </>
  );
}

useTimeFieldMask

Same shape as useDateFieldMask, for time. isoValue is always colon-separated (HH:mm or HH:mm:ss). parsed is a TimeValue or undefined.

import { useTimeFieldMask } from "ictus/react";

function TimeInput() {
  const { inputProps, hiddenInputProps, parsed, status } = useTimeFieldMask({
    onParsedChange: (time) => console.log(time),
  });
  return (
    <>
      <input {...inputProps} />
      <input name="time" {...hiddenInputProps} />
    </>
  );
}