06Pick-up and drop event card
Grabbing a chip lifts and tilts it, each cell it crosses shows a dashed drop target, and releasing really re-parents it.
Best forWhen moving an event today means opening a modal and re-picking the date
pointermoverotatearia-grabbed

<main class="stage">
<div class="dd is-demo">
<div class="dd__head">
<span class="dd__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="M208 108v44a80 80 0 0 1-160 0v-12a20 20 0 0 1 20-20h20V92a20 20 0 0 1 40 0a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M188 80a27.8 27.8 0 0 0-13.36 3.4a28 28 0 0 0-46.64-11A28 28 0 0 0 80 92v20H68a28 28 0 0 0-28 28v12a88 88 0 0 0 176 0v-44a28 28 0 0 0-28-28m12 72a72 72 0 0 1-144 0v-12a12 12 0 0 1 12-12h12v24a8 8 0 0 0 16 0V92a12 12 0 0 1 24 0v28a8 8 0 0 0 16 0V92a12 12 0 0 1 24 0v28a8 8 0 0 0 16 0v-12a12 12 0 0 1 24 0Z"/></g></svg></span>
<span class="dd__title">2026. 9</span>
<span class="dd__live" id="dd-live" role="status" aria-live="polite"></span>
</div>
<div class="dd__dow" aria-hidden="true"><span>M</span><span>T</span><span>W</span><span>T</span><span>F</span></div>
<div class="dd__grid" id="dd-grid" role="grid" aria-label="Move-event calendar">
<div class="dd__r" role="row">
<div class="dd__c" role="gridcell" data-d="7"><span class="dd__n">7</span></div>
<div class="dd__c dd__c--from" role="gridcell" data-d="8"><span class="dd__n">8</span><button class="dd__chip dd__chip--fly" type="button" aria-grabbed="false"><span class="dd__grip" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span><span class="dd__t">Review</span></button></div>
<div class="dd__c dd__c--to" role="gridcell" data-d="9"><span class="dd__n">9</span></div>
<div class="dd__c" role="gridcell" data-d="10"><span class="dd__n">10</span></div>
<div class="dd__c" role="gridcell" data-d="11"><span class="dd__n">11</span></div>
</div>
<div class="dd__r" role="row">
<div class="dd__c" role="gridcell" data-d="14"><span class="dd__n">14</span></div>
<div class="dd__c" role="gridcell" data-d="15" aria-current="date"><span class="dd__n is-today">15</span></div>
<div class="dd__c" role="gridcell" data-d="16"><span class="dd__n">16</span><button class="dd__chip" type="button" aria-grabbed="false"><span class="dd__grip" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span><span class="dd__t">Deploy</span></button></div>
<div class="dd__c" role="gridcell" data-d="17"><span class="dd__n">17</span></div>
<div class="dd__c" role="gridcell" data-d="18"><span class="dd__n">18</span></div>
</div>
<div class="dd__r" role="row">
<div class="dd__c" role="gridcell" data-d="21"><span class="dd__n">21</span></div>
<div class="dd__c" role="gridcell" data-d="22"><span class="dd__n">22</span></div>
<div class="dd__c" role="gridcell" data-d="23"><span class="dd__n">23</span></div>
<div class="dd__c" role="gridcell" data-d="24"><span class="dd__n">24</span><button class="dd__chip" type="button" aria-grabbed="false"><span class="dd__grip" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span><span class="dd__t">Retro</span></button></div>
<div class="dd__c" role="gridcell" data-d="25"><span class="dd__n">25</span></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.dd {
// 한 칸 옆으로 옮기는 거리 = 칸 폭 + gap. 격자 폭이 min(320px, 90vw) 이므로 계산으로 얻는다
--step: calc((min(320px, 90vw) + #{$sp-1}) / 5);
box-sizing: border-box;
width: min(320px, 90vw);
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dd__head { display: flex; align-items: center; gap: $sp-2; margin: 0 0 $sp-2; }
.dd__ico { display: inline-flex; color: $color; }
.dd__title { font-size: clamp(11px, 3.4vw, 14px); font-weight: 700; }
.dd__live {
margin-left: auto;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
color: $subject-blue-deep; // 밝은 면 위 파란 글자 — 5.74:1
white-space: nowrap;
}
.dd__dow {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: $sp-1;
margin: 0 0 $sp-1;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
text-align: center;
color: rgba(23, 20, 26, .72); // AA 4.5 (.45 는 2.97:1 이었다)
}
.dd__grid { display: flex; flex-direction: column; gap: $sp-1; }
.dd__r { display: grid; grid-template-columns: repeat(5, 1fr); gap: $sp-1; }
.dd__c {
position: relative;
box-sizing: border-box;
height: clamp(34px, 10.5vw, 42px);
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
// 받을 준비 — 점선 테두리
.dd__c::after {
content: "";
position: absolute;
inset: 0;
border: 2px dashed $color;
border-radius: $r-sm;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing;
}
.dd__c.is-over::after { opacity: 1; }
.dd__n {
position: absolute;
top: $sp-1; left: $sp-1;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
color: rgba(23, 20, 26, .72); // AA 4.5 (.5 는 3.37:1 이었다)
}
.dd__n.is-today {
color: $subject-blue-deep; // 밝은 면 위 파란 글자 — 5.74:1
text-decoration: underline;
text-underline-offset: 2px;
}
.dd__chip {
position: absolute;
left: $sp-1; right: $sp-1; bottom: $sp-1;
box-sizing: border-box;
height: clamp(14px, 4.4vw, 18px);
display: flex;
align-items: center;
gap: $sp-1;
padding: 0 $sp-1;
border: 0;
border-radius: $r-xs;
background: $subject-blue-deep;
color: $subject-cream;
font-family: inherit;
font-size: clamp(8px, 2.3vw, 9px);
font-weight: 700;
line-height: 1;
cursor: grab;
touch-action: none;
overflow: hidden;
white-space: nowrap;
transition: box-shadow $duration $easing, transform $duration $easing;
}
.dd__grip { display: inline-flex; flex: 0 0 auto; opacity: .75; }
.dd__t { overflow: hidden; text-overflow: ellipsis; }
.dd__chip:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// 집힌 상태 — 떠서 기울고 그림자가 깊어진다. pointer-events 를 끊어야 밑의 칸을 집어낼 수 있다
.dd__chip.is-grab {
z-index: 5;
transform: translateY(-6px) scale(1.06) rotate(-4deg);
box-shadow: $shadow-raised;
cursor: grabbing;
pointer-events: none;
}
// ── 자동 루프: 칩 하나가 옆 칸으로 떠서 옮겨갔다 돌아온다 (2s) ──
.dd.is-demo .dd__chip--fly {
z-index: 5;
animation-name: dd-fly;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.dd.is-demo .dd__c--to::after {
animation-name: dd-ring;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dd.is-demo .dd__c--from::after {
animation-name: dd-ring-back;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dd-fly {
0%, 6% { transform: translate(0, 0) scale(1) rotate(0deg); box-shadow: 0 0 0 rgba(23, 20, 26, 0); }
20% { transform: translate(0, -6px) scale(1.06) rotate(-4deg); box-shadow: $shadow-raised; }
40% { transform: translate(var(--step), -6px) scale(1.06) rotate(-4deg); box-shadow: $shadow-raised; }
50%, 58% { transform: translate(var(--step), 0) scale(1) rotate(0deg); box-shadow: 0 0 0 rgba(23, 20, 26, 0); }
70% { transform: translate(var(--step), -6px) scale(1.06) rotate(4deg); box-shadow: $shadow-raised; }
88% { transform: translate(0, -6px) scale(1.06) rotate(4deg); box-shadow: $shadow-raised; }
100% { transform: translate(0, 0) scale(1) rotate(0deg); box-shadow: 0 0 0 rgba(23, 20, 26, 0); }
}
@keyframes dd-ring {
0%, 13% { opacity: 0; }
14%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
@keyframes dd-ring-back {
0%, 61% { opacity: 0; }
62%, 93% { opacity: 1; }
94%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.dd.is-demo .dd__chip--fly,
.dd.is-demo .dd__c--to::after,
.dd.is-demo .dd__c--from::after { animation: none !important; }
.dd__chip, .dd__c::after { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dd {
--step: calc((min(320px, 90vw) + 4px) / 5);
box-sizing: border-box;
width: min(320px, 90vw);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dd__head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
}
.dd__ico {
display: inline-flex;
color: #2f6df6;
}
.dd__title {
font-size: clamp(11px, 3.4vw, 14px);
font-weight: 700;
}
.dd__live {
margin-left: auto;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
color: #0a4fe8;
white-space: nowrap;
}
.dd__dow {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
margin: 0 0 4px;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
text-align: center;
color: rgba(23, 20, 26, 0.72);
}
.dd__grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.dd__r {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
}
.dd__c {
position: relative;
box-sizing: border-box;
height: clamp(34px, 10.5vw, 42px);
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.dd__c::after {
content: "";
position: absolute;
inset: 0;
border: 2px dashed #2f6df6;
border-radius: 8px;
opacity: 0;
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dd__c.is-over::after {
opacity: 1;
}
.dd__n {
position: absolute;
top: 4px;
left: 4px;
font-size: clamp(8px, 2.4vw, 10px);
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
}
.dd__n.is-today {
color: #0a4fe8;
text-decoration: underline;
text-underline-offset: 2px;
}
.dd__chip {
position: absolute;
left: 4px;
right: 4px;
bottom: 4px;
box-sizing: border-box;
height: clamp(14px, 4.4vw, 18px);
display: flex;
align-items: center;
gap: 4px;
padding: 0 4px;
border: 0;
border-radius: 4px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-size: clamp(8px, 2.3vw, 9px);
font-weight: 700;
line-height: 1;
cursor: grab;
touch-action: none;
overflow: hidden;
white-space: nowrap;
transition: box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dd__grip {
display: inline-flex;
flex: 0 0 auto;
opacity: 0.75;
}
.dd__t {
overflow: hidden;
text-overflow: ellipsis;
}
.dd__chip:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.dd__chip.is-grab {
z-index: 5;
transform: translateY(-6px) scale(1.06) rotate(-4deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grabbing;
pointer-events: none;
}
.dd.is-demo .dd__chip--fly {
z-index: 5;
animation-name: dd-fly;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.dd.is-demo .dd__c--to::after {
animation-name: dd-ring;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dd.is-demo .dd__c--from::after {
animation-name: dd-ring-back;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dd-fly {
0%, 6% {
transform: translate(0, 0) scale(1) rotate(0deg);
box-shadow: 0 0 0 rgba(23, 20, 26, 0);
}
20% {
transform: translate(0, -6px) scale(1.06) rotate(-4deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
40% {
transform: translate(var(--step), -6px) scale(1.06) rotate(-4deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
50%, 58% {
transform: translate(var(--step), 0) scale(1) rotate(0deg);
box-shadow: 0 0 0 rgba(23, 20, 26, 0);
}
70% {
transform: translate(var(--step), -6px) scale(1.06) rotate(4deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
88% {
transform: translate(0, -6px) scale(1.06) rotate(4deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
100% {
transform: translate(0, 0) scale(1) rotate(0deg);
box-shadow: 0 0 0 rgba(23, 20, 26, 0);
}
}
@keyframes dd-ring {
0%, 13% {
opacity: 0;
}
14%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
@keyframes dd-ring-back {
0%, 61% {
opacity: 0;
}
62%, 93% {
opacity: 1;
}
94%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.dd.is-demo .dd__chip--fly,
.dd.is-demo .dd__c--to::after,
.dd.is-demo .dd__c--from::after {
animation: none !important;
}
.dd__chip, .dd__c::after {
transition: none;
}
}// 포인터: pointerdown 으로 집고 pointermove 로 끌고 pointerup 에서 그 칸에 appendChild 한다.
// 키보드: 칩에 포커스 → Space 로 집기 → 화살표로 칸 이동 → Space 로 놓기 (Escape 취소).
var root = document.querySelector('.dd');
var grid = document.getElementById('dd-grid');
var live = document.getElementById('dd-live');
var cells = [].slice.call(grid.querySelectorAll('.dd__c'));
var COLS = 5;
var MSG_GRAB = 'Picked up';
var MSG_DROP = 'Dropped';
var drag = null, held = null, cur = 0;
function armCells(on) {
for (var i = 0; i < cells.length; i++) {
if (on) cells[i].setAttribute('aria-dropeffect', 'move');
else { cells[i].removeAttribute('aria-dropeffect'); cells[i].classList.remove('is-over'); }
}
}
function drop(chip, cell) {
if (cell && cell !== chip.parentNode) { cell.appendChild(chip); live.textContent = MSG_DROP + ' ' + cell.getAttribute('data-d'); }
else live.textContent = MSG_DROP;
}
// ── 포인터 ──
grid.addEventListener('pointerdown', function (e) {
var chip = e.target.closest ? e.target.closest('.dd__chip') : null;
if (!chip) return;
root.classList.remove('is-demo');
drag = { chip: chip, x: e.clientX, y: e.clientY, over: null };
chip.classList.add('is-grab');
chip.setAttribute('aria-grabbed', 'true');
armCells(true);
live.textContent = MSG_GRAB;
e.preventDefault();
});
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');
}
});
document.addEventListener('pointerup', function () {
if (!drag) return;
var chip = drag.chip, cell = drag.over;
chip.style.transform = '';
chip.classList.remove('is-grab');
chip.setAttribute('aria-grabbed', 'false');
armCells(false);
drop(chip, cell);
drag = null;
chip.focus({ preventScroll: true });
});
// ── 키보드 ──
grid.addEventListener('keydown', function (e) {
var chip = e.target.closest ? e.target.closest('.dd__chip') : null;
if (!chip) return;
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
root.classList.remove('is-demo');
if (held === chip) {
drop(chip, cells[cur]);
chip.classList.remove('is-grab');
chip.setAttribute('aria-grabbed', 'false');
armCells(false);
held = null;
chip.focus({ preventScroll: true });
} else {
held = chip;
cur = cells.indexOf(chip.parentNode);
chip.classList.add('is-grab');
chip.setAttribute('aria-grabbed', 'true');
armCells(true);
cells[cur].classList.add('is-over');
live.textContent = MSG_GRAB;
}
return;
}
if (e.key === 'Escape' && held === chip) {
chip.classList.remove('is-grab');
chip.setAttribute('aria-grabbed', 'false');
armCells(false);
held = null;
return;
}
if (held !== chip) return;
var n = cur;
if (e.key === 'ArrowLeft') n = cur - 1;
else if (e.key === 'ArrowRight') n = cur + 1;
else if (e.key === 'ArrowUp') n = cur - COLS;
else if (e.key === 'ArrowDown') n = cur + COLS;
else return;
if (n < 0 || n >= cells.length) return;
e.preventDefault();
cells[cur].classList.remove('is-over');
cur = n;
cells[cur].classList.add('is-over');
live.textContent = cells[cur].getAttribute('data-d');
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DragDropCalendar.module.scss";
export type DragDropEvent = { id: string; title: string; day: number };
export type DragDropCalendarProps = {
/** 칸에 붙일 날짜 라벨 (3행 × 5열 = 15칸) */
days?: number[];
events?: DragDropEvent[];
today?: number;
duration?: number;
easing?: string;
color?: string;
onMove?: (id: string, day: number) => void;
/** aria-live 안내 문구 — 언어팩에서 넣는다 */
labels?: { grabbed: string; dropped: string };
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type PtrEvt = { clientX: number; clientY: number; preventDefault: () => void };
const COLS = 5;
const DEFAULT_DAYS = [7, 8, 9, 10, 11, 14, 15, 16, 17, 18, 21, 22, 23, 24, 25];
const DEFAULT_EVENTS: DragDropEvent[] = [
{ id: "a", title: "리뷰", day: 8 },
{ id: "b", title: "배포", day: 16 },
{ id: "c", title: "회고", day: 24 },
];
function Grip() {
return (
<span className={styles.grip} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2" />
<path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12" />
</g>
</svg>
</span>
);
}
export default function DragDropCalendar({
days = DEFAULT_DAYS,
events = DEFAULT_EVENTS,
today = 15,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
onMove,
labels = { grabbed: "집었습니다", dropped: "놓았습니다" },
}: DragDropCalendarProps) {
const [placed, setPlaced] = useState<DragDropEvent[]>(events);
const [grabbed, setGrabbed] = useState<string | null>(null);
const [over, setOver] = useState<number>(-1);
const [offset, setOffset] = useState<{ x: number; y: number } | null>(null);
const [say, setSay] = useState("");
const chipRefs = useRef<{ [id: string]: HTMLButtonElement | null }>({});
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (!grabbed) return;
const el = chipRefs.current ? chipRefs.current[grabbed] : null;
if (el) el.focus({ preventScroll: true });
}, [grabbed]);
function place(id: string, day: number) {
setPlaced((ps) => ps.map((p) => (p.id === id ? { ...p, day } : p)));
setSay(`${labels.dropped} ${day}`);
if (onMove) onMove(id, day);
}
function dayFromPoint(x: number, y: number): number {
const el = document.elementFromPoint(x, y);
const cell = el && (el as HTMLElement).closest ? (el as HTMLElement).closest("[data-day]") : null;
return cell ? Number((cell as HTMLElement).dataset.day) : -1;
}
function onPointerDown(id: string, e: PtrEvt) {
e.preventDefault();
setGrabbed(id);
setSay(labels.grabbed);
const x0 = e.clientX;
const y0 = e.clientY;
setOffset({ x: 0, y: 0 });
let last = -1;
const onMovePtr = (ev: { clientX: number; clientY: number }) => {
setOffset({ x: ev.clientX - x0, y: ev.clientY - y0 });
last = dayFromPoint(ev.clientX, ev.clientY);
setOver(last);
};
const onUp = () => {
document.removeEventListener("pointermove", onMovePtr);
document.removeEventListener("pointerup", onUp);
if (last >= 0) place(id, last);
setGrabbed(null);
setOver(-1);
setOffset(null);
};
document.addEventListener("pointermove", onMovePtr);
document.addEventListener("pointerup", onUp);
}
function onKeyDown(id: string, day: number, e: KeyEvt) {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
if (grabbed === id) {
if (over >= 0) place(id, days[over]);
setGrabbed(null);
setOver(-1);
} else {
setGrabbed(id);
setOver(days.indexOf(day));
setSay(labels.grabbed);
}
return;
}
if (e.key === "Escape" && grabbed === id) { setGrabbed(null); setOver(-1); return; }
if (grabbed !== id || over < 0) return;
let n = over;
if (e.key === "ArrowLeft") n = over - 1;
else if (e.key === "ArrowRight") n = over + 1;
else if (e.key === "ArrowUp") n = over - COLS;
else if (e.key === "ArrowDown") n = over + COLS;
else return;
if (n < 0 || n >= days.length) return;
e.preventDefault();
setOver(n);
setSay(String(days[n]));
}
const rows: number[][] = [];
for (let r = 0; r * COLS < days.length; r += 1) rows.push(days.slice(r * COLS, r * COLS + COLS));
return (
<div className={styles.dd} style={vars}>
<div className={styles.head}>
<span className={styles.title}>2026. 9</span>
<span className={styles.live} role="status" aria-live="polite">{say}</span>
</div>
<div className={styles.grid} role="grid">
{rows.map((row) => (
<div className={styles.row} role="row" key={row[0]}>
{row.map((d) => {
const i = days.indexOf(d);
const isOver = grabbed !== null && over === i;
return (
<div
key={d}
data-day={d}
className={`${styles.cell} ${isOver ? styles.isOver : ""}`}
role="gridcell"
aria-current={d === today ? "date" : undefined}
aria-dropeffect={grabbed ? "move" : undefined}
>
<span className={`${styles.n} ${d === today ? styles.isToday : ""}`}>{d}</span>
{placed.filter((p) => p.day === d).map((p) => {
const held = grabbed === p.id;
const style: CSSProperties = held && offset
? { transform: `translate(${offset.x}px, ${offset.y - 6}px) scale(1.06) rotate(-4deg)` }
: {};
return (
<button
key={p.id}
ref={(el: HTMLButtonElement | null) => { if (chipRefs.current) chipRefs.current[p.id] = el; }}
type="button"
className={`${styles.chip} ${held ? styles.isGrab : ""}`}
style={style}
aria-grabbed={held}
onPointerDown={(e: PtrEvt) => onPointerDown(p.id, e)}
onKeyDown={(e: KeyEvt) => onKeyDown(p.id, d, e)}
>
<Grip />
<span className={styles.t}>{p.title}</span>
</button>
);
})}
</div>
);
})}
</div>
))}
</div>
</div>
);
}06 Pick-up and drop event card — Measured area 4.262% · moving frames 21/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
Pickers03 Drag inertia — tracking pointer velocity, then decaying it with an exponential glideCSS Range Slider: 9 Physics-Grade Custom Sliders
Pickers01 Calendar drag range select9 Date Range Picker UI Patterns — Copy-Paste
Pickers03 Two-way card-and-pin highlight9 Map UI Design Patterns — Click-to-Drop Pickers