Skip to content

DateRangePicker

두 날 사이의 구간을 고릅니다. 달력 두 개가 나란히 서고, 띠는 두 번째 클릭이 떨어지기 전에 — 포인터를 따라 — 그려집니다.

tsx
import { DateRangePicker } from 'neba';

<DateRangePicker label="숙박" startPlaceholder="체크인" endPlaceholder="체크아웃" clearable />;

Props

Prop타입기본값설명
variant공통'solid' | 'outline' | 'text''outline'표면의 무게. 채움 / 하이라인 / 없음
size공통'xs' | 'sm' | 'md' | 'lg' | 'xl''md'높이와 타입 스케일
color공통'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'의미론적 색 역할. 임의 색상값은 받지 않습니다
density공통'default' | 'compact''default'여백만 바꿉니다. 높이와 글자 크기는 그대로
elevation공통0 | 1 | 2 | 30그림자 깊이. 0은 그림자 없음
valueDateRange | null선택된 범위. { start, end }이고 각각 null일 수 있습니다
defaultValueDateRange | null초기 범위
onValueChange(value: DateRange) => void항상 객체로 불립니다. 비워진 범위는 { start: null, end: null }
defaultMonthDate값이 없을 때 달력이 열리는 달. 기본값은 이번 달
minDateDate | null고를 수 있는 가장 이른 날. 두 패널 모두에 적용됩니다
maxDateDate | nullminDate의 반대쪽 끝
shouldDisableDate(date: Date) => boolean범위 안이지만 고를 수 없는 날 — 주말, 공휴일, 이미 예약된 방. 셀은 목록에 남은 채 비활성이 됩니다
weekStartsOn공통0 | 1 | 2 | 3 | 4 | 5 | 6주가 시작하는 요일. 일요일이 0입니다. 기본값은 로케일이 말하는 것
monthCount1 | 22한 번에 보이는 달의 수. 달을 넘는 범위가 예외가 아니라 보통이라 2가 기본값입니다
startPlaceholderReactNode시작이 비었을 때 트리거의 왼쪽 절반에 보이는 내용
endPlaceholderReactNode끝이 비었을 때의 같은 것
presetsreadonly DateRangePreset[]달력 옆에 놓이는 단축 범위들 — "최근 7일", "이번 달". value가 함수면 눌린 시점에 계산됩니다
openboolean팝업이 열려 있는지. onOpenChange와 함께 제어 컴포넌트로 씁니다
defaultOpenbooleanfalse처음에 열린 채로 시작
onOpenChange(open: boolean) => void열리거나 닫힐 때
localestringBCP 47 태그. 월·요일 이름, 헤더의 연/월 버튼 순서, 트리거의 표기를 정합니다. 기본값은 브라우저의 로케일
formatIntl.DateTimeFormatOptions트리거가 값을 쓰는 방식. Intl에 그대로 넘어갑니다
clearablebooleanfalse값을 비우는 ×를 트리거에 답니다
closeOnSelectbooleantrue두 끝이 다 정해지면 팝업을 닫습니다
labelsPartial<PickerLabels>스크린 리더가 듣는 문자열들. 열여덟 개가 한 벌이라 프롭 하나로 받습니다 — 날짜 이름은 여기 없고 Intl이 만듭니다
namestring폼 제출 시의 필드 이름. 같은 이름의 hidden input 두 개가 나가므로 FormData.getAll로 받습니다
fullWidthbooleanfalse컨테이너 너비만큼 확장
labelReactNode컨트롤과 연결되는 라벨. Base UI Field가 묶어 줍니다
descriptionReactNode보조 설명
errorReactNode오류 메시지. 값이 있으면 invalid 상태도 함께 켜지고 색 계열이 danger로 넘어갑니다
invalidboolean!!error메시지 없이 invalid만 켭니다. 외부 폼 라이브러리가 유효성을 가질 때
readOnlybooleanfalse값은 보이지만 바꿀 수 없음. 색과 가장자리는 유지한 채 채도만 빠집니다
disabledbooleanfalse사용 불가. 색 계열을 버리고 중립 회색이 됩니다

값은 객체 하나입니다

ts
interface DateRange {
  start: Date | null;
  end: Date | null;
}

[Date, Date] 튜플도 아니고 프롭 두 개도 아닙니다. 범위는 하나의 값입니다 — 한 동작으로 고르고, 한 동작으로 비우고, 통째로 검증합니다 — 그리고 이름 두 개가 호출하는 쪽이 끝을 시작 자리에 쓰는 것을 막습니다.

onValueChange는 언제나 객체로 불립니다. null로는 불리지 않으므로, 비워진 범위는 { start: null, end: null }이고 호출하는 쪽이 두 종류의 "비어 있음"을 검사할 일이 없습니다.

절반의 범위는 실재하는 상태입니다. 첫 클릭과 두 번째 클릭 사이에 피커가 들고 있는 것이고, { start, end: null }로 보고됩니다. 두 번째 클릭 없이 팝업을 닫아 버리면 그것은 버려집니다 — 한쪽 끝만 있는 범위를 폼에 남겨 두지 않습니다.

패널은 둘, 달력은 하나

달을 넘는 범위가 예외가 아니라 보통이므로 monthCount의 기본값은 2입니다. 두 패널은 반으로 나뉜 하나의 달력입니다. 왼쪽에는 앞으로 가는 스테퍼가 없고, 오른쪽에는 뒤로 가는 스테퍼가 없으며, 어느 쪽 헤더의 월·연도 버튼이든 둘을 함께 움직입니다.

패널은 이웃한 달의 앞뒤 날들을 일부러 그리지 않습니다. 단독 DatePicker는 그립니다. 두 패널이 모두 6주를 꽉 채워 보이면 8월 1일이 두 번 나타나기 때문입니다 — 한 번은 7월의 꼬리로, 한 번은 자기 자신으로. 같은 이름의 칸이 한 팝업에 둘 있는 것은 포인터에게는 모호하고 스크린 리더에게는 아예 고장입니다.

거꾸로 클릭하는 것은 실수가 아닙니다

두 번째 클릭이 첫 번째보다 앞에 떨어질 수 있습니다. 그것은 거절할 오류가 아니라 순서를 바꿔 말한 같은 범위이므로, 두 끝은 정렬된 뒤에 보고됩니다. 완성된 범위 다음의 클릭은 새 범위를 시작합니다.

예시

프리셋

리포팅 UI가 실제로 쓰이는 통로입니다. "최근 30일"을 하루씩 골라 넣는 사람은 없습니다.

프리셋의 value는 범위이거나 범위를 반환하는 함수입니다. 함수 쪽을 쓰세요 — 렌더 시점에 계산된 범위는 탭을 밤새 열어 둔 사람에게 틀린 값이 됩니다.

패널 하나, 그리고 범위

제출

name은 같은 이름의 hidden input 두 개를 그립니다. 두 끝이 함께 도착합니다.

ts
const form = new FormData(event.currentTarget);
const [start, end] = form.getAll('stay'); // '2026-07-03', '2026-07-09'

둘 다 로컬 기준 YYYY-MM-DD이고, 네이티브 <input type="date">가 제출하는 모양 그대로입니다.

접근성

  • 각 패널은 완전한 날짜로 이름 붙은 role="gridcell" 버튼들의 role="grid"이고, 각자 자기 탭 정지점을 가집니다 — Tab은 84개의 칸을 지나는 대신 두 그리드 사이를 오갑니다.
  • 두 끝은 aria-selected를 답니다. 그 사이의 날들은 띠만 두를 뿐입니다. 범위 안에 있다는 것과 선택되었다는 것은 같은 말이 아니기 때문입니다.
  • 푸터가 다음 클릭이 어느 쪽 끝을 채울지 말해 줍니다. 트리거의 두 절반도 같은 말을 하지만, 팝업이 떠 있는 동안 트리거는 그 뒤에 있습니다.

Released under the MIT License