GODRICH

css 달력 만들기 9종 — 진짜 넘어가는 월간 캘린더

css 달력 만들기는 브라우저가 주는 조그만 날짜 고르기 창 대신, 한 달을 통째로 펼쳐 놓는 격자를 직접 짜는 일입니다. 아홉 개 중 하나는 달을 넘길 때 두 장의 격자가 나란히 미끄러지고, 또 하나는 1년을 네모 365개로 깔아 꾸준함을 색으로 보여 줍니다.

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

아홉 개는 인기 순서가 아니라 달력이 화면에서 실제로 겪는 일 순서대로 놓았습니다. 먼저 달이 넘어가고(01), 오늘이 어디인지 한눈에 보이고(02), 일정이 하루에 쌓이고(03), 며칠씩 한 번에 잡히고(04), 시간이 겹치는 것이 드러나고(05), 일정이 집혀서 옮겨지고(06), 1년이 한 장으로 펼쳐지고(07), 범위가 달을 건너 이어지고(08), 폭이 좁아지면 아젠다로 접힙니다(09). 아홉 개 전부 role="grid"를 달고 있고 화살표 키가 칸 사이를 실제로 옮기며, 날짜를 고르는 다섯 개는 aria-selected로 고른 칸까지 알립니다.

01두 트랙으로 넘어가는 월 이동

화살표를 누르면 나가는 달과 들어오는 달이 날짜 격자 트랙과 달 이름 띠에 실려 한 몸처럼 미끄러집니다. 숫자만 갈아 끼우면 어느 방향으로 갔는지 알 수 없어서, 트랙을 300%로 넓게 깔고 이번 달을 중앙에 둔 뒤 좌우로 밀어 넘깁니다. 전환이 끝나면 transition이 꺼진 순간에 트랙을 되돌리는데, 이 리셋은 눈에 보이지 않습니다.

translateXaria-live@keyframes cal-slide
function go(dir) {
  if (busy) return;
  busy = true; cal.classList.remove('is-demo'); cal.classList.add('is-anim');
  var to = BASE - dir * (100 / 3);
  track.style.transform = strip.style.transform = 'translateX(' + to + '%)';
  var done = function () { track.removeEventListener('transitionend', done); finish(dir); };
  track.addEventListener('transitionend', done);
  window.setTimeout(done, 700);
}
function finish(dir) {
  busy = false; cal.classList.remove('is-anim');
  cur = new Date(cur.getFullYear(), cur.getMonth() + dir, 1);
  render();
  track.style.transform = ''; strip.style.transform = '';
  live.textContent = label(cur);
}

02오늘 링과 숫자 배지

오늘 칸에만 파란 원이 깔리고 그 위로 링이 퍼졌다 사라지기를 반복합니다. 일정이 있는 날은 칸 모서리에 그날 개수가 작은 알약으로 올라앉는데, 흰 테두리를 둘러 파란 원 위에서도 숫자가 읽힙니다. 링의 원은 셀 높이와 같은 정사각형을 border-radius: 50%로 깎아 타원으로 찌그러지지 않게 했습니다.

box-shadowaria-current=datescale
.cal__cell.is-today::after {
  content: "";
  position: absolute; left: 50%; top: 50%;
  width: clamp(24px, 7.5vw, 32px); height: clamp(24px, 7.5vw, 32px);
  border: 3px solid $color;
  border-radius: 50%;
  transform: translate(-50%, -50%) scale(.9);
  opacity: 0;
  pointer-events: none;
  animation-name: today-ring;
  animation-duration: $duration;
  animation-timing-function: $ease-out;
  animation-iteration-count: infinite;
}

03이벤트 칩과 더보기 +N

한 칸에 색 칩이 두 개까지 쌓이고 남는 일정은 +N 줄로 접힙니다. 그 줄을 누르면 나머지가 grid-template-rows0fr에서 1fr로 펴지면서 실제로 열립니다. 펼쳐지는 목록은 절대배치 카드라 격자 높이를 밀지 않아서, 320px 폰에서도 아래 줄이 흔들리지 않습니다.

aria-expandedgrid-template-rows@keyframes more-open
.cal__morewrap {
  position: absolute;
  left: 0; right: 0; top: calc(100% + 2px);
  display: grid;
  grid-template-rows: 0fr;
  background: #fff;
  border-radius: $r-sm;
  box-shadow: $shadow-press;
  transition: grid-template-rows $duration $easing;
  z-index: 2;
}
.cal__more { min-height: 0; overflow: hidden; }
.cal__cell.is-open .cal__morewrap { grid-template-rows: 1fr; }

04드래그로 칠하는 기간 선택

시작 칸을 누른 채 끌면 지나간 칸이 순서대로 칠해집니다. 어느 칸을 칠할지는 paint(anchor, head) 한 함수가 양 끝 인덱스만 받아 범위 안 칸을 양 끝까지 모두 is-in으로 칠하고 거기에 is-start·is-end를 덧붙이는 식이라, 드래그 방향을 뒤집어도 같은 코드가 그대로 동작합니다. 키보드는 처음 고른 칸을 고정한 채 Shift와 화살표로 반대쪽 끝만 늘립니다.

pointerdownaria-selectedborder-radius
function paint(a, b) {
  var lo = Math.min(a, b), hi = Math.max(a, b);
  for (var i = 0; i < cells.length; i++) {
    var on = i >= lo && i <= hi;
    cells[i].classList.toggle('is-in', on);
    cells[i].classList.toggle('is-start', on && i === lo);
    cells[i].classList.toggle('is-end', on && i === hi);
    cells[i].setAttribute('aria-selected', String(on));
  }
  live.textContent = cells[lo].getAttribute('data-d') + ' – ' + cells[hi].getAttribute('data-d');
}

05시간 축 위의 주간 타임라인

왼쪽 시간 눈금에 맞춰 일정 블록이 자기 시작 시각 위치에 놓입니다. 같은 시각에 겹치는 두 블록은 폭을 반씩 나눠 나란히 서서, 월간 격자에서는 안 보이던 충돌이 한눈에 드러납니다. 지금 시각 선은 translateY로 일정한 속도로 내려오고 양 끝에서만 흐려집니다.

@keyframes wt-nowrole=gridtranslateY
.wt.is-demo .wt__now {
  animation-name: wt-now;
  animation-duration: $dur-loop;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}
@keyframes wt-now {
  0%   { transform: translateY(calc(var(--rowh) * .4));  opacity: 0; }
  10%  { opacity: 0; }
  22%  { opacity: 1; }
  78%  { opacity: 1; }
  90%  { opacity: 0; }
  100% { transform: translateY(calc(var(--rowh) * 4.7)); opacity: 0; }
}

06집어서 옮기는 일정 카드

칩을 눌러 잡으면 살짝 떠서 기울고 그림자가 깊어집니다. 커서가 지나가는 칸은 점선 테두리로 받을 준비를 보여 주고, 손을 떼면 appendChild로 그 칸에 실제로 붙습니다. 칩을 Space나 Enter로 집고 화살표로 칸을 고른 뒤 다시 눌러 놓는 키보드 조작도 함께 있어서, 마우스 없이도 일정이 옮겨집니다.

pointermoverotatearia-grabbed
document.addEventListener('pointermove', function (e) {
  if (!drag) return;
  drag.chip.style.transform = 'translate(' + (e.clientX - drag.x) + 'px,' + (e.clientY - drag.y - 6) + 'px) scale(1.06) rotate(-4deg)';
  var el = document.elementFromPoint(e.clientX, e.clientY);
  var c = el && el.closest ? el.closest('.dd__c') : null;
  if (c !== drag.over) {
    if (drag.over) drag.over.classList.remove('is-over');
    drag.over = c;
    if (c) c.classList.add('is-over');
  }
});

07진하기로 읽는 히트맵 달력

1년이 7행 53열의 작은 네모로 깔리고 활동이 많은 날일수록 진해집니다. 색 단계는 무대색에 잉크를 섞어 가다 마지막 하나만 잉크 그대로인 다섯 단계이고, 한 행이 한 요일·한 열이 한 주라 53열만 명시하고 나머지는 기본 행 흐름에 맡깁니다. 루프에서는 왼쪽 열부터 차례로 칸이 차오르는데, 칸마다 시차를 주려고 animation: 단축 대신 낱개 속성을 썼습니다.

grid-template-columnsaria-labelscale
.hm__board {
  display: grid;
  grid-template-columns: repeat(53, 1fr);
}
.hm__row { display: contents; }
.hm__cell { background: color.mix($color, $stage-yellow, 14%); }
.hm__cell.lv1 { background: color.mix($color, $stage-yellow, 30%); }
.hm__cell.lv2 { background: color.mix($color, $stage-yellow, 48%); }
.hm__cell.lv3 { background: color.mix($color, $stage-yellow, 70%); }
.hm__cell.lv4 { background: $color; }

08나란한 미니 3개월

지난달·이번달·다음달이 축소된 격자 셋으로 나란히 섭니다. 글자 크기를 cqw 단위로 주어서 상자 폭이 줄면 셋이 함께 줄어들기 때문에, 320px에서도 셋이 모두 들어갑니다. 선택 범위는 카드가 바뀌어도 같은 상태로 칠해져서, 8월 말에서 10월 초까지 세 달을 건너 하나로 이어집니다.

container-typearia-selectedclamp()
.m3 { container-type: inline-size; width: min(100%, 436px); }
.m3__strip { display: flex; gap: $sp-2; }
.m3__card { flex: 1 1 0; min-width: 0; padding: $sp-1; border-radius: $r-sm; }
.m3__label { font-size: clamp(9px, 2.6cqw, 13px); }
.m3__cell  { font-size: clamp(7px, 2.2cqw, 11px); }
.m3__card--side .m3__num { font-weight: 500; color: color.mix($color, transparent, 45%); }

09격자에서 아젠다로 접히는 전환

같은 9월 데이터가 상자 폭에 따라 7열 격자와 일자별 리스트 중 하나로 보입니다. 보기를 고르는 건 @container (max-width: 176px) 규칙이고, ResizeObserver가 그 순간을 잡아 rolegridlist 사이에서 실제로 바꿉니다. 아이콘 두 개는 aria-pressed로 눌린 쪽을 알려 주고, 뷰가 바뀌면 숨겨진 칸에 있던 포커스를 새 보기의 첫 칸으로 옮깁니다.

@containerrole=listtranslateY
function setMode(narrow) {
  if (narrow === isAgenda) return;
  isAgenda = narrow;
  root.classList.toggle('is-agenda', narrow);
  view.setAttribute('role', narrow ? 'list' : 'grid');
  bgrid.classList.toggle('is-on', !narrow);
  blist.classList.toggle('is-on', narrow);
  bgrid.setAttribute('aria-pressed', String(!narrow));
  blist.setAttribute('aria-pressed', String(narrow));
  var a = document.activeElement;
  if (!a || a === document.body || !a.offsetParent) focusFirst();
}
new ResizeObserver(function () { setMode(frame.getBoundingClientRect().width <= 176); }).observe(frame);

어디서 깨지는가 — 함정

첫 번째 함정은 04번에서 측정으로 잡혔습니다. 칸마다 시차를 줄 때 animation-delay만 설정하면, 지연이 풀리는 동안 칸이 이미 칠해진 기본 상태로 서 있다가 애니메이션이 시작되는 순간 빈 칸으로 튀었습니다. 프레임을 겹쳐 비교해 보니 첫 프레임과 마지막 프레임이 한눈에 보일 만큼 어긋나 있었고, animation-fill-mode: backwards를 붙이자 차이가 거의 사라졌습니다. 시차를 준 요소에는 지연뿐 아니라 채움 모드까지 세트로 들어가야 합니다.

두 번째 함정은 무대 크기에서 왔습니다. 320×200 고정 뷰포트에서 문서가 화면보다 길어지지 않으려면 주 행이 다섯 줄 이하여야 하는데, 6주가 필요한 달이 있습니다. 2026년 8월은 토요일에 시작해 31일이라 여섯째 주에 30일과 31일이 남는데, 다섯 줄 고정 격자는 그 이틀을 아예 그리지 않습니다. 데모는 9월을 기준 달로 잡아 이 문제를 피했지만 실제 화면에 붙일 때는 여섯 줄을 감안한 높이 예산을 잡아야 합니다. 이렇게 잡은 아홉 벌 소스는 압축 비밀번호 4zdx6kfa를 넣으면 열리는 zip에 그대로 들어 있습니다. 세 번째로, 05번의 지금 시각 선은 투명 구간이 3%일 때 캡처 첫 프레임에 선이 반쯤 남았습니다. 렌더러가 시작 두 프레임을 컴포지터 지연으로 늦게 잡는 일이 있어서, 투명 구간을 10%와 90%로 넓혔습니다.

접근성

아홉 개 전부 prefers-reduced-motion: reduce에서 자동 재생 루프를 끄고 상태는 남겨 둡니다. 링은 퍼지지 않고 오늘 원만 서 있고, 달은 미끄러지지 않고 즉시 바뀌며, 히트맵은 색을 채운 채 멈춥니다. 격자에는 role="grid", 주 행에는 role="row", 날짜 칸에는 role="gridcell"이 붙고, 선택이 있는 다섯 개는 상태를 aria-selected로, 오늘을 가리키는 여덟 개는 aria-current="date"로 알립니다. 칸 사이 이동은 칩 자체를 집어 옮기는 06번을 빼면 로빙 tabindex 하나로 처리해서 월간 격자에서는 화살표 ±1일·±7일과 Home·End가 실제로 동작하고, 01번의 월 이름은 aria-live="polite" 영역이 읽어 줍니다. 07번은 네모 하나하나에 날짜와 활동 수를 담은 aria-label이 붙습니다. 포커스를 되돌릴 때 focus({ preventScroll: true })를 쓰는 건 이 데모들이 글 속에 iframe으로 박혀 있어서, 그냥 focus()를 부르면 읽는 사람의 화면이 끌려가기 때문입니다. 여기 쓴 역할 이름의 정의는 MDN의 grid 역할 문서에 있습니다.

대시보드에 붙이는 달력 부품은 대시보드 카테고리에, 눌러서 반응하는 부품은 클릭 카테고리에 모아 두었습니다.

FAQ

날짜 고르기 창이 이미 있는데 굳이 달력을 직접 만드는 이유가 있나요?

하루를 고르는 창이라면 없습니다. 직접 만들 필요는 예약·일정 화면처럼 한 달 전체를 보여 주고 일정 칩을 얹거나 며칠을 묶어 골라야 할 때 생깁니다. 그 경우에도 키보드 이동과 역할 이름은 브라우저가 주던 만큼 직접 채워 넣어야 합니다.

폰에서는 7열 격자가 너무 좁아지지 않나요?

320px에서 칸 하나가 40px 안팎으로 줄어드는 건 피할 수 없어서, 09번은 상자 폭에 반응해 일자별 리스트로 접힙니다. 리스트에서도 같은 데이터를 쓰고 화살표 이동도 그 축에 맞게 바뀌므로, 격자를 그대로 줄여 놓는 것보다 읽히는 정보가 많습니다.

일정이 하루에 열 개 몰리면 어떻게 되나요?

03번처럼 두 개까지 칩으로 보이고 나머지는 +N 한 줄로 접는 게 격자 높이를 지키는 방법입니다. 더보기를 누르면 절대배치 카드가 그 칸 아래로 겹쳐 펼쳐지므로, 격자 높이는 그대로이고 이웃한 줄도 움직이지 않습니다.

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

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