03空いている枠だけ押せるスロットグリッド
30分刻みの枠が格子に並び、埋まった枠は押せず、選んだ枠には確定バッジが一コマで貼り付きます。
使いどころ病院や美容室の予約のように、空き時間を一目で見せる予約画面
aria-pressedgrid-template-columnssteps(1, end)

<main class="stage">
<div class="sg is-demo">
<div class="sg__grid" id="sg-grid" role="group" aria-label="予約の時間帯">
<button class="sg__slot" type="button" aria-pressed="false" data-t="09:00" style="--i:0">09:00</button>
<button class="sg__slot" type="button" disabled data-t="09:30" style="--i:1">09:30<span class="sg__sr">満席</span></button>
<button class="sg__slot" type="button" aria-pressed="false" data-t="10:00" style="--i:2">10:00</button>
<button class="sg__slot" type="button" aria-pressed="false" data-t="10:30" style="--i:3">10:30</button>
<button class="sg__slot" type="button" disabled data-t="11:00" style="--i:4">11:00<span class="sg__sr">満席</span></button>
<button class="sg__slot" type="button" aria-pressed="false" data-t="11:30" data-pick style="--i:5">11:30</button>
<button class="sg__slot" type="button" aria-pressed="false" data-t="12:00" style="--i:6">12:00</button>
<button class="sg__slot" type="button" aria-pressed="false" data-t="12:30" style="--i:7">12:30</button>
<button class="sg__slot" type="button" disabled data-t="13:00" style="--i:8">13:00<span class="sg__sr">満席</span></button>
<button class="sg__slot" type="button" aria-pressed="false" data-t="13:30" style="--i:9">13:30</button>
<button class="sg__slot" type="button" disabled data-t="14:00" style="--i:10">14:00<span class="sg__sr">満席</span></button>
<button class="sg__slot" type="button" aria-pressed="false" data-t="14:30" style="--i:11">14:30</button>
</div>
<p class="sg__out" data-fmt="{t} 予約" aria-live="polite">
<span class="sg__badge">
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>確定</span>
<span class="sg__box">
<span class="sg__txt" data-live>時間を選んでください</span>
<span class="sg__txt" data-v="2">11:30 予約</span>
</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-orange);
.sg {
box-sizing: border-box;
display: grid;
gap: $sp-3;
width: min(320px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 30분 단위 칸 12개 — 폭이 좁아지면 3열로
.sg__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $sp-2;
width: 100%;
}
.sg__slot {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
height: 36px;
border: 0;
border-radius: $r-sm;
background-color: $subject-cream;
color: $color;
font-family: inherit;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
font-weight: 700;
cursor: pointer;
}
.sg__slot:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
// 이미 찬 자리 — 진짜 disabled 라 눌러도 아무 일이 없다. 빗금은 있는 그대로 흐리게
.sg__slot[disabled] {
background-color: rgba(255, 247, 230, .48);
background-image: repeating-linear-gradient(45deg, transparent 0 4px, rgba(23, 20, 26, .18) 4px 8px);
color: $subject-ink; // 빗금 면 위 흐린 잉크 2.55:1 → 9.11:1
cursor: not-allowed;
}
.sg__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
// 눌린 칸은 aria-pressed 한 곳에서 갈린다
.sg:not(.is-demo) .sg__slot[aria-pressed="true"] {
background-color: $color;
color: $subject-cream;
}
.sg__out {
display: grid;
grid-template-columns: auto auto;
justify-content: center;
align-items: center;
gap: $sp-2;
margin: 0;
}
.sg__badge {
display: inline-flex;
align-items: center;
gap: $sp-1;
box-sizing: border-box;
height: 20px;
padding: 0 $sp-2;
border-radius: $r-pill;
background-color: $subject-cream;
color: $stage-orange;
font-size: 11px;
line-height: 1;
font-weight: 800;
white-space: nowrap;
opacity: 0;
}
.sg__badge svg { display: block; }
.sg:not(.is-demo).is-picked .sg__badge { opacity: 1; }
// 아직 아무 칸도 안 골랐으면 배지 자리를 비워 결과 문장이 가운데로 온다
.sg:not(.is-demo):not(.is-picked) .sg__badge { display: none; }
.sg__box { position: relative; display: block; width: 8.6em; height: 20px; }
.sg__txt {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 20px;
font-weight: 700;
color: $subject-cream;
white-space: nowrap;
opacity: 0;
}
.sg__txt[data-live] { opacity: 1; }
// ── 자동 루프 ──
// 옆 칸들이 순서대로 살짝 떠올라 컷 사이 프레임을 채운다
.sg.is-demo .sg__slot:not([data-pick]) {
animation-name: sg-rise;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * .07s);
animation-fill-mode: backwards;
}
@keyframes sg-rise {
0% { transform: translateY(0); }
5% { transform: translateY(-5px); }
11% { transform: translateY(0); }
100% { transform: translateY(0); }
}
// 고른 칸은 한 프레임에 컷으로 갈리고, 되돌림은 78·82·86% 로 쪼갠다
.sg.is-demo .sg__slot[data-pick] {
animation-name: sg-pick, sg-pop;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
@keyframes sg-pick {
0% { background-color: $subject-cream; color: $color; }
40% { background-color: $color; color: $subject-cream; }
86% { background-color: $subject-cream; color: $color; }
100% { background-color: $subject-cream; color: $color; }
}
@keyframes sg-pop {
0% { transform: scale(1); }
38% { transform: scale(1); }
42% { transform: scale(.93); }
50% { transform: scale(1.04); }
58% { transform: scale(1); }
100% { transform: scale(1); }
}
.sg.is-demo .sg__badge,
.sg.is-demo .sg__txt {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sg.is-demo .sg__badge { animation-name: sg-badge; }
.sg.is-demo .sg__txt[data-live] { animation-name: sg-t1; }
.sg.is-demo .sg__txt[data-v="2"] { animation-name: sg-t2; }
@keyframes sg-badge { 0% { opacity: 0; } 40% { opacity: 1; } 78% { opacity: 0; } 100% { opacity: 0; } }
@keyframes sg-t1 { 0% { opacity: 1; } 40% { opacity: 0; } 82% { opacity: 1; } 100% { opacity: 1; } }
@keyframes sg-t2 { 0% { opacity: 0; } 40% { opacity: 1; } 82% { opacity: 0; } 100% { opacity: 0; } }
@media (max-width: 340px) {
.sg { gap: $sp-2; }
.sg__grid { grid-template-columns: repeat(3, 1fr); gap: $sp-1; }
.sg__slot { height: 28px; }
.sg__badge { height: 18px; padding: 0 $sp-1; }
.sg__box { height: 18px; }
.sg__txt { line-height: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo .sg__slot,
.sg.is-demo .sg__badge,
.sg.is-demo .sg__txt { animation: none !important; }
.sg.is-demo .sg__txt[data-live] { opacity: 1; }
}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);
}
.sg {
box-sizing: border-box;
display: grid;
gap: 12px;
width: min(320px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sg__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
width: 100%;
}
.sg__slot {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
height: 36px;
border: 0;
border-radius: 8px;
background-color: #fff7e6;
color: #17141a;
font-family: inherit;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
font-weight: 700;
cursor: pointer;
}
.sg__slot:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.sg__slot[disabled] {
background-color: rgba(255, 247, 230, 0.48);
background-image: repeating-linear-gradient(45deg, transparent 0 4px, rgba(23, 20, 26, 0.18) 4px 8px);
color: #17141a;
cursor: not-allowed;
}
.sg__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.sg:not(.is-demo) .sg__slot[aria-pressed=true] {
background-color: #17141a;
color: #fff7e6;
}
.sg__out {
display: grid;
grid-template-columns: auto auto;
justify-content: center;
align-items: center;
gap: 8px;
margin: 0;
}
.sg__badge {
display: inline-flex;
align-items: center;
gap: 4px;
box-sizing: border-box;
height: 20px;
padding: 0 8px;
border-radius: 999px;
background-color: #fff7e6;
color: #ff4d1f;
font-size: 11px;
line-height: 1;
font-weight: 800;
white-space: nowrap;
opacity: 0;
}
.sg__badge svg {
display: block;
}
.sg:not(.is-demo).is-picked .sg__badge {
opacity: 1;
}
.sg:not(.is-demo):not(.is-picked) .sg__badge {
display: none;
}
.sg__box {
position: relative;
display: block;
width: 8.6em;
height: 20px;
}
.sg__txt {
position: absolute;
left: 0;
right: 0;
top: 0;
text-align: center;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 20px;
font-weight: 700;
color: #fff7e6;
white-space: nowrap;
opacity: 0;
}
.sg__txt[data-live] {
opacity: 1;
}
.sg.is-demo .sg__slot:not([data-pick]) {
animation-name: sg-rise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 0.07s);
animation-fill-mode: backwards;
}
@keyframes sg-rise {
0% {
transform: translateY(0);
}
5% {
transform: translateY(-5px);
}
11% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
.sg.is-demo .sg__slot[data-pick] {
animation-name: sg-pick, sg-pop;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes sg-pick {
0% {
background-color: #fff7e6;
color: #17141a;
}
40% {
background-color: #17141a;
color: #fff7e6;
}
86% {
background-color: #fff7e6;
color: #17141a;
}
100% {
background-color: #fff7e6;
color: #17141a;
}
}
@keyframes sg-pop {
0% {
transform: scale(1);
}
38% {
transform: scale(1);
}
42% {
transform: scale(0.93);
}
50% {
transform: scale(1.04);
}
58% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.sg.is-demo .sg__badge,
.sg.is-demo .sg__txt {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sg.is-demo .sg__badge {
animation-name: sg-badge;
}
.sg.is-demo .sg__txt[data-live] {
animation-name: sg-t1;
}
.sg.is-demo .sg__txt[data-v="2"] {
animation-name: sg-t2;
}
@keyframes sg-badge {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
78% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes sg-t1 {
0% {
opacity: 1;
}
40% {
opacity: 0;
}
82% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes sg-t2 {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
82% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.sg {
gap: 8px;
}
.sg__grid {
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.sg__slot {
height: 28px;
}
.sg__badge {
height: 18px;
padding: 0 4px;
}
.sg__box {
height: 18px;
}
.sg__txt {
line-height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo .sg__slot,
.sg.is-demo .sg__badge,
.sg.is-demo .sg__txt {
animation: none !important;
}
.sg.is-demo .sg__txt[data-live] {
opacity: 1;
}
}// 찬 자리는 진짜 disabled 라 눌러도 아무 일이 없다. 고른 칸은 aria-pressed 한 곳에만 적힌다
(function () {
var sg = document.querySelector('.sg'), grid = document.getElementById('sg-grid');
var out = sg.querySelector('.sg__out'), live = sg.querySelector('[data-live]');
var slots = [].slice.call(grid.querySelectorAll('.sg__slot'));
function pick(b) {
sg.classList.remove('is-demo');
slots.forEach(function (s) { if (!s.disabled) s.setAttribute('aria-pressed', String(s === b)); });
sg.classList.add('is-picked');
live.textContent = out.dataset.fmt.replace('{t}', b.dataset.t);
}
slots.forEach(function (s) { s.addEventListener('click', function () { pick(s); }); });
grid.addEventListener('keydown', function (e) {
var cols = getComputedStyle(grid).gridTemplateColumns.split(' ').length;
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: cols, ArrowUp: -cols }[e.key];
var jump = e.key === 'Home' || e.key === 'End';
if (!step && !jump) return;
var k = slots.indexOf(document.activeElement);
if (k < 0) return;
e.preventDefault();
var d = jump ? (e.key === 'Home' ? -1 : 1) : (step > 0 ? 1 : -1);
var j = jump ? (e.key === 'Home' ? 0 : slots.length - 1) : k + step;
while (j >= 0 && j < slots.length && slots[j].disabled) j += d; // 찬 자리는 건너뛴다
if (j >= 0 && j < slots.length) slots[j].focus({ preventScroll: true });
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BookingSlotGrid.module.scss";
export type BookingSlotGridProps = {
labels?: { group: string; full: string; empty: string; confirmed: string; booked: string };
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type SlotVars = CSSProperties & { "--i"?: number };
type GridKeyEvt = { key: string; preventDefault(): void; currentTarget: HTMLElement };
const SLOTS = [
{ t: "09:00", taken: false }, { t: "09:30", taken: true },
{ t: "10:00", taken: false }, { t: "10:30", taken: false },
{ t: "11:00", taken: true }, { t: "11:30", taken: false },
{ t: "12:00", taken: false }, { t: "12:30", taken: false },
{ t: "13:00", taken: true }, { t: "13:30", taken: false },
{ t: "14:00", taken: true }, { t: "14:30", taken: false },
];
const PICK = 5; // 자동 루프가 고르는 칸
export default function BookingSlotGrid({
labels = {
group: "Booking time slots",
full: "Full",
empty: "Pick a time",
confirmed: "Confirmed",
booked: "{t} booked",
},
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
}: BookingSlotGridProps) {
const [demo, setDemo] = useState(true);
const [picked, setPicked] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 찬 자리는 진짜 disabled 라 눌러도 아무 일이 없다. 고른 칸은 aria-pressed 한 곳에만 적힌다
function pick(t: string) {
setDemo(false);
setPicked(t);
}
function onKey(e: GridKeyEvt) {
const grid = e.currentTarget;
const btns = Array.from(grid.querySelectorAll("button"));
const cols = getComputedStyle(grid).gridTemplateColumns.split(" ").length;
const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1
: e.key === "ArrowDown" ? cols : e.key === "ArrowUp" ? -cols : 0;
const jump = e.key === "Home" || e.key === "End";
if (!step && !jump) return;
const k = btns.indexOf(document.activeElement as unknown as HTMLButtonElement);
if (k < 0) return;
e.preventDefault();
const d = jump ? (e.key === "Home" ? 1 : -1) : (step > 0 ? 1 : -1);
let j = jump ? (e.key === "Home" ? 0 : btns.length - 1) : k + step;
while (j >= 0 && j < btns.length && btns[j].disabled) j += d; // 찬 자리는 건너뛴다
const next = btns[j];
if (next) next.focus({ preventScroll: true });
}
return (
<div className={`${styles.wrap} ${demo ? styles.isDemo : ""} ${picked ? styles.isPicked : ""}`} style={vars}>
<div className={styles.grid} role="group" aria-label={labels.group} onKeyDown={onKey}>
{SLOTS.map((s, i) => (
<button
key={s.t}
className={styles.slot}
type="button"
disabled={s.taken}
aria-pressed={s.taken ? undefined : picked === s.t}
data-pick={i === PICK ? "" : undefined}
style={{ "--i": i } as SlotVars}
onClick={() => pick(s.t)}
>
{s.t}
{s.taken ? <span className={styles.sr}>{labels.full}</span> : null}
</button>
))}
</div>
<p className={styles.out} aria-live="polite">
<span className={styles.badge}>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
{labels.confirmed}
</span>
<span className={styles.box}>
<span className={styles.txt} data-live>{picked ? labels.booked.replace("{t}", picked) : labels.empty}</span>
<span className={styles.txt} data-v="2">{labels.booked.replace("{t}", SLOTS[PICK].t)}</span>
</span>
</p>
</div>
);
}03 空いている枠だけ押せるスロットグリッド — 計測 累積面積 17.663% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · タイムピッカー ui 9選 — 一点から区間まで
このデモが属する記事
タイムピッカー ui 9選 — 一点から区間までタイムピッカー ui とは、何時何分という一点だけでなく、いつからいつまでという長さまで画面上で決めるための部品のことです。ダイヤル・ドラム・枠の格子から、区間バー・繰り返しルール・時差表まで、実際に動く9つを置きました。 この記事のデモをすべて見る


