03キーボード移動切替 — 方向値が変わるたびハイライトセルの値がグリッド上を移動する方式
方向値が変わるたびハイライトセルの値が隣のマスへ移り、行の端に達すると次の行へ自然に続きます。
使いどころキーボード中心の入力フォーム、アクセシビリティ優先ダッシュボード
FOCUS_SEQmoveRingTo()focusDay()

<main class="stage">
<div class="kb is-demo" id="kb">
<div class="kb__head"><span class="kb__label">2026年3月</span></div>
<div class="kb__weekday" aria-hidden="true">
<span>日</span><span>月</span><span>火</span><span>水</span><span>木</span><span>金</span><span>土</span>
</div>
<div class="kb__gridWrap">
<div class="kb__ring" id="kbRing"></div>
<div class="kb__grid" id="kbGrid" role="grid" aria-label="カレンダー"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: moveRingTo() 가 포커스된 셀의 getBoundingClientRect 좌표를 읽어
// 링을 그 좌표로 translate 이동시킨다 — 셀 사이를 좌표값으로 보간하며 옮겨간다.
@include stage($stage-orange);
.kb {
width: 100%;
max-width: 220px;
background: $subject-cream;
border-radius: $r-card;
padding: $sp-2;
box-shadow: $shadow-raised;
box-sizing: border-box;
}
.kb__head {
text-align: center;
margin-bottom: $sp-1;
}
.kb__label {
font-size: 11px;
font-weight: 700;
color: $subject-ink;
}
.kb__weekday {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: $sp-1;
}
.kb__weekday span {
font-size: 9px;
text-align: center;
color: rgba($subject-ink, .72);
}
.kb__gridWrap {
position: relative;
}
.kb__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 21px;
position: relative;
z-index: 1;
}
.kb__cell {
display: grid;
place-items: center;
border: none;
background: transparent;
font-size: 11px;
color: $subject-ink;
border-radius: $r-xs;
cursor: pointer;
}
.kb__cell:focus {
outline: none;
}
.kb__cell--out {
visibility: hidden;
}
.kb__ring {
position: absolute;
top: 0;
left: 0;
z-index: 0;
border: 2px solid $subject-blue-deep;
border-radius: $r-xs;
box-sizing: border-box;
transition: transform $dur-base $ease-spring;
pointer-events: none;
}
@media (max-width: 340px) {
.kb { max-width: 180px; }
.kb__grid { grid-auto-rows: 17px; }
.kb__cell { font-size: 10px; }
.kb__weekday span { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.kb__ring { 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);
}
.kb {
width: 100%;
max-width: 220px;
background: #fff7e6;
border-radius: 16px;
padding: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.kb__head {
text-align: center;
margin-bottom: 4px;
}
.kb__label {
font-size: 11px;
font-weight: 700;
color: #17141a;
}
.kb__weekday {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 4px;
}
.kb__weekday span {
font-size: 9px;
text-align: center;
color: rgba(23, 20, 26, 0.72);
}
.kb__gridWrap {
position: relative;
}
.kb__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 21px;
position: relative;
z-index: 1;
}
.kb__cell {
display: grid;
place-items: center;
border: none;
background: transparent;
font-size: 11px;
color: #17141a;
border-radius: 4px;
cursor: pointer;
}
.kb__cell:focus {
outline: none;
}
.kb__cell--out {
visibility: hidden;
}
.kb__ring {
position: absolute;
top: 0;
left: 0;
z-index: 0;
border: 2px solid #0a4fe8;
border-radius: 4px;
box-sizing: border-box;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
}
@media (max-width: 340px) {
.kb {
max-width: 180px;
}
.kb__grid {
grid-auto-rows: 17px;
}
.kb__cell {
font-size: 10px;
}
.kb__weekday span {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.kb__ring {
transition: none;
}
}(function () {
var BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
var CELL_TOTAL = 42; // 6행 고정
var FOCUS_SEQ = [14, 15, 16, 23, 22, 21, 14]; // 계산 겹: 자동재생이 훑는 日付 순서
function isLeap(y) { return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; }
function daysInMonth(y, m) {
var t = [31, isLeap(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return t[m - 1];
}
function dow(y, m, d) { // Zeller 합동식 — 0=日 ~ 6=土
var yy = y, mm = m;
if (mm < 3) { yy -= 1; mm += 12; }
var K = yy % 100, J = Math.floor(yy / 100);
var h = (d + Math.floor(13 * (mm + 1) / 5) + K + Math.floor(K / 4) + Math.floor(J / 4) - 2 * J) % 7;
h = ((h % 7) + 7) % 7;
return (h + 6) % 7;
}
var kb = document.getElementById('kb');
var grid = document.getElementById('kbGrid');
var ring = document.getElementById('kbRing');
var n = daysInMonth(BASE.y, BASE.m);
var lead = dow(BASE.y, BASE.m, 1);
var cellByDay = {};
var i, cell;
for (i = 0; i < lead; i++) {
cell = document.createElement('span');
cell.className = 'kb__cell kb__cell--out';
grid.appendChild(cell);
}
for (var d = 1; d <= n; d++) {
cell = document.createElement('button');
cell.type = 'button';
cell.className = 'kb__cell';
cell.textContent = String(d);
cell.dataset.day = String(d);
cell.tabIndex = d === BASE.d ? 0 : -1;
grid.appendChild(cell);
cellByDay[d] = cell;
}
var trail = CELL_TOTAL - lead - n;
for (i = 0; i < trail; i++) {
cell = document.createElement('span');
cell.className = 'kb__cell kb__cell--out';
grid.appendChild(cell);
}
var focusedDay = BASE.d;
var ringMeasured = false;
function moveRingTo(cellEl) {
// 계산 겹: 셀의 실제 렌더 좌표·크기를 실측해 링을 그 자리로 옮긴다(좌표 lerp 이동)
var cr = cellEl.getBoundingClientRect();
var gr = grid.getBoundingClientRect();
if (!ringMeasured) {
ring.style.width = cr.width + 'px';
ring.style.height = cr.height + 'px';
ringMeasured = true;
}
ring.style.transform = 'translate(' + (cr.left - gr.left) + 'px,' + (cr.top - gr.top) + 'px)';
}
grid.addEventListener('focusin', function (e) {
var cellEl = e.target.closest('.kb__cell');
if (!cellEl || !cellEl.dataset.day) return;
moveRingTo(cellEl);
});
function focusDay(target) {
if (target < 1) target = 1;
if (target > n) target = n;
var prev = cellByDay[focusedDay];
if (prev) prev.tabIndex = -1;
focusedDay = target;
var next = cellByDay[focusedDay];
next.tabIndex = 0;
next.focus({ preventScroll: true });
}
function human() {
kb.classList.remove('is-demo');
stopAutoplay();
}
grid.addEventListener('keydown', function (e) {
var delta = 0;
if (e.key === 'ArrowRight') delta = 1;
else if (e.key === 'ArrowLeft') delta = -1;
else if (e.key === 'ArrowDown') delta = 7;
else if (e.key === 'ArrowUp') delta = -7;
else return;
e.preventDefault();
human();
focusDay(focusedDay + delta);
});
grid.addEventListener('click', function (e) {
var cellEl = e.target.closest('.kb__cell');
if (!cellEl || !cellEl.dataset.day) return;
human();
focusDay(parseInt(cellEl.dataset.day, 10));
});
var seqIdx = 0;
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
function tick() {
seqIdx = (seqIdx + 1) % FOCUS_SEQ.length;
focusDay(FOCUS_SEQ[seqIdx]);
}
cellByDay[BASE.d].focus({ preventScroll: true });
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(tick, 500); }
})();import { useEffect, useRef, useState } from 'react';
import styles from './DatePickerKeyboardArrow.module.scss';
type Box<T> = { current: T };
const BASE = { y: 2026, m: 3, d: 14 }; // 계산 겹 상수: 고정 오늘, new Date() 미사용
const CELL_TOTAL = 42;
const FOCUS_SEQ = [14, 15, 16, 23, 22, 21, 14]; // 계산 겹: 자동재생이 훑는 날짜 순서
const WEEKDAY = ['일', '월', '화', '수', '목', '금', '토'];
function isLeap(y: number) {
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
}
function daysInMonth(y: number, m: number) {
const t = [31, isLeap(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
return t[m - 1];
}
function dow(y: number, m: number, d: number) { // Zeller 합동식 — 0=일 ~ 6=토
let yy = y;
let mm = m;
if (mm < 3) { yy -= 1; mm += 12; }
const K = yy % 100;
const J = Math.floor(yy / 100);
let h = (d + Math.floor((13 * (mm + 1)) / 5) + K + Math.floor(K / 4) + Math.floor(J / 4) - 2 * J) % 7;
h = ((h % 7) + 7) % 7;
return (h + 6) % 7;
}
const N_DAYS = daysInMonth(BASE.y, BASE.m);
const LEAD = dow(BASE.y, BASE.m, 1);
const TRAIL = CELL_TOTAL - LEAD - N_DAYS;
export default function DatePickerKeyboardArrow() {
const gridRef = useRef(null) as unknown as Box<HTMLDivElement>;
const ringRef = useRef(null) as unknown as Box<HTMLDivElement>;
const cellRefs = useRef({}) as unknown as Box<Record<number, HTMLButtonElement>>;
const ringMeasuredRef = useRef(false) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [focusedDay, setFocusedDay] = useState(BASE.d);
function human() {
setIsDemo(false);
}
function moveRingTo(cellEl: HTMLButtonElement) {
// 계산 겹: 셀의 실제 렌더 좌표·크기를 실측해 링을 그 자리로 옮긴다(좌표 lerp 이동)
const grid = gridRef.current;
const ring = ringRef.current;
if (!grid || !ring) return;
const cr = cellEl.getBoundingClientRect();
const gr = grid.getBoundingClientRect();
if (!ringMeasuredRef.current) {
ring.style.width = cr.width + 'px';
ring.style.height = cr.height + 'px';
ringMeasuredRef.current = true;
}
ring.style.transform = 'translate(' + (cr.left - gr.left) + 'px,' + (cr.top - gr.top) + 'px)';
}
function focusDay(target: number) {
let t = target;
if (t < 1) t = 1;
if (t > N_DAYS) t = N_DAYS;
setFocusedDay(t);
const el = cellRefs.current[t];
if (el) el.focus({ preventScroll: true });
}
useEffect(() => {
const el = cellRefs.current[focusedDay];
if (el) moveRingTo(el);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusedDay]);
useEffect(() => {
const el = cellRefs.current[BASE.d];
if (el) el.focus({ preventScroll: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!isDemo || reduce) return;
let seqIdx = 0;
const timer = window.setInterval(() => {
seqIdx = (seqIdx + 1) % FOCUS_SEQ.length;
focusDay(FOCUS_SEQ[seqIdx]);
}, 500);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDemo]);
function onKeyDown(e: { key: string; preventDefault: () => void }) {
let delta = 0;
if (e.key === 'ArrowRight') delta = 1;
else if (e.key === 'ArrowLeft') delta = -1;
else if (e.key === 'ArrowDown') delta = 7;
else if (e.key === 'ArrowUp') delta = -7;
else return;
e.preventDefault();
human();
focusDay(focusedDay + delta);
}
const lead = Array.from({ length: LEAD }, (_, i) => <span key={'l' + i} className={`${styles.kbCell} ${styles.kbCellOut}`} />);
const days = Array.from({ length: N_DAYS }, (_, i) => {
const d = i + 1;
return (
<button
type="button"
key={'d' + d}
className={styles.kbCell}
tabIndex={d === focusedDay ? 0 : -1}
ref={(el: HTMLButtonElement | null) => { if (el) cellRefs.current[d] = el; }}
onClick={() => { human(); focusDay(d); }}
>
{d}
</button>
);
});
const trail = Array.from({ length: TRAIL }, (_, i) => <span key={'t' + i} className={`${styles.kbCell} ${styles.kbCellOut}`} />);
return (
<div className={`${styles.kb} ${isDemo ? styles.isDemo : ''}`}>
<div className={styles.kbHead}><span className={styles.kbLabel}>2026년 3월</span></div>
<div className={styles.kbWeekday} aria-hidden="true">
{WEEKDAY.map((w) => <span key={w}>{w}</span>)}
</div>
<div className={styles.kbGridWrap}>
<div className={styles.kbRing} ref={ringRef} />
<div className={styles.kbGrid} ref={gridRef} role="grid" aria-label="달력" onKeyDown={onKeyDown}>
{lead}{days}{trail}
</div>
</div>
</div>
);
}03 キーボード移動切替 — 方向値が変わるたびハイライトセルの値がグリッド上を移動する方式 — 計測 累積面積 2.131% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · css 日付選択 9選 — ポップオーバーで開閉する9つの方式
このデモが属する記事
css 日付選択 9選 — ポップオーバーで開閉する9つの方式css 日付選択:9つのうち1つは入力欄の焦点値がオンになるとカレンダーがポップオーバーとして開き、1つは許可範囲外の日付の霞み値が大きくなって選べない状態が現れ、もう1つは画面幅の値が狭くなるとポップオーバーが下から上がるシートに変わりま この記事のデモをすべて見る


