WebberUI

ROC (Minguo) Date Picker (React)

A React date picker that toggles between the ROC (Minguo) calendar and the Gregorian one — a grid calendar with a "民國 115 年" (Minguo year) dropdown, typed input like "115/08/18" or "2026-08-18" parsed with an IME guard, and ISO output that also carries the ROC and Gregorian forms.

Taiwanese forms (birthdays, ID expiry dates, insurance policies) are written in ROC (Minguo) years, but back ends want Gregorian ISO strings. This component bridges the two: the text input accepts "115/08/18", "2026-08-18", "民國115年8月18日" (Minguo 115, Aug 18) or even "1150818", and parses on blur or Enter (an Enter pressed while a Chinese IME is composing is ignored); the button on the right opens a calendar popover whose "民國|西元" (ROC / Gregorian) segmented control re-labels every year instantly (ROC = Gregorian − 1911, and years before the ROC epoch are shown as Gregorian, unconverted), with a year dropdown, month arrows, a 7-column day grid, a today marker and min/max disabled ranges. value is always an ISO string (YYYY-MM-DD), and onChange also hands you the ROC and Gregorian triples plus the display string for the current calendar.

Loading preview…
npx shadcn@latest add https://webberui.com/r/roc-date-picker.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

<RocDatePicker />

Installation

npx shadcn@latest add https://webberui.com/r/roc-date-picker.json

Or, once registries are configured in components.json, install it as @webberui/roc-date-picker.

Usage

import {
  RocDatePicker,
  formatRocDate,
  parseRocDateInput,
} from "@/components/ui/roc-date-picker";

const [iso, setIso] = React.useState("2026-08-18");

<RocDatePicker
  value={iso}
  onChange={(next, meta) => {
    setIso(next); // "2026-08-18"; "" when cleared
    if (meta) {
      console.log(meta.roc); // { year: 115, month: 8, day: 18 }
      console.log(meta.gregorian); // { year: 2026, month: 8, day: 18 }
      console.log(meta.display); // "115/08/18" in ROC mode, "2026/08/18" in Gregorian mode
    }
  }}
  label="保單生效日"
  min="2026-01-01"
  max="2027-12-31"
/>;

// The parser and formatter can be used on their own (importing legacy data, printing ROC years)
parseRocDateInput("115/08/18"); // "2026-08-18"
parseRocDateInput("民國115年8月18日"); // "2026-08-18"
parseRocDateInput("2026-08-18"); // "2026-08-18"
formatRocDate("2026-08-18", "roc"); // "115/08/18"
formatRocDate("2026-08-18", "gregorian", "-"); // "2026-08-18"

Props

PropTypeDefaultDescription
valuestring | nullControlled value: an ISO date string (YYYY-MM-DD); an empty string or null means nothing is selected. When omitted the component is uncontrolled
defaultValuestring""Initial value in uncontrolled mode (ISO string)
onChange(iso: string, meta: RocDateChangeMeta | null) => voidCallback when the value changes: the first argument is the ISO string (empty when cleared), the second carries the ROC, Gregorian and display forms (null when cleared)
calendar"roc" | "gregorian"Controlled calendar: ROC (roc) or Gregorian (gregorian); when omitted the built-in toggle manages it
defaultCalendar"roc" | "gregorian""roc"Initial calendar in uncontrolled mode
onCalendarChange(calendar: RocCalendar) => voidCallback when the user flips the "民國|西元" (ROC / Gregorian) toggle
weekStartsOn0 | 10Whether the week starts on Sunday (0) or Monday (1)
minstringEarliest selectable date (ISO string, inclusive); earlier days are disabled and typed input outside the range is rejected
maxstringLatest selectable date (ISO string, inclusive); later days are disabled and typed input outside the range is rejected
labelstring"日期" (Date)Field label text
placeholderstringInput placeholder; when omitted it follows the calendar — "例:115/08/18" (e.g. 115/08/18) or "例:2026/08/18"
disabledbooleanfalseDisable the field
namestringForm field name: the ISO string is submitted through a hidden input (the visible text input carries no name)
classNamestringForwarded to the outermost container

RocDateChangeMeta

FieldTypeDefaultDescription
rocRocDateROC year/month/day (year is 0 or negative for dates in 1911 or earlier)
gregorianRocDateGregorian year/month/day
displaystringDisplay string formatted for the current calendar ("115/08/18" or "2026/08/18")

RocDate

FieldTypeDefaultDescription
yearnumberYear (ROC or Gregorian depending on context)
monthnumberMonth (1–12)
daynumberDay (1–31)

Named helper exports

  • parseRocDateInput(raw: string): string | null — parses user-typed text into an ISO string, returning null on failure (rules under "How it works")
  • formatRocDate(iso: string, calendar?: RocCalendar, separator?: string): string — formats an ISO date for the given calendar; defaults to ROC mode with a "/" separator, and returns "" for an invalid ISO string
  • toRocDate(iso: string): RocDate | null — ISO string to ROC year/month/day
  • fromRocDate(roc: RocDate): string | null — ROC year/month/day to ISO string; returns null for dates that do not exist (such as Feb 30)

The RocCalendar type ("roc" | "gregorian") is exported as well, so the same conversion logic can be shared at the form layer.

How it works

  • Parsing rules: "115/08/18", "115-8-18", "115.08.18", "民國115年8月18日", "1150818" as well as "2026-08-18", "2026/08/18", "2026年8月18日" and "20260818" are all accepted; full-width digits and separators are normalized first. A year of 1000 or more is treated as Gregorian, anything smaller as ROC (+1911); a "民國" (Minguo) prefix always forces ROC; 8 plain digits are read as Gregorian YYYYMMDD, 6–7 digits as ROC (Y)YYMMDD
  • When parsing fails or the date is outside min/max, the input turns red, explains why and sets aria-invalid; the previous value is left untouched. Clearing the input and blurring counts as clearing the value
  • ROC year 1 is Gregorian 1912. In ROC mode, dates in 1911 or earlier keep Gregorian years in the header, the dropdown and the display string — you never see "民國 −3 年"; meta.roc.year still reports 0 or a negative number so you can handle it yourself
  • The year dropdown covers ROC year 1 through 2100 by default; with min/max it is narrowed to that span, and the month arrows are disabled at the boundary months
  • The today marker and the "今天" (Today) button use the device clock and are computed after mount, so SSR and the first client paint match; the button is disabled when today is outside the selectable range
  • The popover opens with a spring, month changes slide the old and new grids past each other horizontally (AnimatePresence mode="popLayout"), and the selected cell pops in with a scale bounce; the grid is fixed at 6 rows / 42 cells so its height never jumps between months
  • When name is given, a hidden input submits the ISO string; the visible input carries no name, so native forms always receive YYYY-MM-DD

Accessibility

  • The input is associated with its label; the hint / error message lives in a role="status" (aria-live="polite") region referenced by aria-describedby, so parse results are read out by screen readers, and aria-invalid is set on failure
  • The trigger is a type="button" with aria-haspopup="dialog", aria-expanded and aria-controls; the popover is a role="dialog" that closes on Esc (returning focus to the trigger), on outside clicks and when focus leaves the component
  • The calendar uses role="grid" / row / columnheader / gridcell, with aria-selected on the chosen day, aria-current="date" on today and aria-disabled on out-of-range days; every cell has a full aria-label (date + weekday + today / unavailable)
  • The grid uses a roving tabindex, so only one cell is in the Tab order; arrow keys move by a day or a week, Home / End jump to the start / end of the week, PageUp / PageDown change the month (with Shift, the year), and Enter or Space selects; the visible month follows the focused cell across month boundaries
  • The calendar toggle is a role="group" of two aria-pressed buttons and the year dropdown is a native select, so keyboard and screen-reader behavior matches ordinary form controls
  • Enter in the input checks isComposing and keyCode 229 before parsing, so nothing is submitted mid-composition in a Chinese IME
  • When the user has "reduce motion" enabled at the system level, the popover open/close, month slide, selection bounce and hint transitions all switch instantly, leaving only the state and color changes

On this page