GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

날짜 선택 UI 9종 — 팝오버로 열고 접는 아홉 가지 방식

날짜 선택 UI는 입력 하나로 달력이 열리고 접히는 인터랙션인데, 아홉 개 중 하나는 입력칸의 초점 값이 켜지면 달력이 팝오버로 펼쳐지고, 하나는 허용 범위 밖 날짜의 흐림 값이 커져 선택할 수 없는 칸이 드러나며, 또 하나는 화면 너비 값이 좁아지면 팝오버가 아래에서 올라오는 시트로

아홉 항목 모두 로드 시점에 자동으로 도는 시연 루프는 값을 미리 보여주는 보조 장치일 뿐이고, 진짜 계산은 클릭·키보드·초점 같은 실사용자 입력이 일어나는 순간 다시 돌아갑니다. 달력을 그리는 여섯 항목(01·03·04·05·06·08)은 오늘 날짜를 상수 BASE(2026년 3월 14일)로 고정해 두고(02는 연도 상수만, 07·09는 정적 텍스트) 아홉 개 모두 Math.random 호출은 0번이라, 같은 순서로 입력하면 매번 같은 값이 나오는 계산입니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개의 해부 — 어떤 값이 언제 바뀌는가

01은 필드 클릭과 문서 전체의 바깥 클릭·Escape 키로, 02는 헤더·이전·다음·연도칸 클릭과 좌우 화살표 키로, 03은 칸 초점 이동과 방향키·클릭으로, 04·06·08은 격자 클릭 하나로, 05는 배지 클릭과 Enter·Space 키로, 07은 필드·전환 버튼 클릭 두 개로, 09는 두 필드의 클릭으로 값을 다시 계산합니다. 날짜 선택 팝업 하나를 열고 닫는 계산이라도 아홉 항목이 붙잡는 값은 저마다 다릅니다. 다음 표에 항목별로 실제로 화면이 얼마나 넓게·세게·몇 프레임 동안 바뀌었는지를 실측해 정리했습니다(분모는 캡처 24프레임 중 실제로 값이 바뀐 23프레임입니다).

번호 이름 리스너 누적면적% 강도 움직인프레임/23
01 입력 팝오버 전환 click(필드)+바깥 click+keydown(Esc) 25.68 84.3 8
02 월년 단위 전환 click(헤더·이전·다음·연도칸)+keydown(←→) 5.12 109.6 19
03 키보드 이동 전환 focusin+keydown(방향키)+click 2.13 111.3 8
04 범위 밖 흐림 전환 click(격자) 4.21 83.8 14
05 오늘 표시 전환 click(배지)+keydown(Enter/Space) 3.24 38.3 16
06 빠른 선택 전환 click(칩) 11.49 63.8 18
07 시트 전환 click(필드)+click(전환 버튼) 17.93 106.2 15
08 거부 흔들림 전환 click(격자) 3.38 39.3 16
09 모션 감소 대체 전환 click(필드×2) 3.15 76.6 15

09는 컴포넌트 한 칸짜리 비교 데모라 누적면적(3.15%)이 크지 않지만, 왼쪽·오른쪽 두 판을 같은 클릭 값 하나로 동시에 열고 닫으면서 서로 다른 CSS 값(transform 유무)만 갈아 끼운다는 점에서 09는 화면을 채우는 면적이 아니라 같은 입력에 두 계산 결과를 나란히 세우는 쪽으로 값을 보여 줍니다.

웹 날짜 선택 ui 화면에서 실측 좌표로 목표 값을 정하는 값 — 01·03·07

01의 measureAnchor()는 필드의 실제 렌더 좌표·폭을 getBoundingClientRect()로 재 ANCHOR_GAP(8px)만큼 아래에 팝오버를 앵커하고, 03의 moveRingTo()는 강조 칸의 좌표를 실측해 링을 그 자리로 옮기며, 07의 measureMode()는 뷰포트 요소의 실측 폭을 THRESHOLD(140px)와 비교해 팝오버·시트 형태를 고릅니다. 셋 다 짐작한 고정 픽셀이 아니라 지금 화면에 실제로 놓인 좌표·크기를 기준으로 목표 값을 정합니다.

매 프레임 지수 감쇠를 계산해 값을 그리는 값 — 05·08

05의 pulseAt()과 08의 shakeAt()은 둘 다 requestAnimationFrame 루프 안에서 지수 감쇠(Math.exp(-ZETA*x)) x 코사인 진동을 매 프레임 계산해 인라인 스타일로 그립니다. 05는 ZETA(4.2)로 링 스케일·투명도와 배지 스케일을, 08은 SHAKE_ZETA(6.5)로 칸의 가로 위치 값을 흔듭니다. 나머지 일곱 개(01·02·03·04·06·07·09)는 클래스를 토글한 뒤 SCSS transition 값이 움직임을 대신 그립니다.

고정 배열의 인덱스를 옮겨 다음 값을 정하는 값 — 02·06

02는 AUTO_SEQ([3, 7, 11, 3])라는 자동재생용 목표 월 배열의 인덱스를 순환시키고, 06은 PRESETS(이번 주·다음 7일·이번 달) 배열의 인덱스를 옮겨 다음 값을 고릅니다. 둘 다 매 프레임 계산 대신 배열에서 다음 값을 꺼내는 방식이라, 화면에 보이는 전환 자체는 CSS transition이 담당합니다.

경계로부터 거리만큼 값을 깎는 값 — 04

04는 min·max 범위 밖 날짜마다 경계로부터 며칠 떨어져 있는지(distOut)를 세어 FADE_STEP(.12)씩 불투명도를 깎고, 아무리 멀어도 FADE_FLOOR(.15) 아래로는 내려가지 않게 막습니다. 05·08의 시간 기반 감쇠와 달리, 04는 시간이 아니라 날짜 칸 사이의 거리를 감쇠 변수로 씁니다.

패턴별 코드

01입력 팝오버 전환 — 입력칸의 초점 값이 켜지면 달력이 팝오버로 펼쳐지고 꺼지면 접히는 방식

measureAnchor()가 필드의 실제 렌더 좌표를 실측해 ANCHOR_GAP(8px)만큼 아래에 팝오버를 앵커하고, setOpen()이 열림 여부에 따라 그 실측을 다시 돌립니다. 체크인 날짜 입력칸, 필터 패널의 기간 입력에 쓰는 계산입니다.

  function measureAnchor() {
    // 계산 겹: 실측 위치 — 필드의 실제 렌더 폭·좌표를 읽어 팝오버를 그 자리에 앵커한다
    var fr = field.getBoundingClientRect();
    var pr = dp.getBoundingClientRect();
    pop.style.left = (fr.left - pr.left) + 'px';
    pop.style.top = (fr.bottom - pr.top + ANCHOR_GAP) + 'px';
    pop.style.width = fr.width + 'px';
  }

  function setOpen(v) {
    dp.classList.toggle('dp--open', v);
    field.setAttribute('aria-expanded', v ? 'true' : 'false');
    pop.setAttribute('aria-hidden', v ? 'false' : 'true');
    if (v) measureAnchor();
  }
입력 팝오버 전환 — 입력칸의 초점 값이 켜지면 달력이 팝오버로 펼쳐지고 꺼지면 접히는 방식
새 창에서 열기

01 · 입력 팝오버 전환 — 입력칸의 초점 값이 켜지면 달력이 팝오버로 펼쳐지고 꺼지면 접히는 방식 — 측정 누적면적 25.682% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

measureAnchor()ANCHOR_GAP$ease-elastic

02월년 단위 전환 — 표시 단위 값이 월에서 년으로 바뀌면 여러 해를 한 번에 건너뛸 수 있는 방식

가운데 헤더 버튼을 짚을 때마다 mode 값이 month·year 사이를 오가고, slideTo()는 뷰포트 실측 폭을 슬라이드 거리로 써서 목표-현재 오프셋 부호만큼 방향을 정합니다. 10년 단위 묶음 없이 월과 년 두 단계만 오가는 계산입니다.

  function slideTo(target) {
    // 계산 겹: 뷰포트 실측 폭을 슬라이드 거리로, 목표-현재 오프셋 부호를 슬라이드 방향으로 쓴다
    var dist = viewport.getBoundingClientRect().width;
    var dir = target >= current ? 1 : -1;
    current = target;
    renderMonth(current);
    setHead(current);
    root.classList.remove('mj--year');
    mode = 'month';
    monthGrid.style.transition = 'none';
    monthGrid.style.transform = 'translateX(' + (dir * dist) + 'px)';
    void monthGrid.offsetWidth;
    monthGrid.style.transition = '';
    monthGrid.style.transform = 'translateX(0)';
  }
월년 단위 전환 — 표시 단위 값이 월에서 년으로 바뀌면 여러 해를 한 번에 건너뛸 수 있는 방식
새 창에서 열기

02 · 월년 단위 전환 — 표시 단위 값이 월에서 년으로 바뀌면 여러 해를 한 번에 건너뛸 수 있는 방식 — 측정 누적면적 5.122% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

AUTO_SEQCELL_TOTALmode

03키보드 이동 전환 — 방향 값이 바뀔 때마다 강조 칸 값이 격자 위를 이동하는 방식

방향키 하나마다 delta 값을 ±1(좌우)·±7(상하)로 정해 focusDay()에 넘기고, 격자 끝에 닿으면 1일·말일에서 값이 멈춥니다. 키보드 위주 입력 폼, 접근성 우선 대시보드에 쓰는 계산입니다.

  function human() {
    kb.classList.remove('is-demo');
    stopAutoplay();
  }

  grid.addEventListener('keydown', function (e) {
    var delta = 0;
    if (e.key === 'ArrowRight') delta = 1;
    else if (e.key === 'ArrowLeft') delta = -1;
    else if (e.key === 'ArrowDown') delta = 7;
    else if (e.key === 'ArrowUp') delta = -7;
    else return;
    e.preventDefault();
    human();
    focusDay(focusedDay + delta);
  });
키보드 이동 전환 — 방향 값이 바뀔 때마다 강조 칸 값이 격자 위를 이동하는 방식
새 창에서 열기

03 · 키보드 이동 전환 — 방향 값이 바뀔 때마다 강조 칸 값이 격자 위를 이동하는 방식 — 측정 누적면적 2.131% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FOCUS_SEQmoveRingTo()focusDay()

04범위 밖 흐림 전환 — 허용 범위 값을 벗어난 날짜의 흐림 값이 커져 고를 수 없는 상태로 바뀌는 방식

recompute()가 클릭이 일어날 때마다 42칸 전부를 다시 훑어, 경계로부터의 거리(distOut)만큼 FADE_STEP(.12)씩 불투명도를 깎습니다. 예약 가능 기간 제한, 유효기간이 있는 쿠폰 사용일 선택에 쓰는 계산입니다.

  var min = 5, max = 20, pickStage = 'min';

  function recompute() {
    // 계산 겹: 범위 밖 각 셀은 경계로부터의 거리만큼 불투명도를 계산해 낮춘다
    for (var day = 1; day <= n; day++) {
      var c = cellByDay[day];
      var distOut = day < min ? (min - day) : (day > max ? (day - max) : 0);
      var opacity = distOut === 0 ? 1 : Math.max(FADE_FLOOR, 1 - distOut * FADE_STEP);
      c.style.opacity = String(opacity);
      c.classList.toggle('mf__cell--bound', day === min || day === max);
      c.classList.toggle('mf__cell--out-range', distOut > 0);
    }
  }
범위 밖 흐림 전환 — 허용 범위 값을 벗어난 날짜의 흐림 값이 커져 고를 수 없는 상태로 바뀌는 방식
새 창에서 열기

04 · 범위 밖 흐림 전환 — 허용 범위 값을 벗어난 날짜의 흐림 값이 커져 고를 수 없는 상태로 바뀌는 방식 — 측정 누적면적 4.213% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FADE_STEPFADE_FLOORrecompute()

05오늘 표시 전환 — 오늘 값을 가진 칸 둘레에서 링이 주기적으로 커지며 옅어지는 방식

pulseAt()이 지수 감쇠(ZETA 4.2) x 코사인 진동을 계산해 링 스케일·투명도와 배지 스케일 값을 함께 만들어, 밝기가 아니라 크기 값으로 오늘 칸을 드러냅니다. 출석 체크 달력, 마감일이 가까운 일정 확인에 쓰는 계산입니다.

  function pulseAt(t) {
    // 계산 겹: 지수 감쇠 x 코사인 진동 — 스프링이 잦아드는 물리량을 그대로 계산한다
    if (t < 0 || t > DUR) return { ring: 0, ringAlpha: 0, badge: 1 };
    var x = t / DUR;
    var amp = Math.exp(-ZETA * x);
    var osc = Math.cos(FREQ * t);
    var ringScale = 1 + 1.9 * (1 - amp);
    var ringAlpha = Math.max(0, amp * .55);
    var badgeScale = 1 + .16 * amp * osc;
    return { ring: ringScale, ringAlpha: ringAlpha, badge: badgeScale };
  }
오늘 표시 전환 — 오늘 값을 가진 칸 둘레에서 링이 주기적으로 커지며 옅어지는 방식
새 창에서 열기

05 · 오늘 표시 전환 — 오늘 값을 가진 칸 둘레에서 링이 주기적으로 커지며 옅어지는 방식 — 측정 누적면적 3.242% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pulseAt()DURZETA

06빠른 선택 전환 — 칩 값을 짚으면 격자를 거치지 않고 날짜 값이 바로 정해지는 방식

applyPreset()이 선택 범위 시작으로부터의 위치(pos)만큼 STAGGER(28ms)를 곱해 전환 지연을 매기고, 그 칩의 배경 값만 채워 선택 상태를 나타냅니다. 리포트 기간 빠른 선택, 검색 필터의 최근 기간에 쓰는 계산입니다.

  function applyPreset(idx) {
    // 계산 겹: 선택 범위 시작으로부터의 위치(pos)만큼 전환 지연을 매겨 순차 채움을 만든다
    var p = PRESETS[idx];
    for (var day = 1; day <= n; day++) {
      var c = cellByDay[day];
      var inRange = day >= p.start && day <= p.end;
      if (inRange) {
        var pos = day - p.start;
        c.style.transitionDelay = (pos * STAGGER) + 'ms';
      } else {
        c.style.transitionDelay = '0ms';
      }
      c.classList.toggle('ps__cell--on', inRange);
    }
    chipEls.forEach(function (el, i2) { el.classList.toggle('ps__chip--active', i2 === idx); });
  }
빠른 선택 전환 — 칩 값을 짚으면 격자를 거치지 않고 날짜 값이 바로 정해지는 방식
새 창에서 열기

06 · 빠른 선택 전환 — 칩 값을 짚으면 격자를 거치지 않고 날짜 값이 바로 정해지는 방식 — 측정 누적면적 11.493% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PRESETSSTAGGERapplyPreset()

07시트 전환 — 화면 너비 값이 좁아지면 팝오버 값이 아래에서 올라오는 시트 값으로 바뀌는 방식

measureMode()가 뷰포트 요소의 실측 폭을 THRESHOLD(140px)와 비교해 팝오버·시트 중 어느 값을 그릴지 정하고, render()가 그 값으로 클래스를 다시 매깁니다. 모바일 예약 앱의 날짜 선택, 좁은 화면의 필터 패널에 쓰는 계산입니다.

  function measureMode() {
    // 계산 겹: 뷰포트 요소의 실제 렌더 폭을 실측해 팝오버/시트 형태를 고른다
    var w = viewport.getBoundingClientRect().width;
    return w < THRESHOLD ? 'sheet' : 'popover';
  }

  function render() {
    var mode = measureMode();
    panel.classList.toggle('ms--open', open);
    panel.classList.toggle('ms__panel--sheet', mode === 'sheet');
    panel.classList.toggle('ms__panel--popover', mode === 'popover');
  }
시트 전환 — 화면 너비 값이 좁아지면 팝오버 값이 아래에서 올라오는 시트 값으로 바뀌는 방식
새 창에서 열기

07 · 시트 전환 — 화면 너비 값이 좁아지면 팝오버 값이 아래에서 올라오는 시트 값으로 바뀌는 방식 — 측정 누적면적 17.928% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

THRESHOLDmeasureMode()viewport

08거부 흔들림 전환 — 막힌 값을 짚으면 그 칸의 위치 값이 짧게 좌우로 흔들리는 방식

shakeAt()이 SHAKE_ZETA(6.5) 지수 감쇠를 코사인 진동에 곱해 픽셀 오프셋 값을 만들고, loop()가 매 프레임 그 값을 칸의 transform에 그대로 얹습니다. 이미 예약된 날짜 재선택 방지, 마감된 접수일 안내에 쓰는 계산입니다.

  function shakeAt(t) {
    // 계산 겹: 지수 감쇠 x 코사인 진동을 픽셀 오프셋으로 바꾼다
    if (t < 0 || t > SHAKE_DUR) return 0;
    var x = t / SHAKE_DUR;
    var amp = Math.exp(-SHAKE_ZETA * x);
    return SHAKE_AMP * amp * Math.cos(SHAKE_FREQ * t);
  }

  function loop(ts) {
    if (shakeTarget) {
      var elapsed = ts - shakeStart;
      if (elapsed > SHAKE_DUR) {
        shakeTarget.style.transform = '';
        shakeTarget = null;
      } else {
        shakeTarget.style.transform = 'translateX(' + shakeAt(elapsed).toFixed(2) + 'px)';
      }
    }
    rafId = requestAnimationFrame(loop);
  }
거부 흔들림 전환 — 막힌 값을 짚으면 그 칸의 위치 값이 짧게 좌우로 흔들리는 방식
새 창에서 열기

08 · 거부 흔들림 전환 — 막힌 값을 짚으면 그 칸의 위치 값이 짧게 좌우로 흔들리는 방식 — 측정 누적면적 3.376% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

shakeAt()SHAKE_ZETABANNER_MS

09모션 감소 대체 전환 — 모션 감소 값이 켜지면 팝오버·시트·흔들림 값이 즉시 값만 바뀌는 방식

같은 열림 값 하나로 왼쪽(일반)은 transform(scale)+opacity 값을, 오른쪽(감소된 모션)은 opacity 값만 바꿔 나란히 비교합니다. 모션 최소화 설정을 켠 사용자, 저사양 기기의 폼 화면에 쓰는 계산입니다.

.rm__pop--normal {
  transform: scale(.8);
  transform-origin: top center;
  transition: transform $dur-enter $ease-elastic, opacity $dur-quick $ease-out;
}
.rm__pop--normal.is-open { transform: scale(1); opacity: 1; pointer-events: auto; }
.rm__pop--reduced {
  transition: opacity $dur-quick $ease-out;
}
.rm__pop--reduced.is-open { opacity: 1; pointer-events: auto; }
모션 감소 대체 전환 — 모션 감소 값이 켜지면 팝오버·시트·흔들림 값이 즉시 값만 바뀌는 방식
새 창에서 열기

09 · 모션 감소 대체 전환 — 모션 감소 값이 켜지면 팝오버·시트·흔들림 값이 즉시 값만 바뀌는 방식 — 측정 누적면적 3.151% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

rm__pop--normalrm__pop--reducedprefers-reduced-motion

값이 팝오버를 여닫는 곳 — 초점 값·범위 값·너비 기준

여닫힘 기준은 01과 07이 서로 다르게 잡습니다. 01은 필드 버튼을 짚을 때마다 dp--open 클래스 값이 뒤집히며 팝오버가 열리고, 문서 전체에 건 리스너가 dp 바깥을 짚은 순간과 Escape 키 입력을 같은 human() 함수로 몰아 닫습니다. 07은 열림 자체는 01과 같은 클릭 값이지만, 그 위에 THRESHOLD(140px)라는 너비 기준 값 하나를 더 얹어 같은 열림 상태에서도 팝오버 모양이 될지 시트 모양이 될지를 매번 다시 잽니다. 07 같은 너비 기준 전환은 모바일 날짜 선택 ui 화면에서 같은 달력 마크업을 재사용하면서 좁은 화면에만 시트 형태를 얹고 싶을 때 유용합니다.

범위 값을 여닫는 기준은 04입니다. min·max 두 값을 클릭 두 번으로 정하고 나면 recompute()가 매번 42칸 전부를 다시 훑어 경계로부터 거리(distOut)만큼 FADE_STEP(.12)씩 불투명도를 깎고 FADE_FLOOR(.15) 아래로는 내려가지 않게 막습니다. 01의 여닫힘 값이 켜짐·꺼짐 두 상태만 오가는 것과 달리, 04의 범위 값은 min·max 두 숫자가 같이 움직이며 칸마다 다른 불투명도 값을 만들어냅니다.

어디서 깨지는가 — 좁은 화면·범위 밖 입력·모션 감소

좁은 화면(340px 이하)에서는 09의 두 칸 비교 레이아웃이 .rm__mini 폭을 108px에서 84px로 줄이고 글자 크기도 함께 낮춥니다. 07은 반대로 화면 전체 폭이 아니라 msViewport 자체의 실측 폭이 THRESHOLD(140px) 밑으로 내려가는 순간에만 반응해, 부모 레이아웃이 아무리 좁아져도 msViewport 자체 폭이 140px 이상이면 여전히 팝오버 형태로 남습니다.

범위 밖 입력은 04가 담당합니다. min·max를 벗어난 날짜를 클릭해도 recompute()가 매 클릭마다 42칸을 다시 훑어 값이 어긋나는 순간이 없습니다. 08은 범위가 아니라 주말이라는 고정 조건(isWeekend)으로 막힌 값을 가려내고, 막힌 칸을 짚으면 shakeAt()이 SHAKE_ZETA(6.5) 감쇠 계산으로 흔들림 값을 만들며 BANNER_MS(900ms) 동안 배너를 띄웁니다.

모션을 줄이는 처리는 아홉 항목 모두 자바스크립트에서 window.matchMedia('(prefers-reduced-motion: reduce)')로 판정하고, prefers-reduced-motion 미디어 쿼리로 SCSS 쪽에서도 같은 판정을 한 번 더 겁니다. 끊어내는 지점은 항목마다 갈립니다: 01·02·03·04·06·07은 reduce 값이 켜지면 해당 요소의 transition만 none으로 끊어 클래스가 바뀌는 순간 즉시 최종 모습으로 넘어가고, 05는 tb__ring 자체를 display: none으로 아예 지우며, 08은 kd__cell의 transform을 !important로 강제 해제하고 자동재생 자체도 시작하지 않아 흔들림 루프가 돌 일이 없습니다. 09는 반대로 미디어 쿼리가 왼쪽 칸(.rm__pop--normal)의 transform만 지워 오른쪽(.rm__pop--reduced)과 같은 opacity 전용 모양으로 맞춥니다.

아홉 개 패턴의 html·scss·js 소스 전부가 비번 2s5u3jbf 으로 열리는 zip 안에 들어 있어, 위에서 짚은 04의 매번 재계산·08의 감쇠 공식을 직접 코드에서 확인하고 바꿔볼 수 있습니다.

변형 세 가지와 접근성

이름 값 느낌
오늘 링 감쇠비 낮추기 05 ZETA 4.2 → 2.0 감쇠 상수를 4.2 에서 2.0 으로 낮추면 링이 한 번에 잦아들지 않고 두세 번 더 파동치듯 커졌다 옅어지길 반복한다
거부 흔들림 감쇠비 낮추기 08 SHAKE_ZETA 6.5 → 3.0 감쇠 상수를 6.5 에서 3.0 으로 낮추면 흔들림이 한 번에 멈추지 않고 좌우로 몇 차례 더 오가며 거부감이 더 세게 느껴진다
칩 채움 전파 늦추기 06 STAGGER 28 → 70 지연값을 28 에서 70 로 늘리면 배경이 시작일부터 끝일까지 거의 동시에 물들지 않고 파도처럼 한 칸씩 뒤따라 채워지는 게 눈에 보인다

접근성은 01과 03이 가장 두텁습니다. 01은 필드 버튼에 aria-haspopup="dialog"·aria-expanded를, 팝오버에 role="dialog"·aria-label="달력"·aria-hidden을 걸어 열림 여부를 스크린리더에도 알립니다. 03은 격자에 role="grid"·aria-label="달력"을 걸고, 초점이 있는 칸 하나만 tabIndex 0을 갖고 나머지는 -1로 감춰 두는 로빙 tabindex로 Tab 키 한 번에 격자 전체가 아니라 지금 칸 하나만 걸리게 합니다. 02는 반대로 월·년 전환 버튼에 aria-expanded나 aria-pressed가 없어, 지금이 월 목록인지 년 목록인지 텍스트가 바뀌는 것 말고는 스크린리더가 알 방법이 없습니다. 04·05·06·08은 요일 머리글에 aria-hidden을 걸어 장식용 텍스트를 읽지 않게 뺀 것 외에 별도 상태 속성이 없고, 07·09는 요일 머리글 자체가 없어 아리아 속성이 하나도 없이 버튼 요소 자체의 기본 포커스·클릭 동작에만 기댑니다.

FAQ

01 같은 팝오버 달력을 실제 예약 폼에 옮겨 붙이면 서버의 예약 가능일과 자동으로 맞물리나요?

아니요. 01의 measureAnchor()·setOpen()은 화면에서 팝오버를 열고 닫는 위치 계산일 뿐이고, 04의 범위 흐림이나 08의 주말 차단처럼 어떤 날짜를 고를 수 없게 할지는 서버가 내려주는 값을 따로 붙여야 합니다.

03의 방향키 이동과 08의 흔들림 거부는 왜 계산 방식이 완전히 다른가요?

03은 focusDay()가 delta 값(±1·±7)만큼 초점을 옮기고 로빙 tabindex로 DOM 초점 자체를 관리하는 이산적인 계산이고, 08은 shakeAt()이 매 프레임 지수 감쇠 x 코사인 값을 연속적으로 계산해 픽셀 오프셋을 만듭니다. 하나는 어느 칸이 초점인지 상태를 옮기는 계산이고, 다른 하나는 시간에 따라 변하는 물리량을 그리는 계산이라 방식 자체가 다릅니다.

웹 날짜 선택 ui 를 만들 때 09처럼 두 모션 버전을 한 화면에 같이 두어야 하나요?

아니요. 09는 일반·감소된 모션 두 값을 나란히 비교해 보여 주는 학습용 데모라 둘을 동시에 그립니다. 실제 화면에서는 window.matchMedia('(prefers-reduced-motion: reduce)') 판정 하나로 갈라, 사용자 설정에 맞는 한쪽 값만 그리면 됩니다.

더 읽을거리

달력 선택 UI 9종 — 복붙하면 되는은 값 선택 UI를 폭넓게 모은 피커 모음을 다루는 데 비해, 이 편은 날짜 선택 값 하나의 열림 위치·이동·범위·유효성 계산만 깊게 판다는 점에서 계산 축이 다릅니다.

css 달력 만들기 9종 — 진짜 넘어가는 월간 캘린더는 월 전체를 보여 주는 달력 격자 레이아웃 자체를 다루는 반면, 이 편의 02·03은 그 격자 위에서 초점·표시 단위 값이 어떻게 움직이는지를 판다는 점에서 레이아웃 축이 아니라 값 이동 축입니다.

시간 선택 ui 9종 — 시각 하나가 아니라 구간까지는 시각·구간이라는 다른 축의 값을 다루는데, 이 편은 같은 팝오버·범위 계산이라도 날짜 값을 기준으로 열림·이동·유효성을 판단한다는 점에서 값의 종류 자체가 다릅니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.