moment-less
v0.2.1
Published
Zero-dependency token-based formatting for the native JavaScript Temporal API
Maintainers
Readme
moment-less
Moment.js muscle memory, Temporal-native engine.
Format the native JavaScript Temporal API with the YYYY-MM-DD tokens you already know — zero dependencies, ~2.2 KB gzipped, no legacy Date anywhere in sight.
Documentation · API Reference · Migrating from Moment.js
import { format, fromNow, calendar } from 'moment-less';
format(Temporal.Now.plainDateISO(), 'MMMM Do, YYYY'); // "April 9th, 2026"
format(Temporal.Now.plainDateTimeISO(), 'ddd h:mm A'); // "Thu 2:05 PM"
fromNow(Temporal.Now.instant().subtract({ hours: 3 })); // "3 hours ago"
calendar(Temporal.Now.plainDateTimeISO()); // "Today at 2:05 PM"The token syntax you have in muscle memory, wired directly to Temporal's native, immutable, time-zone-aware primitives. No parsing engine, no mutation, no Date.
What is moment-less?
The TC39 Temporal API finally fixes JavaScript's broken Date object — it gives you immutable, time-zone-aware, calendar-correct date primitives. But Temporal deliberately ships without a formatting method: the spec delegates display to Intl.DateTimeFormat, which is powerful but painfully verbose for everyday YYYY-MM-DD HH:mm UI work.
Meanwhile Moment.js is officially in maintenance mode and recommends migrating to native platform APIs — but everyone migrating still wants to write format(date, 'MMM D, YYYY') without pulling in a 72 KB library that doesn't even understand Temporal objects.
moment-less is the missing bridge. It is intentionally scoped to one job — turn a Temporal object into a string — and leaves date math, parsing, and time-zone conversion to Temporal, which already does them better than any library can.
Moment.js token syntax ──► moment-less ──► native Temporal fields
(the part you keep) (the part Temporal already nails)Why moment-less?
- 🔤 Token syntax you already know —
YYYY,MMMM,Do,dddd,HH:mm,A/a, and more. If you know Moment.js or day.js, there is nothing new to learn. - 📅 Every Temporal type —
PlainDate,PlainTime,PlainDateTime,ZonedDateTime, andInstant. Each type exposes exactly the tokens that make sense for it, with a descriptive error if you ask for one it can't provide. - 🕐 More than formatting — relative time (
fromNow), calendar labels (calendar), duration humanizing (humanizeDuration), and a legacy-Datebridge (fromDate). - 🌍 Locale-aware — month names, weekday names, and relative phrases adapt to any BCP 47 locale through the built-in
IntlAPIs. No locale bundles to ship. - 📦 Zero production dependencies — nothing to audit, nothing to update. ~2.2 KB gzipped for the whole library; ~1.3 KB if you only import
format. - 🧩 Tree-shakeable & typed — every function is an independent named export, with strict bundled TypeScript types (no
@types/*package). - 🌐 Universal runtime — browser, Node.js, Deno, Bun, Cloudflare Workers, Vercel Edge.
Install
npm install moment-less
# or
pnpm add moment-less
# or
yarn add moment-lessRequires the Temporal API. It ships natively in Node 22+, Chrome 127+, Firefox 139+, Safari 18.2+, Deno 2.1+, and Bun 1.2+. For older targets, drop in a polyfill — see Runtime & browser support.
Quick start
import { format, fromNow, calendar, humanizeDuration, fromDate } from 'moment-less';
// 1. Token-based formatting — works on any Temporal type
const dt = Temporal.PlainDateTime.from('2026-04-09T14:05:30');
format(dt, 'YYYY-MM-DD'); // "2026-04-09"
format(dt, 'dddd, MMMM Do [at] h:mm A'); // "Thursday, April 9th at 2:05 PM"
// 2. Relative time — no manual duration math
fromNow(dt.subtract({ hours: 3 }), dt); // "3 hours ago"
// 3. Calendar labels — like chat apps and file managers
calendar(dt, Temporal.PlainDate.from('2026-04-09')); // "Today at 2:05 PM"
// 4. Humanize a duration
humanizeDuration(Temporal.Duration.from({ hours: 2, minutes: 45 })); // "3 hours"
// 5. Bridge a legacy Date from an API or DB driver
format(fromDate(new Date()), 'YYYY-MM-DD HH:mm');Core concept: types decide tokens
A Temporal object only carries the fields its type makes sense for — a PlainDate has no clock, a PlainTime has no calendar. moment-less honours that: using a token a type can't supply throws a descriptive error instead of inventing data.
| Token group | PlainDate | PlainTime | PlainDateTime | ZonedDateTime | Instant¹ |
|-------------|:-----------:|:-----------:|:---------------:|:---------------:|:----------:|
| Year / month / day (YYYY MMMM Do …) | ✅ | ❌ | ✅ | ✅ | ✅ |
| Weekday (dddd ddd) | ✅ | ❌ | ✅ | ✅ | ✅ |
| Time (HH h mm ss SSS A/a) | ❌ | ✅ | ✅ | ✅ | ✅ |
| UTC offset (Z ZZ) | ❌ | ❌ | ❌ | ✅ | ✅ |
| Unix timestamp (X x) | ❌ | ❌ | ❌ | ✅ | ✅ |
¹ An Instant has no time zone of its own, so moment-less normalizes it to a UTC ZonedDateTime before formatting. Every field is then available and the offset is always +00:00.
format(Temporal.PlainDate.from('2026-04-09'), 'HH:mm');
// ❌ Error: Token "HH" requires an hour field
// (not available on PlainDate — use PlainDateTime or ZonedDateTime)API reference
All functions are pure, side-effect-free, and independently importable.
format(temporalObj, formatString, options?)
Converts any Temporal object into a string using Moment.js-style tokens.
| Parameter | Type | Description |
|-----------|------|-------------|
| temporalObj | PlainDate \| PlainTime \| PlainDateTime \| ZonedDateTime \| Instant | The value to format. |
| formatString | string | A pattern of tokens and literal text. |
| options.locale | string (optional) | BCP 47 tag for localized month/weekday names (e.g. 'fr', 'ja'). |
import { format } from 'moment-less';
const d = Temporal.PlainDate.from('2026-04-09');
format(d, 'YYYY-MM-DD'); // "2026-04-09"
format(d, 'MMMM Do, YYYY'); // "April 9th, 2026"
format(d, 'ddd, MMM D'); // "Thu, Apr 9"
const dt = Temporal.PlainDateTime.from('2026-04-09T14:05:59.123');
format(dt, 'hh:mm A'); // "02:05 PM"
format(dt, 'HH:mm:ss.SSS'); // "14:05:59.123"
const zdt = Temporal.ZonedDateTime.from('2026-04-09T14:05[Asia/Tokyo]');
format(zdt, 'YYYY-MM-DDTHH:mmZ'); // "2026-04-09T14:05+09:00"
format(Temporal.Now.instant(), 'X'); // "1775736000" (Unix seconds, UTC)
// Localized names
format(d, 'dddd D MMMM', { locale: 'fr' }); // "jeudi 9 avril"Escaping literal text. Wrap any text in [square brackets] to emit it verbatim — essential when literal words contain token letters:
format(dt, '[Today is] dddd'); // "Today is Thursday"
format(dt, 'h [o\'clock]'); // "2 o'clock"
format(dt, 'YYYY-MM-DD[T]HH:mm[Z]'); // "2026-04-09T14:05Z" (literal T and Z)fromNow(temporalObj, reference?, locale?)
Returns a human-readable relative-time string, powered by the native Intl.RelativeTimeFormat — no manual thresholds, fully localized.
| Parameter | Type | Description |
|-----------|------|-------------|
| temporalObj | PlainDate \| PlainDateTime \| ZonedDateTime \| Instant | The target time. PlainTime is unsupported (no date context). |
| reference | Temporal.Instant (optional) | The "now" to measure against. Defaults to Temporal.Now. Pass an explicit value for deterministic tests. |
| locale | string (optional) | BCP 47 tag for the output phrase. |
import { fromNow } from 'moment-less';
fromNow(Temporal.Now.instant().subtract({ hours: 3 })); // "3 hours ago"
fromNow(Temporal.Now.plainDateISO().add({ days: 1 })); // "tomorrow"
fromNow(Temporal.Now.instant().subtract({ years: 1 })); // "last year"
// Deterministic: pass your own reference instant
fromNow(target, someFixedInstant); // "in 2 days"
// Localized
fromNow(target, undefined, 'fr'); // "il y a 3 heures"
fromNow(target, undefined, 'es'); // "hace 3 horas"Date-aware by design. When you pass a
PlainDate, moment-less compares calendar days, not raw instants — sofromNow(today)is reliably"today"andfromNow(tomorrow)is"tomorrow", regardless of the time of day or time zone.
calendar(temporalObj, reference?, options?)
Returns a context-aware calendar label in the style of Moment's .calendar() — the kind messaging apps and file managers use.
| Parameter | Type | Description |
|-----------|------|-------------|
| temporalObj | PlainDate \| PlainDateTime \| ZonedDateTime \| Instant | The date to label. |
| reference | Temporal.PlainDate (optional) | The "today" to compare against. Defaults to Temporal.Now. |
| options.locale | string (optional) | Locale for weekday/month names and the time portion. |
| options.timeFormat | string (optional) | Token string for the time suffix. Default 'h:mm A'. |
| options.labels | { today?, yesterday?, tomorrow?, at? } (optional) | Override the day words and the at connector (for i18n or custom copy). |
| Distance from reference | Output |
|-------------------------|--------|
| Same day | Today at 2:05 PM |
| 1 day before / after | Yesterday at 11:30 AM / Tomorrow at 9:00 AM |
| Within the surrounding week | Monday at 2:05 PM |
| Further away | Apr 9, 2026 |
import { calendar } from 'moment-less';
calendar(Temporal.Now.plainDateTimeISO()); // "Today at 2:05 PM"
calendar(yesterdayDateTime); // "Yesterday at 11:30 AM"
calendar(oldDate); // "Nov 15, 2025"
// Localized labels + 24-hour time + localized connector
calendar(pastDateTime, undefined, {
locale: 'fr',
timeFormat: 'HH:mm',
labels: { today: "Aujourd'hui", yesterday: 'Hier', at: 'à' },
}); // "Hier à 14:05"A PlainDate input produces a date-only label (no at … suffix).
humanizeDuration(duration, locale?)
Converts a Temporal.Duration into the most significant single-unit phrase — magnitude only, no "ago"/"in" direction.
| Parameter | Type | Description |
|-----------|------|-------------|
| duration | Temporal.Duration | The duration to describe. Sign is ignored (absolute magnitude). |
| locale | string (optional) | BCP 47 tag for the output phrase. |
import { humanizeDuration } from 'moment-less';
humanizeDuration(Temporal.Duration.from({ hours: 2, minutes: 45 })); // "3 hours"
humanizeDuration(Temporal.Duration.from({ seconds: 45 })); // "45 seconds"
humanizeDuration(Temporal.Duration.from({ days: 400 })); // "1 year"
humanizeDuration(Temporal.Duration.from({ seconds: 45 }), 'fr'); // "45 secondes"Months and years are calendar-approximate (30 and 365 days respectively); sub-second components down to nanoseconds are counted toward the total.
fromDate(date)
Bridges a legacy JavaScript Date (from an API, DB driver, or third-party lib) to a Temporal.Instant, ready to hand to format or fromNow.
| Parameter | Type | Description |
|-----------|------|-------------|
| date | Date | A legacy Date object. |
| returns | Temporal.Instant | The equivalent instant (millisecond precision). |
import { fromDate, format } from 'moment-less';
const instant = fromDate(new Date('2026-04-09T14:05:00Z'));
format(instant, 'YYYY-MM-DD HH:mm:ss'); // "2026-04-09 14:05:00"Token reference
| Token | Description | Example |
|-------|-------------|---------|
| YYYY | 4-digit year | 2026 |
| YY | 2-digit year, zero-padded | 26 |
| MMMM | Full month name | April |
| MMM | Short month name | Apr |
| MM | Month, zero-padded | 04 |
| M | Month, no padding | 4 |
| Do | Day of month, ordinal | 9th |
| DD | Day of month, zero-padded | 09 |
| D | Day of month, no padding | 9 |
| dddd | Full weekday name | Thursday |
| ddd | Short weekday name | Thu |
| HH | Hour (24h), zero-padded | 14 |
| H | Hour (24h), no padding | 14 |
| hh | Hour (12h), zero-padded | 02 |
| h | Hour (12h), no padding | 2 |
| mm | Minute, zero-padded | 05 |
| ss | Second, zero-padded | 59 |
| SSS | Millisecond, zero-padded | 007 |
| A | AM / PM (uppercase) | PM |
| a | am / pm (lowercase) | pm |
| Z | UTC offset, with colon | +09:00 |
| ZZ | UTC offset, no colon | +0900 |
| X | Unix timestamp (seconds) | 1775736000 |
| x | Unix timestamp (milliseconds) | 1775736000000 |
| […] | Escaped literal text | [at] → at |
Tokens are matched longest-first (
MMMMbeforeMMMbeforeMMbeforeM,DobeforeDDbeforeD), so there is no ambiguity. Any unbracketed letters that form a token are interpreted as one — wrap literal words likeDay, or a trailingZ, in[…]. See the type matrix for which tokens each Temporal type supports.
Locale support
format localizes month and weekday names via options.locale; fromNow, calendar, and humanizeDuration localize their phrases via their locale argument. Everything flows through the platform's Intl — there are no locale data files to import or ship.
format(date, 'dddd D MMMM', { locale: 'ja' }); // "木曜日 9 4月"
fromNow(past, undefined, 'es'); // "hace 3 horas"
humanizeDuration(dur, 'de'); // "2 Stunden"Numeric tokens (Do ordinal suffix, A/a) currently render in English regardless of locale.
Migrating from Moment.js
Moment.js is in maintenance mode and recommends migrating to native platform APIs. With Temporal handling the data and moment-less handling the display, the move is nearly token-transparent:
// Before — Moment.js (~72 KB gzipped, mutable, legacy Date)
import moment from 'moment';
moment().format('YYYY-MM-DD');
moment().add(1, 'days').format('DD/MM/YYYY');
moment(someDate).fromNow();
// After — moment-less + Temporal (~2.2 KB gzipped, immutable, no legacy Date)
import { format, fromNow } from 'moment-less';
format(Temporal.Now.plainDateISO(), 'YYYY-MM-DD');
format(Temporal.Now.plainDateISO().add({ days: 1 }), 'DD/MM/YYYY');
fromNow(Temporal.Now.instant());What moves to Temporal (and why that's better):
- Parsing →
Temporal.PlainDate.from('2026-04-09')instead ofmoment('2026-04-09')— strict, unambiguous, no silent fallbacks. - Manipulation → Temporal's built-in
.add()/.subtract()/.until()— immutable, returns new objects. - Time zones →
Temporal.ZonedDateTimedirectly — first-class, DST-correct, no plugin.
moment-less vs moment.js, date-fns, and day.js
| Library | Zero deps | Works with Temporal | Gzipped | Token syntax |
|---------|:---------:|:-------------------:|--------:|:------------:|
| moment-less | ✅ | ✅ native | ~2.2 KB | ✅ |
| moment | ❌ | ❌ legacy Date | ~72 KB | ✅ |
| date-fns | ❌ | ❌ legacy Date | ~13 KB (tree-shaken) | ✅ |
| day.js | ❌ | ❌ legacy Date | ~2.9 KB | ✅ |
| Intl.DateTimeFormat | ✅ | ✅ via Instant | 0 B | ❌ verbose |
moment-less is intentionally scoped: format Temporal objects, nothing else. Date arithmetic, parsing, and time-zone conversion are Temporal's job.
Runtime & browser support
moment-less is a pure formatting utility — no DOM, no Node built-ins. It runs anywhere Temporal is available, natively or polyfilled.
Native Temporal (no polyfill):
| Runtime | Minimum version | |---------|-----------------| | Node.js | 22.0+ | | Chrome / Edge | 127+ | | Firefox | 139+ | | Safari | 18.2+ | | Deno | 2.1+ | | Bun | 1.2+ |
Older environments — install a polyfill and import it once at your entry point:
npm install temporal-polyfillimport 'temporal-polyfill/global'; // installs Temporal on globalThis
import { format } from 'moment-less';
format(Temporal.Now.plainDateISO(), 'YYYY-MM-DD');moment-less has no opinion on where Temporal comes from — native or polyfilled, it behaves identically. It also works out of the box on V8-based edge runtimes (Cloudflare Workers, Vercel Edge).
Bundle size
| Import | Gzipped |
|--------|--------:|
| Full library (all five functions) | ~2.2 KB |
| format + fromNow | ~1.7 KB |
| format only (tree-shaken) | ~1.3 KB |
Zero production dependencies.
FAQ
How do I format a date as YYYY-MM-DD with Temporal?
format(Temporal.Now.plainDateISO(), 'YYYY-MM-DD'). No setup beyond the import.
Is this a drop-in Moment.js replacement?
For formatting and relative time, yes — the token syntax is intentionally compatible. For parsing and date math, use Temporal directly (.from(), .add(), .subtract()); Temporal replaces those parts of Moment natively.
Does it polyfill Temporal?
No. moment-less is formatting only. Use temporal-polyfill or @js-temporal/polyfill where Temporal isn't native yet.
Why not just use Intl.DateTimeFormat?
It's great for locale-sensitive display but unwieldy for structural formats like YYYY-MM-DD HH:mm:ss — you'd call .formatToParts(), reduce the array, and zero-pad by hand. moment-less makes that a one-liner.
Does it support TypeScript?
Fully. Types are bundled — no separate @types/moment-less. Every Temporal type is inferred correctly.
Is it tree-shakeable?
Yes. Each function is an independent named export; importing only format ships ~1.3 KB gzipped.
Documentation
Full guides, the complete API reference, and framework recipes (React, Vue) live at thinkgrid-labs.github.io/moment-less.
Contributing
Issues and pull requests are welcome.
pnpm install
pnpm test # run the suite
pnpm test:coverage # with coverage
pnpm typecheck # strict type checkLicense
MIT © thinkgrid-labs
