Skip to main content
Overview

Radix UI 및 shadcn/ui 라이브러리로 Calendar 만들기

2025년 12월 16일
5 min read

최근 프로젝트 요구사항에 맞춰 Calendar(DatePicker) 컴포넌트를 전반적으로 개선했습니다.

처음에는 단순한 UI 개선 작업이라고 생각했지만,
구조적 리팩토링(PR #110)을 진행하는 과정에서
UX 문제와 타입 설계의 한계가 자연스럽게 연결되어 있다는 점을 확인하게 되었습니다.

이 글은 Date Picker UX 개선을 계기로,
Calendar 컴포넌트의 구조와 타입 설계를 함께 재정비하게 된 과정을 정리한 기록입니다.

구조 개선 전반에 대한 논의는
관련 Discussion에 정리되어 있으며,
본 글에서는 UX에 직접적인 영향을 준 문제와 그 해결 과정에 집중합니다.


문제 발견 — 사용하면서 드러난 UX 한계

이전 PR(#55)에서
shadcn Calendar 기반으로 날짜/시간 선택 UI를 구성했습니다.

기본 기능은 동작했지만, 실제 사용 과정에서 다음과 같은 UX 문제가 확인되었습니다.

  1. 단일/범위 선택 UX가 직관적이지 않음
    (예: 2~10 선택 후 3 클릭 시 3~10이 아닌 2~3으로 축소)
  2. 지난 날짜(disabled)가 hover / pointer로 인해 클릭 가능한 요소처럼 보임
  3. DayButton 크기가 반응형 환경에서 유지되지 않아 range 배경이 끊겨 보임
  4. today / range / outside 스타일 우선순위 충돌

이 문제들은 모두 날짜 선택 경험과 직결되어 있었고,
단순한 스타일 보완보다는 날짜 선택 규칙 자체를 다시 정의해야 하는 문제로 판단했습니다.

UX 개선 방향

Range 선택 UX 개선 — Start-Date First Selection

기존 구현에서는 range가 선택된 상태에서 날짜를 다시 클릭할 경우,
사용자의 의도와 다른 방식으로 범위가 재조정되는 문제가 있었습니다.

예를 들어,

  • 기존 선택: 2~10
  • 3 클릭
  • 기대: 3~10
  • 실제 동작: 2~3

이 동작은 내부 로직상 일관성은 있었지만,
사용자 입장에서는 다음 동작을 예측하기 어려운 UX로 느껴졌습니다.

이를 해결하기 위해 range 선택 규칙을 다음과 같이 단순화했습니다.

  1. 첫 번째 클릭 → Start-Date 설정
    { from: 2, to: undefined }
  2. 두 번째 클릭 → Range 확정
    { from: 2, to: 10 }
  3. 기존 range가 있는 상태에서 새 날짜 클릭
    → 기존 range 초기화 후 Start-Date 재설정

이 규칙은 onSelect 핸들러에서 다음과 같이 코드로 명시됩니다.

import type { DateRange, OnSelectHandler } from 'react-day-picker'
const handleSelect: OnSelectHandler<DateRange | undefined> = (
_selected,
triggerDate,
) => {
const current = selected
if (!current || (!current.from && !current.to)) {
onSelect?.({ from: triggerDate, to: undefined })
return
}
const anchor = current.from
if (anchor && !current.to) {
const from = triggerDate < anchor ? triggerDate : anchor
const to = triggerDate < anchor ? anchor : triggerDate
onSelect?.({ from, to })
return
}
onSelect?.({ from: triggerDate, to: undefined })
}

UX 규칙이 조건문 구조 그대로 드러나며,
“지금 어떤 상태에서 어떤 클릭이 들어왔는지”를 코드만 보고도 파악할 수 있도록 구성했습니다.

지난 날짜 선택 불가(disablePastDates)

지난 날짜는 모임 생성 시 사용할 수 없기 때문에,
단순히 선택을 막는 수준이 아니라
완전히 비활성화된 상태로 인식되어야 했습니다.

이를 위해 disable 조건을 CalendarRoot 단계에서 병합했습니다.

import { DayPicker, getDefaultClassNames, type Matcher } from 'react-day-picker'
type CalendarRootProps = React.ComponentProps<typeof DayPicker> & {
disablePastDates?: boolean
}
function CalendarRoot({
className,
classNames,
components,
disablePastDates,
...props
}: CalendarRootProps) {
const defaultClassNames = getDefaultClassNames()
const disabledMatchers: Matcher[] = []
if (disablePastDates) {
disabledMatchers.push({ before: today })
}
const disabled = disabledMatchers.length > 0 ? disabledMatchers : undefined
return (
<DayPicker
//...
classNames={{
disabled: cn('text-gray-400', defaultClassNames.disabled),
...classNames,
}}
/>
)
}

이 방식은 개별 mode 로직에서 처리하는 대신,
Calendar 공통 레이어에서 UX 규칙을 강제하는 구조입니다.

그 결과 지난 날짜는

  • 클릭 불가
  • hover / pointer 미노출
  • range 시작점으로도 선택 불가

라는 규칙이 구조 차원에서 보장됩니다.

DayButton 레이아웃 보완

반응형 환경에서 DayButton이 정사각형을 유지하지 못하면서
range 배경이 끊겨 보이는 문제가 확인되었습니다.

이를 해결하기 위해 DayButton을 wrapper + button 구조로 분리했습니다.

function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
//...
return (
<div
className={cn(
'relative aspect-square w-full overflow-hidden',
isRange && 'bg-brand-800',
isStart && 'rounded-l-lg',
isEnd && 'rounded-r-lg',
)}
>
<button
className={cn(
defaultClassNames.day,
//...
)}
//...
/>
</div>
)
}

range 배경과 radius를 wrapper에서 처리하면서,
버튼 크기와 무관하게 연속적인 range 표현이 유지되도록 구성했습니다.

today / range / outside 스타일 우선순위 재정의

기존에는 today, range, outside 상태가 겹칠 경우
시각적으로 어떤 상태가 우선되는지 명확하지 않았습니다.

이를 다음과 같은 우선순위로 재정의했습니다.

  1. range (최우선)
  2. today (range가 아닐 때만 강조)
  3. outside (항상 가장 낮은 우선순위)

이를 통해 날짜 상태가 보다 명확하게 구분되도록 개선했습니다.

UX 개선을 구현하며 드러난 구조적 문제

UX 개선 방향 자체는 비교적 명확했지만,
이를 코드로 구현하는 과정에서 예상보다 큰 장벽을 마주하게 되었습니다.

바로 Calendar 컴포넌트의 구조와 타입 설계 문제였습니다.

기존 Calendar는 단일 날짜 선택(single)과
범위 선택(range)을 하나의 컴포넌트에서 처리하는 구조였고,
range UX를 구현하기 위해 onSelect 로직을 커스터마이징하기 시작하면서
이 구조의 한계가 점점 드러나기 시작했습니다.

문제의 본질 — mode 기반 Union 타입의 한계

react-day-picker는 mode에 따라 selected 타입이 완전히 달라집니다.

  • mode="single"Date | undefined
  • mode="range"DateRange | undefined
    • mode="multiple"Date[] | undefined

하지만 Calendar 내부에서는 selected가 항상 다음과 같이 인식됩니다.

Date | DateRange | Date[] | undefined

TypeScript 입장에서는 mode와 관계없이 selected가 “여러 타입 중 하나”가 되며 mode 조건만으로 타입을 안전하게 좁힐 수 없는 구조인 것입니다.

이로 인해 다음과 같은 코드에서 문제가 발생했습니다.

function CalendarRoot({
className,
classNames,
components,
disablePastDates,
onSelect,
selected,
mode,
...props
}: React.ComponentProps<typeof DayPicker> & { disablePastDates?: boolean }) {
const defaultClassNames = getDefaultClassNames();
const today = new Date();
const disabledInterval = disablePastDates ? { before: today } : undefined;
const rangeOnSelect =
mode === 'range'
? (onSelect as OnSelectHandler<DateRange | undefined> | undefined)
: undefined; // ❌ 3) onSelect 제네릭이 전달되지 않음
const handleRangeSelect = React.useCallback<
OnSelectHandler<DateRange | undefined>
>(
(value, day, modifiers, e) => {
if (rangeOnSelect && day) {
const current = selected as DateRange | undefined; // ❌1) selected가 Date | DateRange | undefined → narrowing 실패
const start = current?.from;
const end = current?.to;
// 이미 전체 범위가 선택된 상태에서 사용자가 시작 날짜 이후를 클릭하면 해당 날짜부터 새 범위를 시작
if (start && end && day.getTime() > start.getTime()) { // ❌ 2) 비교 연산 타입 에러
rangeOnSelect({ from: day, to: undefined }, day, modifiers, e);
return;
}
}
rangeOnSelect?.(value, day, modifiers, e);
},
[rangeOnSelect, selected]
);

TypeScript는 current가 Date일 가능성을 배제할 수 없기 때문에
from, to 접근과 날짜 비교 모두 타입 에러로 이어졌습니다.

결국 캐스팅이 필요해졌고,

const current = selected as DateRange | undefined

이런 코드가 늘어날수록 “타입을 우회하고 있다”는 느낌이 강해졌습니다.

저는 이 문제를 단순한 타입 단정이나 조건문으로 해결할 수 있는 문제가 아니라,
구조적 제약에서 비롯된 문제로 이해하게 되었습니다.

정리해보면, 문제의 핵심은 다음과 같았습니다.

  1. react-day-picker는 mode에 따라 완전히 다른 타입을 제공합니다.
  2. Calendar는 모든 mode를 하나의 컴포넌트에서 처리하고 있습니다.
  3. TypeScript는 런타임 조건만으로 타입을 좁히지 않습니다.

이 세 가지 조건이 동시에 존재하는 한,
range UX처럼 상태 전이가 복잡한 로직을
타입 안정성을 유지한 채 구현하는 데에는 한계가 있다고 판단했습니다.

해결 — Calendar.Single / Calendar.Range 분리

이에 따라 Calendar를 단일 책임 원칙에 따라 분리했습니다.

<Calendar.Single selected={single} onSelect={setSingle} />
<Calendar.Range selected={range} onSelect={setRange} />

이 구조에서는 Calendar.Range 내부에서
selected가 항상 DateRange | undefined로 보장되기 때문에,

  • from / to 접근
  • 날짜 비교
  • UX 로직 커스터마이징

모두 타입 단정 없이 안전하게 처리할 수 있었습니다.

이는 단순히 타입 에러를 피하기 위한 선택이 아니라,
UX 요구사항을 안정적으로 구현하기 위한 구조적 결정이었습니다.

정리

이번 Date Picker 개선 작업은
단순한 UI 수정이 아니라
UX 규칙 정의 → 구조 문제 인식 → 타입 설계 개선으로 이어진 과정이었습니다.

특히,

  • UX 문제는 구현 단계에서 구조와 타입 설계 문제로 이어질 수 있고
  • 타입 안정성은 단순한 DX 차원이 아니라
  • 사용자 경험을 안정적으로 구현하기 위한 기반이라는 점을 다시 한 번 확인할 수 있었습니다.