04ドラッグで塗る期間選択
開始マスを押したまま引くと通ったマスが順に塗られ、両端のマスだけ角が丸く残ります。
使いどころ宿泊・休暇・レンタルのように1日ではなく数日をまとめて選ばせる画面に付けるとき
pointerdownaria-selectedborder-radius

<main class="stage">
<div class="rc is-demo">
<div class="rc__head">
<span class="rc__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 48v40H40V48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 32h-24v-8a8 8 0 0 0-16 0v8H88v-8a8 8 0 0 0-16 0v8H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16M72 48v8a8 8 0 0 0 16 0v-8h80v8a8 8 0 0 0 16 0v-8h24v32H48V48Zm136 160H48V96h160z"/></g></svg></span>
<span class="rc__title">2026. 9</span>
<span class="rc__out" id="rc-live" role="status" aria-live="polite">13 – 19</span>
</div>
<div class="rc__dow" aria-hidden="true"><span>日</span><span>月</span><span>火</span><span>水</span><span>木</span><span>金</span><span>土</span></div>
<div class="rc__grid" id="rc-grid" role="grid" aria-label="期間選択カレンダー" aria-multiselectable="true">
<div class="rc__r" role="row">
<div class="rc__c is-out" role="gridcell" tabindex="-1" aria-selected="false" aria-disabled="true" data-d="30">30</div>
<div class="rc__c is-out" role="gridcell" tabindex="-1" aria-selected="false" aria-disabled="true" data-d="31">31</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="1">1</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="2">2</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="3">3</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="4">4</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="5">5</div>
</div>
<div class="rc__r" role="row">
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="6">6</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="7">7</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="8">8</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="9">9</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="10">10</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="11">11</div>
<div class="rc__c is-today" role="gridcell" tabindex="0" aria-selected="false" aria-current="date" data-d="12">12</div>
</div>
<div class="rc__r" role="row">
<div class="rc__c d d1 is-in is-start" role="gridcell" tabindex="-1" aria-selected="true" data-d="13">13</div>
<div class="rc__c d d2 is-in" role="gridcell" tabindex="-1" aria-selected="true" data-d="14">14</div>
<div class="rc__c d d3 is-in" role="gridcell" tabindex="-1" aria-selected="true" data-d="15">15</div>
<div class="rc__c d d4 is-in" role="gridcell" tabindex="-1" aria-selected="true" data-d="16">16</div>
<div class="rc__c d d5 is-in" role="gridcell" tabindex="-1" aria-selected="true" data-d="17">17</div>
<div class="rc__c d d6 is-in" role="gridcell" tabindex="-1" aria-selected="true" data-d="18">18</div>
<div class="rc__c d d7 is-in is-end" role="gridcell" tabindex="-1" aria-selected="true" data-d="19">19</div>
</div>
<div class="rc__r" role="row">
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="20">20</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="21">21</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="22">22</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="23">23</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="24">24</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="25">25</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="26">26</div>
</div>
<div class="rc__r" role="row">
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="27">27</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="28">28</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="29">29</div>
<div class="rc__c" role="gridcell" tabindex="-1" aria-selected="false" data-d="30">30</div>
<div class="rc__c is-out" role="gridcell" tabindex="-1" aria-selected="false" aria-disabled="true" data-d="1">1</div>
<div class="rc__c is-out" role="gridcell" tabindex="-1" aria-selected="false" aria-disabled="true" data-d="2">2</div>
<div class="rc__c is-out" role="gridcell" tabindex="-1" aria-selected="false" aria-disabled="true" data-d="3">3</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.rc {
box-sizing: border-box;
width: min(320px, 90vw);
color: $subject-ink; // 주황 무대 위 글자 — AA 4.5 (크림은 3.11:1)
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rc__head {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0 0 $sp-2;
}
.rc__ico { display: inline-flex; opacity: .8; }
.rc__title {
font-size: clamp(11px, 3.4vw, 14px);
font-weight: 700;
letter-spacing: .02em;
}
.rc__out {
margin-left: auto;
padding: 0 $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .18);
font-size: clamp(9px, 2.6vw, 11px);
font-weight: 700;
line-height: 1.6;
white-space: nowrap;
}
.rc__dow {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: $sp-1;
margin: 0 0 $sp-1;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
text-align: center;
color: $subject-ink; // 주황 면 위 글자는 알파 없는 잉크 — opacity .72 는 면과 섞여 3.76:1 이었다 (AA 4.5 → 5.50:1)
}
.rc__grid { display: flex; flex-direction: column; gap: $sp-1; }
.rc__r { display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-1; }
.rc__c {
box-sizing: border-box;
height: clamp(20px, 6.4vw, 30px);
display: flex;
align-items: center;
justify-content: center;
border-radius: $r-sm;
background: rgba(255, 255, 255, .14);
color: $subject-ink; // AA 4.5 (크림은 2.73:1)
font-size: clamp(9px, 2.9vw, 12px);
font-weight: 600;
cursor: pointer;
user-select: none;
touch-action: none;
transition: background $duration $easing, color $duration $easing, border-radius $duration $easing;
}
.rc__c.is-out { opacity: .4; }
.rc__c.is-today { box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .7); }
.rc__c:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// 범위 — 가운데는 각진 띠, 양 끝 칸만 둥근 모서리
.rc__c.is-in { background: $subject-cream; color: $subject-orange-deep; border-radius: 0; } // 밝은 면 위 주황 글자 — 5.04:1
.rc__c.is-start { border-radius: $r-sm 0 0 $r-sm; }
.rc__c.is-end { border-radius: 0 $r-sm $r-sm 0; }
.rc__c.is-start.is-end { border-radius: $r-sm; }
// ── 자동 루프: 왼쪽 칸부터 차례로 칠해졌다 차례로 접힌다 (2s) ──
// animation 단축은 animation-delay 를 0s 로 리셋한다 → 낱개 속성으로만 쓴다
.rc.is-demo .rc__c.d {
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
// backwards 가 없으면 delay 동안 기본 스타일(is-in = 칠해진 상태)이 보이다가 애니메이션 시작에 튄다
animation-fill-mode: backwards;
}
.rc.is-demo .d1 { animation-name: rc-l; animation-delay: 0s; }
.rc.is-demo .d2 { animation-name: rc-m; animation-delay: .09s; }
.rc.is-demo .d3 { animation-name: rc-m; animation-delay: .18s; }
.rc.is-demo .d4 { animation-name: rc-m; animation-delay: .27s; }
.rc.is-demo .d5 { animation-name: rc-m; animation-delay: .36s; }
.rc.is-demo .d6 { animation-name: rc-m; animation-delay: .45s; }
.rc.is-demo .d7 { animation-name: rc-r; animation-delay: .54s; }
@keyframes rc-l {
0%, 6% { background: rgba(255, 255, 255, .14); color: $subject-cream; border-radius: $r-sm; }
14%, 46% { background: $subject-cream; color: $stage-orange; border-radius: $r-sm 0 0 $r-sm; }
54%, 100% { background: rgba(255, 255, 255, .14); color: $subject-cream; border-radius: $r-sm; }
}
@keyframes rc-m {
0%, 6% { background: rgba(255, 255, 255, .14); color: $subject-cream; border-radius: $r-sm; }
14%, 46% { background: $subject-cream; color: $stage-orange; border-radius: 0; }
54%, 100% { background: rgba(255, 255, 255, .14); color: $subject-cream; border-radius: $r-sm; }
}
@keyframes rc-r {
0%, 6% { background: rgba(255, 255, 255, .14); color: $subject-cream; border-radius: $r-sm; }
14%, 46% { background: $subject-cream; color: $stage-orange; border-radius: 0 $r-sm $r-sm 0; }
54%, 100% { background: rgba(255, 255, 255, .14); color: $subject-cream; border-radius: $r-sm; }
}
@media (prefers-reduced-motion: reduce) {
.rc.is-demo .rc__c.d { animation: none !important; }
.rc__c { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rc {
box-sizing: border-box;
width: min(320px, 90vw);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rc__head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
}
.rc__ico {
display: inline-flex;
opacity: 0.8;
}
.rc__title {
font-size: clamp(11px, 3.4vw, 14px);
font-weight: 700;
letter-spacing: 0.02em;
}
.rc__out {
margin-left: auto;
padding: 0 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
font-size: clamp(9px, 2.6vw, 11px);
font-weight: 700;
line-height: 1.6;
white-space: nowrap;
}
.rc__dow {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin: 0 0 4px;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
text-align: center;
color: #17141a;
}
.rc__grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.rc__r {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.rc__c {
box-sizing: border-box;
height: clamp(20px, 6.4vw, 30px);
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: rgba(255, 255, 255, 0.14);
color: #17141a;
font-size: clamp(9px, 2.9vw, 12px);
font-weight: 600;
cursor: pointer;
user-select: none;
touch-action: none;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-radius 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rc__c.is-out {
opacity: 0.4;
}
.rc__c.is-today {
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.7);
}
.rc__c:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.rc__c.is-in {
background: #fff7e6;
color: #c93000;
border-radius: 0;
}
.rc__c.is-start {
border-radius: 8px 0 0 8px;
}
.rc__c.is-end {
border-radius: 0 8px 8px 0;
}
.rc__c.is-start.is-end {
border-radius: 8px;
}
.rc.is-demo .rc__c.d {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rc.is-demo .d1 {
animation-name: rc-l;
animation-delay: 0s;
}
.rc.is-demo .d2 {
animation-name: rc-m;
animation-delay: 0.09s;
}
.rc.is-demo .d3 {
animation-name: rc-m;
animation-delay: 0.18s;
}
.rc.is-demo .d4 {
animation-name: rc-m;
animation-delay: 0.27s;
}
.rc.is-demo .d5 {
animation-name: rc-m;
animation-delay: 0.36s;
}
.rc.is-demo .d6 {
animation-name: rc-m;
animation-delay: 0.45s;
}
.rc.is-demo .d7 {
animation-name: rc-r;
animation-delay: 0.54s;
}
@keyframes rc-l {
0%, 6% {
background: rgba(255, 255, 255, 0.14);
color: #fff7e6;
border-radius: 8px;
}
14%, 46% {
background: #fff7e6;
color: #ff4d1f;
border-radius: 8px 0 0 8px;
}
54%, 100% {
background: rgba(255, 255, 255, 0.14);
color: #fff7e6;
border-radius: 8px;
}
}
@keyframes rc-m {
0%, 6% {
background: rgba(255, 255, 255, 0.14);
color: #fff7e6;
border-radius: 8px;
}
14%, 46% {
background: #fff7e6;
color: #ff4d1f;
border-radius: 0;
}
54%, 100% {
background: rgba(255, 255, 255, 0.14);
color: #fff7e6;
border-radius: 8px;
}
}
@keyframes rc-r {
0%, 6% {
background: rgba(255, 255, 255, 0.14);
color: #fff7e6;
border-radius: 8px;
}
14%, 46% {
background: #fff7e6;
color: #ff4d1f;
border-radius: 0 8px 8px 0;
}
54%, 100% {
background: rgba(255, 255, 255, 0.14);
color: #fff7e6;
border-radius: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rc.is-demo .rc__c.d {
animation: none !important;
}
.rc__c {
transition: none;
}
}// pointerdown 으로 시작 칸을 잡고 pointermove 로 끌면 지나간 칸이 순서대로 칠해진다.
// Shift+ArrowLeft/Right(Up/Down) 로도 같은 범위를 늘린다. 범위 안 칸은 aria-selected="true".
var root = document.querySelector('.rc');
var grid = document.getElementById('rc-grid');
var live = document.getElementById('rc-live');
var cells = [].slice.call(grid.querySelectorAll('.rc__c'));
var COLS = 7;
var anchor = 14, head = 20, dragging = false;
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');
}
function rove(i) {
for (var k = 0; k < cells.length; k++) cells[k].setAttribute('tabindex', k === i ? '0' : '-1');
cells[i].focus({ preventScroll: true });
}
function hand() { root.classList.remove('is-demo'); }
function cellAt(x, y) {
var el = document.elementFromPoint(x, y);
return el && el.closest ? el.closest('.rc__c') : null;
}
grid.addEventListener('pointerdown', function (e) {
var c = e.target.closest ? e.target.closest('.rc__c') : null;
if (!c) return;
hand();
dragging = true;
anchor = head = cells.indexOf(c);
paint(anchor, head);
rove(head);
e.preventDefault();
});
document.addEventListener('pointermove', function (e) {
if (!dragging) return;
var c = cellAt(e.clientX, e.clientY);
if (!c) return;
var i = cells.indexOf(c);
if (i >= 0 && i !== head) { head = i; paint(anchor, head); }
});
document.addEventListener('pointerup', function () { dragging = false; });
document.addEventListener('pointercancel', function () { dragging = false; });
grid.addEventListener('keydown', function (e) {
var i = cells.indexOf(document.activeElement);
if (i < 0) return;
var n = i;
if (e.key === 'ArrowLeft') n = i - 1;
else if (e.key === 'ArrowRight') n = i + 1;
else if (e.key === 'ArrowUp') n = i - COLS;
else if (e.key === 'ArrowDown') n = i + COLS;
else if (e.key === 'Home') n = i - (i % COLS);
else if (e.key === 'End') n = i - (i % COLS) + COLS - 1;
else if (e.key === ' ' || e.key === 'Enter') { hand(); anchor = head = i; paint(i, i); e.preventDefault(); return; }
else return;
if (n < 0 || n >= cells.length) return;
e.preventDefault();
hand();
if (e.shiftKey) { head = n; paint(anchor, head); }
else { anchor = head = n; paint(n, n); }
rove(n);
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RangeDragCalendar.module.scss";
export type RangeDragCalendarProps = {
/** 격자에 깔 날짜 라벨. 기본값은 5주 × 7열 = 35칸 */
days?: string[];
/** 처음 선택돼 있는 범위 [시작 index, 끝 index] */
initial?: [number, number];
duration?: number;
easing?: string;
color?: string;
onChange?: (from: number, to: number) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; shiftKey: boolean; preventDefault: () => void };
type PtrEvt = { clientX: number; clientY: number; preventDefault: () => void };
const COLS = 7;
function defaultDays(): string[] {
// 30·31 → 1..30 → 1·2·3 (앞뒤 달 흘림칸 포함 35칸)
const out: string[] = ["30", "31"];
for (let d = 1; d <= 30; d += 1) out.push(String(d));
return out.concat(["1", "2", "3"]);
}
export default function RangeDragCalendar({
days = defaultDays(),
initial = [14, 20],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
onChange,
}: RangeDragCalendarProps) {
const [anchor, setAnchor] = useState(initial[0]);
const [head, setHead] = useState(initial[1]);
const [focus, setFocus] = useState(initial[0]);
const dragging = useRef(false);
const cellRefs = useRef<(HTMLDivElement | null)[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const lo = Math.min(anchor, head);
const hi = Math.max(anchor, head);
function commit(a: number, h: number) {
setAnchor(a);
setHead(h);
if (onChange) onChange(Math.min(a, h), Math.max(a, h));
}
function move(i: number) {
setFocus(i);
const el = cellRefs.current ? cellRefs.current[i] : null;
if (el) el.focus({ preventScroll: true });
}
function indexFromPoint(x: number, y: number): number {
const el = document.elementFromPoint(x, y);
const cell = el && (el as HTMLElement).closest ? (el as HTMLElement).closest("[data-i]") : null;
return cell ? Number((cell as HTMLElement).dataset.i) : -1;
}
function onPointerDown(i: number, e: PtrEvt) {
dragging.current = true;
commit(i, i);
move(i);
e.preventDefault();
const onMove = (ev: { clientX: number; clientY: number }) => {
if (!dragging.current) return;
const n = indexFromPoint(ev.clientX, ev.clientY);
if (n >= 0) setHead(n);
};
const onUp = () => {
dragging.current = false;
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
if (onChange) onChange(Math.min(anchor, head), Math.max(anchor, head));
};
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
}
function onKeyDown(i: number, e: KeyEvt) {
let n = i;
if (e.key === "ArrowLeft") n = i - 1;
else if (e.key === "ArrowRight") n = i + 1;
else if (e.key === "ArrowUp") n = i - COLS;
else if (e.key === "ArrowDown") n = i + COLS;
else if (e.key === "Home") n = i - (i % COLS);
else if (e.key === "End") n = i - (i % COLS) + COLS - 1;
else if (e.key === " " || e.key === "Enter") { commit(i, i); e.preventDefault(); return; }
else return;
if (n < 0 || n >= days.length) return;
e.preventDefault();
if (e.shiftKey) { setHead(n); if (onChange) onChange(Math.min(anchor, n), Math.max(anchor, n)); }
else commit(n, n);
move(n);
}
const rows: number[][] = [];
for (let r = 0; r * COLS < days.length; r += 1) {
const row: number[] = [];
for (let c = 0; c < COLS && r * COLS + c < days.length; c += 1) row.push(r * COLS + c);
rows.push(row);
}
return (
<div className={styles.rc} style={vars}>
<div className={styles.head}>
<span className={styles.title}>2026. 9</span>
<span className={styles.out} role="status" aria-live="polite">
{days[lo]} – {days[hi]}
</span>
</div>
<div className={styles.grid} role="grid" aria-multiselectable="true">
{rows.map((row) => (
<div className={styles.row} role="row" key={row[0]}>
{row.map((i) => {
const on = i >= lo && i <= hi;
const cls = [
styles.cell,
on ? styles.isIn : "",
on && i === lo ? styles.isStart : "",
on && i === hi ? styles.isEnd : "",
].join(" ");
return (
<div
key={i}
data-i={i}
ref={(el: HTMLDivElement | null) => { if (cellRefs.current) cellRefs.current[i] = el; }}
className={cls}
role="gridcell"
aria-selected={on}
tabIndex={i === focus ? 0 : -1}
onPointerDown={(e: PtrEvt) => onPointerDown(i, e)}
onKeyDown={(e: KeyEvt) => onKeyDown(i, e)}
>
{days[i]}
</div>
);
})}
</div>
))}
</div>
</div>
);
}04 ドラッグで塗る期間選択 — 計測 累積面積 6.149% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · カレンダー css デザイン 9選 — 実際に月が動く
このデモが属する記事
カレンダー css デザイン 9選 — 実際に月が動くカレンダー css デザイン というと、ブラウザが用意する小さな日付ポップアップの色を変えるところで終わりがちです。ここで作る9つは1か月をまるごと広げる月間グリッドそのもので、どれも本当に動きます。1つは月を送るときに2枚のグリッドが一緒 この記事のデモをすべて見る


