Frontend Development 29 min read

Implementing a Xiaomi‑Style Calendar Component with React, TypeScript, TailwindCSS and Lunar Library

This article details how the author recreated Xiaomi's calendar using React functional components, TypeScript typings, TailwindCSS styling, and the lunar‑typescript library, covering the tech stack, core date calculations, view rendering, touch‑based scrolling, and view mode switching with complete source code examples.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Implementing a Xiaomi‑Style Calendar Component with React, TypeScript, TailwindCSS and Lunar Library

The author, Lee, a front‑end developer with two years of experience, explains why they decided to replicate Xiaomi's calendar as a learning project and to explore the React ecosystem after previously working with Vue.

The goal is to build a feature‑rich calendar component that supports month, year, and week views, displays lunar (Chinese) calendar information, holidays, and custom festivals, while demonstrating the use of modern front‑end technologies.

Tech Stack : React 18.2.0, TypeScript 4.x, lunar‑typescript 1.7.1, TailwindCSS, Moment.js, and the HolidayUtil library for holiday detection.

React Overview : React is introduced as a mainstream front‑end framework with a virtual DOM, component‑based architecture, and lifecycle hooks, similar to Vue, enabling declarative UI updates.

TypeScript Overview : TypeScript adds static type checking to JavaScript, offering interfaces, generics, abstract classes, and literal type constraints, which help catch errors during development.

Lunar Library : The lunar‑typescript library provides comprehensive lunar and solar date data, including festivals, solar terms, and zodiac information, which the component uses to render detailed day info.

Core Problem – Month and Day Calculation :

import moment from 'moment';
// Get number of days in the current month
moment().daysInMonth();
// Get the weekday of the first day of the month
moment().startOf('month').format('d');
// Native Date API for previous month days
new Date(2024, 0, 0); // 2023‑12‑31
new Date(2024, 0, -1); // 2023‑12‑30
new Date(2024, 0, 32); // 2024‑02‑01

Generating the Day List for a Month :

function getMonthList(timeInfo, dayPosition) {
  const { yearOnView, monthOnView, viewMode } = timeInfo;
  let curDate = null;
  if (viewMode === ViewMode.YEAR) {
    curDate = moment(`${yearOnView + dayPosition}-${monthOnView}`);
  } else if (viewMode === ViewMode.MONTH) {
    curDate = moment(`${yearOnView}-${monthOnView + dayPosition}`);
  }
  // Adjust for overflow/underflow months
  if (viewMode === ViewMode.MONTH) {
    if (monthOnView + dayPosition === 0) {
      curDate = moment(`${yearOnView - 1}-12`);
    } else if (monthOnView + dayPosition === 13) {
      curDate = moment(`${yearOnView + 1}-1`);
    }
  }
  const daysInMonth = curDate.daysInMonth();
  const startWeekDay = parseInt(curDate.startOf('month').format('d'));
  const dayList = [];
  for (let i = 0; i < daysInMonth + startWeekDay; i++) {
    if (!dayList.length || dayList[dayList.length - 1]) {
      dayList.push(...new Array(7).fill(null));
    }
    const day = i - startWeekDay + 1;
    dayList[i] = getDetailDayInfo(buildDetailDayInfoParams({
      year: yearOnView,
      month: monthOnView - 1,
      day,
      offset: dayPosition,
      viewMode,
      timeInfo,
    }), timeInfo);
  }
  // Fill trailing nulls
  let index = dayList.findIndex(d => !d);
  if (index !== -1) {
    for (; index < dayList.length; index++) {
      const day = index - startWeekDay + 1;
      dayList[index] = getDetailDayInfo(buildDetailDayInfoParams({
        year: yearOnView,
        month: monthOnView - 1,
        day,
        offset: dayPosition,
        viewMode,
        timeInfo,
      }), timeInfo);
    }
  }
  return dayList;
}

Detail Day Information (including lunar data) :

export function getDetailDayInfo(date, timeInfo) {
  const lunar = Lunar.fromDate(date);
  const solar = Solar.fromDate(date);
  const momentDate = moment(date);
  const weekDay = momentDate.format('dddd');
  const { year, month, day, yearOnView, monthOnView, dayOnView } = timeInfo;
  const dayInfo = {
    day: Solar.fromDate(date).getDay(),
    chineseDay: lunar.getDayInChinese(),
    isWeekend: weekDay === 'Saturday' || weekDay === 'Sunday',
    weekDay,
    fullDate: momentDate.format('YYYY-MM-DD'),
    dateFromTheMonth: date.getMonth() + 1 === timeInfo.monthOnView,
    isToday: momentDate.isSame(moment(`${year}-${month}-${day}`), 'day'),
    isSelected: momentDate.isSame(moment(`${yearOnView}-${monthOnView}-${dayOnView}`), 'day'),
    yiList: lunar.getDayYi(),
    jiList: lunar.getDayJi(),
    chineseDateName: '农历' + lunar.getMonthInChinese() + '月' + lunar.getDayInChinese(),
    chineseYearName: lunar.getYearInGanZhi() + lunar.getShengxiao() + '年',
    chineseMonthName: lunar.getMonthInGanZhi() + '月',
    chineseDayName: lunar.getDayInGanZhi() + '日',
  };
  // Special festival handling
  if (dayInfo.chineseDateName.includes('腊月廿三')) dayInfo.chineseDay = '北方小年';
  else if (dayInfo.chineseDateName.includes('腊月廿四')) dayInfo.chineseDay = '南方小年';
  const holiday = HolidayUtil.getHoliday(momentDate.format('YYYY-MM-DD'));
  if (holiday) {
    dayInfo.isRestDay = !holiday.isWork();
    dayInfo.isWorkDay = holiday.isWork();
  }
  const season = lunar.getJieQi();
  const festivals = [...solar.getFestivals(), ...lunar.getFestivals()];
  dayInfo.festivalList = festivals;
  if (festivals.length && festivals[0].length < 4) {
    dayInfo.chineseDay = festivals[0];
  } else if (season) {
    dayInfo.chineseDay = season;
  } else if (lunar.getDay() === 1) {
    dayInfo.chineseDay = lunar.getMonthInChinese() + '月';
  }
  return dayInfo;
}

View Rendering : The component creates a context ( TimeInfoContext ) to share state, then renders month, year, or week grids based on viewMode . Month view uses a 7‑column grid, year view a 3‑column grid, and each cell is styled according to holiday, weekend, today, or selected status.

Touch‑Based Scrolling :

const divEleRef = useRef(null);
useEffect(() => {
  let startTime = 0, startTouch = {};
  const onTouchStart = e => { startTouch = e.changedTouches[0]; startTime = e.timeStamp; };
  const onTouchMove = e => { setDivEleTranslateXState(e.changedTouches[0].pageX - startTouch.pageX); };
  const onTouchEnd = e => {
    const distance = e.changedTouches[0].pageX - startTouch.pageX;
    if (e.timeStamp - startTime < 500 && Math.abs(distance) > 20) || Math.abs(distance) > window.innerWidth / 2) {
      // slide to previous/next month or year
      notifyTimeInfoContextUpdate(distance);
    }
    setDivEleTranslateXState(0);
  };
  divEleRef.current.addEventListener('touchstart', onTouchStart, { passive: true });
  divEleRef.current.addEventListener('touchmove', onTouchMove, { passive: true });
  divEleRef.current.addEventListener('touchend', onTouchEnd, { passive: true });
  return () => {
    divEleRef.current.removeEventListener('touchstart', onTouchStart);
    divEleRef.current.removeEventListener('touchmove', onTouchMove);
    divEleRef.current.removeEventListener('touchend', onTouchEnd);
  };
}, [timeInfo]);

View Mode Switching is handled by updating viewMode in the context; clicking the header toggles between month and year, and the header also shows the distance in days from the current date.

The article concludes with a link to the source repository ( we‑del/react‑xiaomi‑calendar ) and invites readers to comment, like, or collect the post.

frontendTypeScriptReactTailwindCSScalendarLunar
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.