ictus
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.
-
Dates:
dd.mm.yyyy,dd/mm/yyyy,dd-mm-yyyy, plusmm/dd/yyyyandyyyy-mm-ddviamode -
Times:
HH:mmorHH:mm:ssviaictus/time -
React:
useDateFieldMaskanduseTimeFieldMask
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.keyvalue: a digit,.,/,-,Backspace, orDelete. 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
ArrowUpandArrowDownchange the group under the caret. Defaults to0, which ignores arrows.isDateMaskKeyreturnsfalsefor 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, orundefineduntil 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} />
</>
);
}