04Drag-painted date range
Holding the start cell and dragging paints every cell you pass in order, and only the two ends keep rounded corners.
Best forFor stays, leave requests or rentals where the user picks several days at once, not one
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>S</span><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span></div>
<div class="rc__grid" id="rc-grid" role="grid" aria-label="Date range calendar" 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 Drag-painted date range — Measured area 6.149% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · 9 css calendar design patterns that really move
The post this demo lives in
9 css calendar design patterns that really movecss calendar design often stops at restyling the date picker the browser gives you. These nine build the whole month gri See all demos in this post
If you like this effect - similar demos
Pickers01 Calendar drag range select9 Date Range Picker UI Patterns — Copy-Paste
Pickers06 Country and currency select with flags9 Select Dropdown Design Patterns That Really Open
Pickers03 Drag inertia — tracking pointer velocity, then decaying it with an exponential glideCSS Range Slider: 9 Physics-Grade Custom Sliders