03Webinar slot booking
Of the three sessions the sold-out one is disabled from the start; picking an open one runs the time-left clock down to the second, and pressing register drops a confirmed badge right into that slot.
Best for
aria-checkedtranslateYsetInterval

<main class="stage is-demo">
<section class="wb">
<p class="wb__title">
<span class="wb__title-ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><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>
Pick a webinar time
</p>
<div class="wb__slots" id="wb-slots" role="radiogroup" aria-label="Webinar sessions">
<button class="wb__slot" type="button" role="radio" aria-checked="true" tabindex="0" data-t="Thu 8:00 PM">
<span class="wb__ring" aria-hidden="true"></span>
<span class="wb__day">Thu 8:00 PM</span>
<span class="wb__seat">8 seats left</span>
<span class="wb__ok" aria-hidden="true"><svg class="wb__ok-ic" viewBox="0 0 24 24" width="10" height="10"><polyline points="4 12 9 17 20 6" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>Booked</span>
</button>
<button class="wb__slot" type="button" role="radio" aria-checked="false" tabindex="-1" data-t="Fri 11:00 AM">
<span class="wb__ring" aria-hidden="true"></span>
<span class="wb__day">Fri 11:00 AM</span>
<span class="wb__seat">24 seats</span>
<span class="wb__ok" aria-hidden="true"><svg class="wb__ok-ic" viewBox="0 0 24 24" width="10" height="10"><polyline points="4 12 9 17 20 6" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>Booked</span>
</button>
<button class="wb__slot" type="button" role="radio" aria-checked="false" tabindex="-1" data-t="Fri 8:00 PM" disabled>
<span class="wb__ring" aria-hidden="true"></span>
<span class="wb__day">Fri 8:00 PM</span>
<span class="wb__seat wb__seat--closed">Full</span>
<span class="wb__ok" aria-hidden="true"><svg class="wb__ok-ic" viewBox="0 0 24 24" width="10" height="10"><polyline points="4 12 9 17 20 6" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>Booked</span>
</button>
</div>
<p class="wb__cd" role="timer" aria-live="off">
<span class="wb__cd-ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m64-88a8 8 0 0 1-8 8h-56a8 8 0 0 1-8-8V72a8 8 0 0 1 16 0v48h48a8 8 0 0 1 8 8"/></g></svg></span>
Registration closes in
<span class="wb__clock" aria-hidden="true"><b class="wb__mm" id="wb-mm">14</b>:<span class="wb__d" id="wb-d1" style="--d:3"><span class="wb__strip"><b>0</b><b>1</b><b>2</b><b>3</b><b>4</b><b>5</b><b>6</b><b>7</b><b>8</b><b>9</b></span></span><span class="wb__d wb__d--s1" id="wb-d0" style="--d:2"><span class="wb__strip"><b>0</b><b>1</b><b>2</b><b>3</b><b>4</b><b>5</b><b>6</b><b>7</b><b>8</b><b>9</b></span></span></span>
<span class="sr-only" id="wb-cdt">14 min 32 sec left</span>
</p>
<button class="wb__go" id="wb-go" type="button">Book this session</button>
<p class="wb__msg" id="wb-msg" role="status" aria-live="polite">Pick a time and we'll hold your seat</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-orange);
.wb {
box-sizing: border-box; width: min(360px, 94vw);
display: flex; flex-direction: column; gap: $sp-2;
background: $subject-cream; color: $subject-ink;
border-radius: $r-card; padding: $sp-4; box-shadow: $shadow-raised;
}
.wb__title { display: flex; align-items: center; gap: $sp-1; margin: 0; font-weight: 800; font-size: 14px; line-height: 1.3; }
.wb__title-ic { display: inline-flex; color: $color; }
.wb__slots { display: grid; grid-template-columns: repeat(3, 1fr); gap: $sp-2; }
.wb__slot {
position: relative; box-sizing: border-box;
display: flex; flex-direction: column; align-items: center; gap: $sp-1;
padding: $sp-2; border: 1.5px solid rgba(23, 20, 26, .14); border-radius: $r-control;
background: $stage-paper; color: $subject-ink; cursor: pointer;
transform: translateY(0);
transition: transform $dur-quick $easing;
}
.wb__slot[disabled] { background: rgba(23, 20, 26, .06); color: rgba(23, 20, 26, .42); cursor: not-allowed; }
.wb__slot:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 선택 링은 테두리색을 바꾸는 대신 겹쳐 둔 판을 opacity 로만 켠다 (transform·opacity 만 애니메이션)
.wb__ring {
position: absolute; inset: 0; border: 2px solid $color; border-radius: $r-control; opacity: 0;
}
.wb__slot[aria-checked="true"] .wb__ring { opacity: 1; }
.wb__slot[aria-checked="true"] { transform: translateY(-2px); }
.wb__day { font-weight: 800; font-size: 11px; line-height: 1.2; }
.wb__seat { font-weight: 600; font-size: 10px; line-height: 1.2; color: rgba(23, 20, 26, .58); }
.wb__seat--closed {
padding: 0 $sp-1; border-radius: $r-pill;
background: rgba($error, .14); color: $error; font-weight: 800;
}
.wb__ok {
display: inline-flex; align-items: center; gap: $sp-1;
padding: 0 $sp-1; border-radius: $r-pill;
background: rgba($color, .14); color: $color;
font-weight: 800; font-size: 9px; line-height: 1.4;
opacity: 0; transform: translateY(-8px);
transition: opacity $dur-quick $ease-out, transform $dur-base $easing;
}
.wb__ok-ic { stroke-dasharray: 28; stroke-dashoffset: 28; transition: stroke-dashoffset $dur-base $ease-out; }
.wb__slot.is-ok .wb__ok { opacity: 1; transform: translateY(0); }
.wb__slot.is-ok .wb__ok-ic { stroke-dashoffset: 0; }
.wb__cd { display: flex; align-items: center; gap: $sp-1; margin: 0; font-weight: 600; font-size: 11px; line-height: 1.3; color: rgba(23, 20, 26, .7); }
.wb__cd-ic { display: inline-flex; color: $color; }
.wb__clock { display: inline-flex; align-items: flex-end; font-weight: 800; font-size: 13px; line-height: 1.15; color: $subject-ink; font-variant-numeric: tabular-nums; }
// 숫자 띠 — 창 하나 높이만 보이고 띠를 한 칸씩 올려 숫자를 바꾼다
.wb__d { display: block; overflow: hidden; width: .62em; height: 1.15em; }
.wb__strip { display: block; transform: translateY(calc(var(--d, 0) * -1.15em)); }
.wb__strip b { display: block; height: 1.15em; line-height: 1.15; font-weight: 800; text-align: center; }
.wb__go {
padding: $sp-2 $sp-3; border: 0; border-radius: $r-control;
background: $color; color: $stage-paper;
font-weight: 800; font-size: 12px; line-height: 1.2; cursor: pointer;
}
.wb__go:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.wb__msg { margin: 0; min-height: 13px; font-weight: 500; font-size: 10px; line-height: 1.3; color: rgba(23, 20, 26, .6); }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
// ── 측정용 자동 루프 ────────────────────────────────
// 선택이 1회차 → 2회차로 한 프레임에 옮겨 가고(컷), 카드가 살짝 떠오르며(translateY) 초 숫자 띠가 계속 흐른다
.stage.is-demo .wb__slot:nth-child(1) .wb__ring {
animation-name: wb-ring-a; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.stage.is-demo .wb__slot:nth-child(2) .wb__ring {
animation-name: wb-ring-b; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes wb-ring-a {
0%, 43% { opacity: 1; }
44%, 100% { opacity: 0; }
}
@keyframes wb-ring-b {
0%, 43% { opacity: 0; }
44%, 100% { opacity: 1; }
}
.stage.is-demo .wb__slot:nth-child(1) {
animation-name: wb-lift-a; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.stage.is-demo .wb__slot:nth-child(2) {
animation-name: wb-lift-b; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes wb-lift-a {
0%, 38% { transform: translateY(-2px); }
52%, 100% { transform: translateY(0); }
}
@keyframes wb-lift-b {
0%, 40% { transform: translateY(0); }
56% { transform: translateY(-2px); }
100% { transform: translateY(-2px); }
}
// 확정 배지는 마지막 30% 에 한 번 내려앉는다 (사라지는 장면은 재현하지 않는다)
.stage.is-demo .wb__slot:nth-child(2) .wb__ok {
animation-name: wb-badge; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes wb-badge {
0%, 70% { opacity: 0; transform: translateY(-8px); }
70.01% { opacity: 1; transform: translateY(-8px); }
86% { opacity: 1; transform: translateY(0); }
100% { opacity: 1; transform: translateY(0); }
}
.stage.is-demo .wb__slot:nth-child(2) .wb__ok-ic {
animation-name: wb-draw; animation-duration: $duration;
animation-timing-function: $ease-out; animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes wb-draw {
0%, 70% { stroke-dashoffset: 28; }
88% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: 0; }
}
// 초 자리는 JS 가 실제로 세지만, 루프에서는 띠가 한 칸씩 올라가며 초가 흐르는 것을 보여 준다
.stage.is-demo .wb__d--s1 .wb__strip {
animation-name: wb-tick; animation-duration: $duration;
animation-timing-function: steps(10, end); animation-iteration-count: infinite;
}
@keyframes wb-tick {
from { transform: translateY(-10.35em); }
to { transform: translateY(1.15em); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.wb__slot, .wb__ring, .wb__ok, .wb__ok-ic, .wb__strip,
.stage.is-demo .wb__slot:nth-child(1), .stage.is-demo .wb__slot:nth-child(2),
.stage.is-demo .wb__slot:nth-child(1) .wb__ring, .stage.is-demo .wb__slot:nth-child(2) .wb__ring,
.stage.is-demo .wb__slot:nth-child(2) .wb__ok, .stage.is-demo .wb__slot:nth-child(2) .wb__ok-ic,
.stage.is-demo .wb__d--s1 .wb__strip {
animation: none !important;
transition: none !important;
}
// 움직임은 꺼도 「어느 회차가 골라졌나」는 남긴다
.stage.is-demo .wb__slot:nth-child(1) .wb__ring { opacity: 0 !important; }
.stage.is-demo .wb__slot:nth-child(2) .wb__ring { opacity: 1 !important; }
.stage.is-demo .wb__slot:nth-child(2) { transform: translateY(-2px) !important; }
.stage.is-demo .wb__slot:nth-child(2) .wb__ok { opacity: 1 !important; transform: translateY(0) !important; }
.stage.is-demo .wb__slot:nth-child(2) .wb__ok-ic { stroke-dashoffset: 0 !important; }
}
// ── 반응형(320×200) ──────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.wb { width: min(292px, 92vw); gap: $sp-1; padding: $sp-2; }
.wb__title { font-size: 12px; }
.wb__title-ic svg { width: 12px; height: 12px; }
.wb__slots { gap: $sp-1; }
.wb__slot { gap: 0; padding: $sp-1; }
.wb__day { font-size: 10px; }
.wb__seat { font-size: 9px; }
.wb__ok { font-size: 8px; }
.wb__cd { font-size: 9px; }
.wb__cd-ic svg { width: 12px; height: 12px; }
.wb__clock { font-size: 11px; }
.wb__go { padding: $sp-1 $sp-2; font-size: 10px; }
.wb__msg { min-height: 0; font-size: 9px; }
}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);
}
.wb {
box-sizing: border-box;
width: min(360px, 94vw);
display: flex;
flex-direction: column;
gap: 8px;
background: #fff7e6;
color: #17141a;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.wb__title {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: 14px;
line-height: 1.3;
}
.wb__title-ic {
display: inline-flex;
color: #2f6df6;
}
.wb__slots {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.wb__slot {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px;
border: 1.5px solid rgba(23, 20, 26, 0.14);
border-radius: 12px;
background: #ffffff;
color: #17141a;
cursor: pointer;
transform: translateY(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.wb__slot[disabled] {
background: rgba(23, 20, 26, 0.06);
color: rgba(23, 20, 26, 0.42);
cursor: not-allowed;
}
.wb__slot:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.wb__ring {
position: absolute;
inset: 0;
border: 2px solid #2f6df6;
border-radius: 12px;
opacity: 0;
}
.wb__slot[aria-checked=true] .wb__ring {
opacity: 1;
}
.wb__slot[aria-checked=true] {
transform: translateY(-2px);
}
.wb__day {
font-weight: 800;
font-size: 11px;
line-height: 1.2;
}
.wb__seat {
font-weight: 600;
font-size: 10px;
line-height: 1.2;
color: rgba(23, 20, 26, 0.58);
}
.wb__seat--closed {
padding: 0 4px;
border-radius: 999px;
background: rgba(194, 54, 26, 0.14);
color: #c2361a;
font-weight: 800;
}
.wb__ok {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 4px;
border-radius: 999px;
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
font-weight: 800;
font-size: 9px;
line-height: 1.4;
opacity: 0;
transform: translateY(-8px);
transition: opacity 220ms ease-out, transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.wb__ok-ic {
stroke-dasharray: 28;
stroke-dashoffset: 28;
transition: stroke-dashoffset 300ms ease-out;
}
.wb__slot.is-ok .wb__ok {
opacity: 1;
transform: translateY(0);
}
.wb__slot.is-ok .wb__ok-ic {
stroke-dashoffset: 0;
}
.wb__cd {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 600;
font-size: 11px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.7);
}
.wb__cd-ic {
display: inline-flex;
color: #2f6df6;
}
.wb__clock {
display: inline-flex;
align-items: flex-end;
font-weight: 800;
font-size: 13px;
line-height: 1.15;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.wb__d {
display: block;
overflow: hidden;
width: 0.62em;
height: 1.15em;
}
.wb__strip {
display: block;
transform: translateY(calc(var(--d, 0) * -1.15em));
}
.wb__strip b {
display: block;
height: 1.15em;
line-height: 1.15;
font-weight: 800;
text-align: center;
}
.wb__go {
padding: 8px 12px;
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #ffffff;
font-weight: 800;
font-size: 12px;
line-height: 1.2;
cursor: pointer;
}
.wb__go:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.wb__msg {
margin: 0;
min-height: 13px;
font-weight: 500;
font-size: 10px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.6);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.stage.is-demo .wb__slot:nth-child(1) .wb__ring {
animation-name: wb-ring-a;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.stage.is-demo .wb__slot:nth-child(2) .wb__ring {
animation-name: wb-ring-b;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes wb-ring-a {
0%, 43% {
opacity: 1;
}
44%, 100% {
opacity: 0;
}
}
@keyframes wb-ring-b {
0%, 43% {
opacity: 0;
}
44%, 100% {
opacity: 1;
}
}
.stage.is-demo .wb__slot:nth-child(1) {
animation-name: wb-lift-a;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.stage.is-demo .wb__slot:nth-child(2) {
animation-name: wb-lift-b;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes wb-lift-a {
0%, 38% {
transform: translateY(-2px);
}
52%, 100% {
transform: translateY(0);
}
}
@keyframes wb-lift-b {
0%, 40% {
transform: translateY(0);
}
56% {
transform: translateY(-2px);
}
100% {
transform: translateY(-2px);
}
}
.stage.is-demo .wb__slot:nth-child(2) .wb__ok {
animation-name: wb-badge;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes wb-badge {
0%, 70% {
opacity: 0;
transform: translateY(-8px);
}
70.01% {
opacity: 1;
transform: translateY(-8px);
}
86% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.stage.is-demo .wb__slot:nth-child(2) .wb__ok-ic {
animation-name: wb-draw;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes wb-draw {
0%, 70% {
stroke-dashoffset: 28;
}
88% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: 0;
}
}
.stage.is-demo .wb__d--s1 .wb__strip {
animation-name: wb-tick;
animation-duration: 2s;
animation-timing-function: steps(10, end);
animation-iteration-count: infinite;
}
@keyframes wb-tick {
from {
transform: translateY(-10.35em);
}
to {
transform: translateY(1.15em);
}
}
@media (prefers-reduced-motion: reduce) {
.wb__slot, .wb__ring, .wb__ok, .wb__ok-ic, .wb__strip,
.stage.is-demo .wb__slot:nth-child(1), .stage.is-demo .wb__slot:nth-child(2),
.stage.is-demo .wb__slot:nth-child(1) .wb__ring, .stage.is-demo .wb__slot:nth-child(2) .wb__ring,
.stage.is-demo .wb__slot:nth-child(2) .wb__ok, .stage.is-demo .wb__slot:nth-child(2) .wb__ok-ic,
.stage.is-demo .wb__d--s1 .wb__strip {
animation: none !important;
transition: none !important;
}
.stage.is-demo .wb__slot:nth-child(1) .wb__ring {
opacity: 0 !important;
}
.stage.is-demo .wb__slot:nth-child(2) .wb__ring {
opacity: 1 !important;
}
.stage.is-demo .wb__slot:nth-child(2) {
transform: translateY(-2px) !important;
}
.stage.is-demo .wb__slot:nth-child(2) .wb__ok {
opacity: 1 !important;
transform: translateY(0) !important;
}
.stage.is-demo .wb__slot:nth-child(2) .wb__ok-ic {
stroke-dashoffset: 0 !important;
}
}
@media (max-width: 340px), (max-height: 220px) {
.wb {
width: min(292px, 92vw);
gap: 4px;
padding: 8px;
}
.wb__title {
font-size: 12px;
}
.wb__title-ic svg {
width: 12px;
height: 12px;
}
.wb__slots {
gap: 4px;
}
.wb__slot {
gap: 0;
padding: 4px;
}
.wb__day {
font-size: 10px;
}
.wb__seat {
font-size: 9px;
}
.wb__ok {
font-size: 8px;
}
.wb__cd {
font-size: 9px;
}
.wb__cd-ic svg {
width: 12px;
height: 12px;
}
.wb__clock {
font-size: 11px;
}
.wb__go {
padding: 4px 8px;
font-size: 10px;
}
.wb__msg {
min-height: 0;
font-size: 9px;
}
}// 실제 동작: 회차는 radiogroup 이라 클릭·좌우 방향키로 고른다(aria-checked 로 상태를 알린다). setInterval 이 1초마다
// 남은 시간을 줄여 숫자 띠를 --d 만큼 올리고, 예약 버튼은 고른 회차에 확정 배지를 내려앉힌다.
var stage = document.querySelector('.stage');
var slots = [].slice.call(document.querySelectorAll('.wb__slot'));
var book = document.getElementById('wb-go');
var msg = document.getElementById('wb-msg');
var srText = document.getElementById('wb-cdt');
var mm = document.getElementById('wb-mm'), d1 = document.getElementById('wb-d1'), d0 = document.getElementById('wb-d0');
var left = 14 * 60 + 32, picked = 0, booked = -1;
function human() { stage.classList.remove('is-demo'); }
function tick() {
var m = Math.floor(left / 60), s = left % 60;
mm.textContent = m < 10 ? '0' + m : '' + m;
d1.style.setProperty('--d', Math.floor(s / 10));
d0.style.setProperty('--d', s % 10);
srText.textContent = m + ' min ' + s + ' sec left';
}
function pick(i) {
picked = i;
slots.forEach(function (b, j) {
b.setAttribute('aria-checked', j === i ? 'true' : 'false');
b.tabIndex = j === i ? 0 : -1;
});
slots[i].focus({ preventScroll: true });
}
slots.forEach(function (b, i) { b.addEventListener('click', function () { human(); pick(i); }); });
document.getElementById('wb-slots').addEventListener('keydown', function (e) {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
e.preventDefault(); human();
var n = picked, step = e.key === 'ArrowRight' ? 1 : slots.length - 1;
do { n = (n + step) % slots.length; } while (slots[n].disabled && n !== picked);
pick(n);
});
book.addEventListener('click', function () {
human();
if (booked === picked) { msg.textContent = 'You\'ve already booked this one'; return; }
booked = picked;
slots.forEach(function (b) { b.classList.remove('is-ok'); });
slots[picked].classList.add('is-ok');
msg.textContent = slots[picked].dataset.t + ' session booked. A confirmation email is on its way';
});
setInterval(function () { if (left > 0) { left--; tick(); } }, 1000);
tick();
stage.addEventListener('pointerdown', human, { once: true });
window.addEventListener('keydown', human, { once: true });import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./WebinarSlotBooking.module.scss";
export type WebinarSlotBookingProps = {
/** 격자 미리보기용 루프 길이(ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type DigitVars = CSSProperties & { "--d"?: number };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
const sessions = [
{ day: "목 20:00", seats: "8석 남음", closed: false },
{ day: "금 11:00", seats: "24석", closed: false },
{ day: "금 20:00", seats: "마감", closed: true },
];
function CalendarBlank() {
return (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true">
<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>
);
}
function Clock() {
return (
<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="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m64-88a8 8 0 0 1-8 8h-56a8 8 0 0 1-8-8V72a8 8 0 0 1 16 0v48h48a8 8 0 0 1 8 8" />
</g>
</svg>
);
}
function Digit({ value, strip }: { value: number; strip?: boolean }) {
const v: DigitVars = { "--d": value };
return (
<span className={[styles.wb__d, strip ? styles["wb__d--s1"] : ""].filter(Boolean).join(" ")} style={v}>
<span className={styles.wb__strip}>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((d) => (
<b key={d}>{d}</b>
))}
</span>
</span>
);
}
export default function WebinarSlotBooking({
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: WebinarSlotBookingProps) {
const [picked, setPicked] = useState(0);
const [confirmed, setConfirmed] = useState(-1);
const [remain, setRemain] = useState(14 * 60 + 32);
const [msg, setMsg] = useState("회차를 고르면 자리를 잡아 둬요");
const [loop, setLoop] = useState(true);
const cellRefs = useRef<(HTMLButtonElement | null)[]>([]) as Box<(HTMLButtonElement | null)[]>;
// setInterval 이 1초마다 남은 시간을 줄이고, 그 값이 숫자 띠의 --d 로 들어간다
useEffect(() => {
const id = setInterval(() => setRemain((p) => (p > 0 ? p - 1 : 0)), 1000);
return () => clearInterval(id);
}, []);
function select(i: number) {
setLoop(false);
setPicked(i);
const el = cellRefs.current[i];
if (el) el.focus({ preventScroll: true });
}
function onKey(e: KeyEvt) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const step = e.key === "ArrowRight" ? 1 : sessions.length - 1;
let n = picked;
do {
n = (n + step) % sessions.length;
} while (sessions[n].closed && n !== picked);
select(n);
}
function book() {
setLoop(false);
if (confirmed === picked) {
setMsg("이미 예약한 회차예요");
return;
}
setConfirmed(picked);
setMsg(`${sessions[picked].day} 회차로 예약했습니다. 확인 메일을 보냈어요`);
}
const m = Math.floor(remain / 60);
const s = remain % 60;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.wb, loop ? styles["is-demo"] : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars}>
<p className={styles.wb__title}>
<span className={styles["wb__title-ic"]} aria-hidden="true"><CalendarBlank /></span>
웨비나 회차 고르기
</p>
<div className={styles.wb__slots} role="radiogroup" aria-label="웨비나 회차" onKeyDown={onKey}>
{sessions.map((se, i) => (
<button
key={se.day}
ref={(el: HTMLButtonElement | null) => {
cellRefs.current[i] = el;
}}
className={[styles.wb__slot, confirmed === i ? styles["is-ok"] : ""].filter(Boolean).join(" ")}
type="button"
role="radio"
aria-checked={picked === i}
tabIndex={picked === i ? 0 : -1}
disabled={se.closed}
onClick={() => select(i)}
>
<span className={styles.wb__ring} aria-hidden="true" />
<span className={styles.wb__day}>{se.day}</span>
<span className={[styles.wb__seat, se.closed ? styles["wb__seat--closed"] : ""].filter(Boolean).join(" ")}>
{se.seats}
</span>
<span className={styles.wb__ok} aria-hidden="true">
<svg className={styles["wb__ok-ic"]} viewBox="0 0 24 24" width="10" height="10">
<polyline points="4 12 9 17 20 6" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
</svg>
확정됨
</span>
</button>
))}
</div>
<p className={styles.wb__cd} role="timer" aria-live="off">
<span className={styles["wb__cd-ic"]} aria-hidden="true"><Clock /></span>
선택 회차 마감까지
<span className={styles.wb__clock} aria-hidden="true">
<b className={styles.wb__mm}>{m < 10 ? `0${m}` : m}</b>:
<Digit value={Math.floor(s / 10)} />
<Digit value={s % 10} strip />
</span>
<span className={styles["sr-only"]}>{`${m}분 ${s}초 남음`}</span>
</p>
<button className={styles.wb__go} type="button" onClick={book}>이 회차로 예약하기</button>
<p className={styles.wb__msg} role="status" aria-live="polite">{msg}</p>
</section>
);
}03 Webinar slot booking — Measured area 3.188% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · Landing Page Conversion: 9 Sections That Convert
The post this demo lives in
Landing Page Conversion: 9 Sections That ConvertLanding page conversion is the share of visitors who leave an email, book a slot, or buy, and this post hands you the ni See all demos in this post


