달력 선택 UI 9종 — 복붙하면 되는
달력 선택 UI는 시작일과 종료일처럼 값 하나가 아니라 범위·묶음을 고르게 해주는 선택 도구입니다. 세그먼트 컨트롤이나 색상 스와치처럼 이미 다뤘던 낱개 선택지 말고, 날짜 범위·가격대·아바타·사이즈+수량·검색 드롭다운·스크롤 휠·비율·좌우 이동 리스트까지 실제 서비스에서 자주 막히는
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 달력 범위 드래그 선택
- 02 듀얼 섬 가격 범위 슬라이더
- 03 카드형 플랜 라디오 선택
- 04 아바타 아이콘 그리드 피커
- 05 사이즈 칩 + 수량 스테퍼 콤보
- 06 검색 콤보박스 리스트박스
- 07 스크롤 스냅 숫자 휠 피커
- 08 종횡비 크롭 프리셋 피커
- 09 좌우 이동 듀얼 리스트 피커
아홉 개는 인기 순이 아니라 고르는 값의 개수가 늘어나는 순서로 놓았습니다. 01·02는 하나의 범위(시작~끝, 최소~최대)를 고르고, 03·04·05는 여러 옵션 중 하나를 카드·아바타·칩으로 고르며, 06·07은 목록에서 항목 하나를 검색하거나 스크롤로 찾고, 08은 비율을, 09는 여러 개를 한꺼번에 다른 목록으로 옮깁니다.
01달력 범위 드래그 선택
월 그리드에서 시작일을 먼저 누르고 종료일을 누르면, 그 사이 날짜 칸들이 연한 배경으로 채워지고 양 끝만 진한 원으로 표시됩니다. 숙소 예약 기간이나 휴가 신청 기간을 고르는 자리에 씁니다.
.cal__day.is-range { background: rgba($color, .18); }
.cal__day.is-edge {
background: $color;
color: #fff;
font-weight: 700;
transform: scale(1.06);
}
02듀얼 섬 가격 범위 슬라이더
손잡이 두 개를 포인터로 드래그하거나 방향키로 움직이면 최소·최대 값이 함께 갱신되고, 그 사이 트랙만 색이 채워집니다. 쇼핑몰 가격대 필터나 면적·평수 범위 필터에 맞습니다.
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
function setLo(n) { v.lo = Math.max(0, Math.min(n, v.hi - 5)); paint(); }
function setHi(n) { v.hi = Math.min(100, Math.max(n, v.lo + 5)); paint(); }
03카드형 플랜 라디오 선택
:has()로 고른 카드만 위로 살짝 뜨고 테두리가 밝아지며, 오른쪽 위에 체크 배지가 scale로 튀어나옵니다. 요금제 카드나 배송 방법 카드를 고르는 화면에 씁니다.
.plan:has(.plan__radio:checked) {
transform: translateY(-3px);
border-color: $color;
box-shadow: $shadow-raised;
}
.plan:has(.plan__radio:checked) .plan__badge { transform: scale(1); opacity: 1; }
04아바타 아이콘 그리드 피커
그리드의 아바타 중 하나를 고르면 그 아바타 고유 색으로 링이 커지고, 위쪽 미리보기 이름이 함께 바뀝니다. 프로필 아바타 선택이나 캐릭터 선택 화면에 맞습니다.
function select(idx) {
cur = idx;
items.forEach(function (b, i) {
b.setAttribute('aria-checked', i === idx ? 'true' : 'false');
b.tabIndex = i === idx ? 0 : -1;
b.classList.toggle('is-on', i === idx);
});
pvEmoji.textContent = items[idx].dataset.emoji;
pvName.textContent = items[idx].dataset.name;
}
05사이즈 칩 + 수량 스테퍼 콤보
사이즈 칩을 고르면 단가가 바뀌고, +/- 스테퍼로 수량을 조절하면 소계가 그 자리에서 다시 계산됩니다. 쇼핑몰에서 사이즈와 수량을 한 화면에서 같이 정하는 상품 옵션에 씁니다.
function price() {
return +(radios.filter(function (r) { return r.checked; })[0] || radios[0]).dataset.price;
}
function paint() {
num.textContent = qty;
dec.disabled = qty <= 1; inc.disabled = qty >= 9;
total.textContent = (price() * qty).toLocaleString('ko-KR') + '원';
}
06검색 콤보박스 리스트박스
입력창에 글자를 치면 그 글자로 시작하는 항목만 걸러 목록이 열리고, 방향키로 옮긴 항목이 aria-selected로 표시됩니다. 국가·도시 검색 선택창이나 수신자를 검색해서 고르는 자리에 맞습니다.
function render(q) {
shown = all.filter(function (c) { return c.indexOf(q) === 0 || !q; }).slice(0, 3);
list.innerHTML = '';
shown.forEach(function (c, i) {
var li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', i === active ? 'true' : 'false');
li.textContent = c;
list.appendChild(li);
});
}
07스크롤 스냅 숫자 휠 피커
세로로 스크롤하면 가운데 칸에 걸리는 숫자만 커지고 진해지며, 손을 놓는 순간 scroll-snap이 숫자 한 칸에 딱 멈춥니다. 알람 시각의 시(時)를 고르거나 예약 인원수를 고르는 휠에 씁니다.
function mark() {
var idx = Math.round(track.scrollTop / H);
idx = Math.max(0, Math.min(N - 1, idx));
if (idx === centered) return;
centered = idx;
items.forEach(function (el, i) { el.classList.toggle('is-center', i === idx); });
track.setAttribute('aria-activedescendant', items[idx].id);
}
08종횡비 크롭 프리셋 피커
1:1·4:5·16:9·9:16 칩 중 하나를 고르면 미리보기 박스의 aspect-ratio 값이 그 비율로 부드럽게 바뀝니다. 프로필 사진 크롭 비율이나 릴스·쇼츠 업로드 비율을 고르는 화면에 맞습니다.
function apply(v) { box.style.aspectRatio = v; }
radios.forEach(function (r) {
r.addEventListener('change', function () { apply(r.value); });
});
.ar__box {
aspect-ratio: 1 / 1;
height: 100%;
border: 2px solid $color;
transition: aspect-ratio $duration $easing;
}
.ar__radio:checked ~ span { background: $color; color: #fff; }
09좌우 이동 듀얼 리스트 피커
왼쪽 목록에서 항목을 골라 화살표 버튼을 누르면 오른쪽 목록으로 옮겨가고, 방향키로 목록 안을 옮겨 다닐 수 있습니다. 권한·역할 부여 화면이나 이메일 수신 그룹을 구성하는 자리에 씁니다.
function move(from, to, sel) {
var keep = [], moved = [];
from.forEach(function (n) { (sel[n] ? moved : keep).push(n); });
from.length = 0; Array.prototype.push.apply(from, keep);
Array.prototype.push.apply(to, moved);
Object.keys(sel).forEach(function (k) { delete sel[k]; });
render();
}
어디서 깨지는가 — 안 보이는 장식 층도 스크롤 영역엔 잡힙니다
01을 처음 만들 때는 실제 클릭 로직과 별개로 translateX만 쓰는 장식용 스윕 막대를 ::before로 얹어 데모 루프를 돌리려 했습니다. 그런데 이 막대에 translateX(366%)를 줬더니 눈에는 무대 배경 밖으로 나가 안 보이는데도 document.documentElement.scrollWidth가 그만큼 늘어나, 480×300은 물론 320px 폭에서도 스크롤바가 생기는 넘침 판정을 받았습니다. opacity: 0이나 pointer-events: none으로 숨겨도 레이아웃 상 자리(와 이동 거리)는 그대로 잡히는 탓입니다 — 결국 장식 층을 지우고, 실제 .is-range·.is-edge 클래스가 500ms마다 바뀌는 진짜 상태 전환만 루프에 남겼습니다. 06의 검색 목록도 비슷한 이유로 처음엔 닫힌 상태에서도 목록 칸의 높이가 그대로 남아 초기 렌더에서 넘침이 잡혔습니다 — max-height: 0; overflow: hidden;으로 닫힌 상태의 박스를 실제로 접어야 사라집니다. 03이 쓰는 :has()는 Chrome·Edge 105+, Safari 15.4+, Firefox 121+에서만 동작하며, 그 아래 버전에서는 카드가 떠오르지 않을 뿐 라디오 선택 자체는 정상 동작합니다. 아홉 개 파일이 그대로 담긴 zip의 압축 비밀번호는 uv8qjazc입니다.
접근성
03·05·08은 실제 <input type="radio">를 시각적으로만 숨겨 스크린 리더가 라디오 그대로 읽고 방향키로 옮겨 다닐 수 있습니다. 01은 날짜 칸마다 role="gridcell"과 aria-selected를 직접 갱신하고, 02·04·09처럼 라디오가 아닌 커스텀 컨트롤은 각각 role="slider"·role="radiogroup"·role="listbox"와 aria-valuenow·aria-checked·aria-selected를 상태가 바뀔 때마다 갱신해 스크린 리더가 지금 값을 알 수 있게 했습니다. 06은 강조된 항목에 aria-selected를 옮겨 붙이는 방식이고, 07은 포커스를 옮기지 않고도 aria-activedescendant로 가운데 칸을 읽어 주며, 05의 수량은 aria-live="polite"라 버튼을 누를 때마다 새 값이 조용히 읽힙니다. prefers-reduced-motion: reduce에서는 아홉 개 전부 트랜지션을 껐고, 선택 상태(체크·링·배지)는 켜지거나 닫힌 최종 모양 그대로 남아 정보 자체는 사라지지 않습니다.
@media (prefers-reduced-motion: reduce) {
.cal__day, .plan, .ava__item, .ar__box { transition: none; }
}
선택 UI 말고 다른 낱개 컴포넌트는 세그먼트 컨트롤 선택 UI 9종에도 더 있고, 같은 선택 UI 중 셀렉트·드롭다운 쪽은 css 셀렉트박스 디자인 9종에 정리해 뒀습니다.
FAQ
달력 범위 선택과 두 칸짜리 날짜 입력창은 뭐가 다른가요?
날짜 입력창 두 개(시작일·종료일)는 값 하나씩 따로 받지만, 01처럼 그리드 하나에서 시작일과 종료일을 연달아 누르면 그 사이 날짜가 한눈에 채워져 며칠짜리 기간인지 바로 보입니다. 숙소 예약처럼 기간의 길이 자체가 중요한 화면일수록 그리드형이 유리합니다.
듀얼 섬 슬라이더에서 손잡이 두 개가 겹치면 어떻게 되나요?
02는 setLo·setHi에서 항상 최소값을 최대값보다 5 이상 작게 강제합니다. 한쪽 손잡이를 반대쪽 근처까지 밀면 반대쪽 손잡이가 최소 간격만큼 같이 밀려나 두 손잡이가 겹치거나 순서가 뒤집히지 않습니다.
스크롤 스냅 휠 피커가 마우스 휠 없이 터치나 키보드만으로도 되나요?
네, 07은 scroll-snap-type: y mandatory가 걸린 트랙이라 터치 스와이프로도 자연스럽게 멈추고, 트랙에 포커스를 준 뒤 방향키를 누르면 scrollBy({ top, behavior: 'smooth' })로 한 칸씩 움직입니다. 마우스 휠은 그 위에 얹힌 부가 입력일 뿐입니다.