So I built a calendar app. Not because I needed one, but because it seemed like a good way to actually think through some frontend stuff I'd been hand-waving past for a while—date math, range selection, that kind of thing.
It covers all 12 months of 2026. Each month has a full photo at the top, a little spiral binding separator (yes, just CSS), and then a grid below. You can click dates, select ranges across multiple days, and write notes that save in localStorage so they're still there when you come back.
npm install
npm run devOpens at http://localhost:5173. That's it honestly.
React with Vite. The setup is fast and I didn't want to mess around with webpack configs. Tailwind v4 for styling—the new @theme block is actually really nice for defining custom tokens without a separate config file, though the VS Code CSS plugin doesn't know about it yet and shows warnings. Harmless, just annoying.
For dates I used date-fns instead of the usual suspects. Moment.js is basically abandonware at this point, and it adds like 70kb even if you only use one function. date-fns is tree-shakeable so you only pay for what you import. Functions like getDaysInMonth, isWithinInterval, getDay—they just work, and the API is clean.
State management is just useState. I thought about useReducer for the range logic at some point but... honestly it wasn't complex enough to justify it. Three clicks, three states. Simple.
This is the part most people gloss over. The grid is 7 columns wide (Sun through Sat), but months don't start on Sunday. So you need empty cells at the beginning to push day 1 into the right column.
The formula is basically:
startOffset = getDay(firstDayOfMonth)
getDay returns 0 for Sunday, 1 for Monday, all the way to 6. So if a month starts on Wednesday, you get 3 empty null cells before day 1. Then you fill in the actual dates. The whole thing is a flat array you map over—nulls render as empty divs, real dates render as buttons.
April 2026 starts on a Wednesday, so there are 3 blanks, then 1 through 30. That's really all there is to it. Leap years are handled by getDaysInMonth automatically, which is another reason date-fns is worth it—I didn't have to write that logic myself.
One thing I realized mid-build: some months need 6 rows, not 5. May 2026 starts on a Friday, so after the offset you end up with dates spilling into a 6th row. I initially hardcoded the grid height for 5 rows and it clipped the last week of May completely. Fixed it by letting the grid container grow naturally instead of setting a fixed height—grid-template-rows just auto-fills based on content.
Click once—that's your start date. Click again—that's the end. Everything between gets highlighted. Click a third time and it resets. There's an edge case where someone clicks the same date twice... I initially had that just crashing (well, not crashing, but isWithinInterval throwing because start === end is technically an invalid interval). Fixed it so it just opens the note instead.
Ranges also handle reversed selection—if you click July 10 first and then July 3, it still highlights 3 through 10. Just swaps the two internally before doing the interval check.
Notes are stored as a plain object: { "2026-07-04": "some text here" }. I went back and forth on this—considered grouping by month, but then looking up a note by date required filtering. The flat key approach means it's just notes[dateKey] and you're done. Saves to localStorage on every change via a custom hook that wraps useState. Nothing fancy, it just means I don't have to write the useEffect every time.
The month transition animation is a simple fade. I wanted a page-flip—like an actual physical calendar turning—but that's genuinely hard to get right without a library, and I didn't want to pull in something just for one animation. Maybe later.
Keyboard navigation between months would be nice. You can tab through all the day cells, but you can't press left/right arrow to jump months. It's on the list.
And the mobile experience is decent but swipe gestures to change months would feel way more natural. Touch events aren't complicated but they need testing across devices and I didn't have time to do that properly.
src/
├── components/
│ ├── HeroImage.jsx # photo banner with gradient + month overlay
│ ├── CalendarBinding.jsx # the spiral hole separator (pure CSS/JSX)
│ ├── CalendarGrid.jsx # 7-column grid, useMemo for perf
│ ├── DayCell.jsx # each day button—handles all visual states
│ ├── MonthNavigator.jsx # the ← April 2026 → bar
│ └── NotesPanel.jsx # bottom sheet note editor
├── hooks/
│ └── useLocalStorage.js
├── utils/
│ ├── constants.js # month names, image paths, year bounds
│ └── dateHelpers.js # grid builder, range check, date key formatter
├── App.jsx
└── index.css
Built with React, Vite, Tailwind CSS v4, date-fns.