06つまんで動かす予定カード
予定チップをつまむと少し浮いて傾き、通るマスが点線で受け入れを示し、離すとそのマスに実際に移ります。
使いどころ予定を動かすのにモーダルを開いて日付を選び直す必要がある管理画面を直すとき
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>月</span><span>火</span><span>水</span><span>木</span><span>金</span></div>
<div class="dd__grid" id="dd-grid" role="grid" aria-label="予定を動かすカレンダー">
<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">レビュー</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">デプロイ</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">ふり返り</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 = 'つかみました';
var MSG_DROP = '置きました';
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 つまんで動かす予定カード — 計測 累積面積 4.262% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · カレンダー css デザイン 9選 — 実際に月が動く
このデモが属する記事
カレンダー css デザイン 9選 — 実際に月が動くカレンダー css デザイン というと、ブラウザが用意する小さな日付ポップアップの色を変えるところで終わりがちです。ここで作る9つは1か月をまるごと広げる月間グリッドそのもので、どれも本当に動きます。1つは月を送るときに2枚のグリッドが一緒 この記事のデモをすべて見る


