03Vertical category rail
A selection marker slides down a column of tall category cells while the panel on the right swaps in step. The rail sits on the left where a standing hand actually reaches.
Best forCoffee, tea and dessert groups on a cafe terminal, store groups on a food court kiosk
aria-selectedaria-orientationtranslateY

<main class="stage">
<div class="kr is-demo" id="kr" data-sel="1">
<div class="kr__rail">
<span class="kr__mark" aria-hidden="true"></span>
<div class="kr__tabs" role="tablist" aria-orientation="vertical" aria-label="Menu categories" id="krTabs">
<button type="button" role="tab" class="kr__tab" id="krTab1" aria-selected="true" aria-controls="krPanel1" tabindex="0" data-sel="1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="kr__ico" aria-hidden="true"><path d="M208,88v48a88,88,0,0,1-51.3,80H83.3A88,88,0,0,1,32,136V88Z" opacity="0.2"/><path d="M80,56V24a8,8,0,0,1,16,0V56a8,8,0,0,1-16,0Zm40,8a8,8,0,0,0,8-8V24a8,8,0,0,0-16,0V56A8,8,0,0,0,120,64Zm32,0a8,8,0,0,0,8-8V24a8,8,0,0,0-16,0V56A8,8,0,0,0,152,64Zm96,56v8a40,40,0,0,1-37.51,39.91,96.59,96.59,0,0,1-27,40.09H208a8,8,0,0,1,0,16H32a8,8,0,0,1,0-16H56.54A96.3,96.3,0,0,1,24,136V88a8,8,0,0,1,8-8H208A40,40,0,0,1,248,120ZM200,96H40v40a80.27,80.27,0,0,0,45.12,72h69.76A80.27,80.27,0,0,0,200,136Zm32,24a24,24,0,0,0-16-22.62V136a95.78,95.78,0,0,1-1.2,15A24,24,0,0,0,232,128Z"/></svg>
<span class="kr__label">Coffee</span>
</button>
<button type="button" role="tab" class="kr__tab" id="krTab2" aria-selected="false" aria-controls="krPanel2" tabindex="-1" data-sel="2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="kr__ico" aria-hidden="true"><path d="M160,122.22V216a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V122.22a8,8,0,0,1,1.14-4.12L69.67,83.88A8,8,0,0,1,76.53,80h54.94a8,8,0,0,1,6.86,3.88l20.53,34.22A8,8,0,0,1,160,122.22Z" opacity="0.2"/><path d="M224,160a16,16,0,0,1-16-16V64A56,56,0,0,0,96,64v8H76.53a16.09,16.09,0,0,0-13.72,7.77L42.28,114A16.06,16.06,0,0,0,40,122.22V216a16,16,0,0,0,16,16h96a16,16,0,0,0,16-16V122.22a16.06,16.06,0,0,0-2.28-8.24L145.19,79.77A16.09,16.09,0,0,0,131.47,72H112V64a40,40,0,0,1,80,0v80a32,32,0,0,0,32,32,8,8,0,0,0,0-16ZM131.47,88,152,122.22V216H56V122.22L76.53,88H96v48a8,8,0,0,0,16,0V88Z"/></svg>
<span class="kr__label">Tea</span>
</button>
<button type="button" role="tab" class="kr__tab" id="krTab3" aria-selected="false" aria-controls="krPanel3" tabindex="-1" data-sel="3">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="kr__ico" aria-hidden="true"><path d="M216,120a16,16,0,0,1-16,16H56a16,16,0,0,1,0-32V96a72,72,0,0,1,144,0v8A16,16,0,0,1,216,120Z" opacity="0.2"/><path d="M208,97.37V96A80,80,0,0,0,48,96v1.37A24,24,0,0,0,56,144h3.29l54.82,95.94a16,16,0,0,0,27.78,0L196.71,144H200a24,24,0,0,0,8-46.63ZM77.71,144H97.07l40.61,71.06L128,232Zm57.08,0,21.75,38.06-9.65,16.88L115.5,144Zm31,21.94L153.21,144h25.08ZM200,128H56a8,8,0,0,1,0-16,8,8,0,0,0,8-8V96a64,64,0,0,1,128,0v8a8,8,0,0,0,8,8,8,8,0,0,1,0,16Z"/></svg>
<span class="kr__label">Dessert</span>
</button>
<button type="button" role="tab" class="kr__tab" id="krTab4" aria-selected="false" aria-controls="krPanel4" tabindex="-1" data-sel="4">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="kr__ico" aria-hidden="true"><path d="M232,88,216.93,201.06A8,8,0,0,1,209,208H47a8,8,0,0,1-7.93-6.94L24,88Z" opacity="0.2"/><path d="M136,120v56a8,8,0,0,1-16,0V120a8,8,0,0,1,16,0Zm36.84-.8-5.6,56A8,8,0,0,0,174.4,184a7.32,7.32,0,0,0,.81,0,8,8,0,0,0,7.95-7.2l5.6-56a8,8,0,0,0-15.92-1.6Zm-89.68,0a8,8,0,0,0-15.92,1.6l5.6,56a8,8,0,0,0,8,7.2,7.32,7.32,0,0,0,.81,0,8,8,0,0,0,7.16-8.76ZM239.93,89.06,224.86,202.12A16.06,16.06,0,0,1,209,216H47a16.06,16.06,0,0,1-15.86-13.88L16.07,89.06A8,8,0,0,1,24,80H68.37L122,18.73a8,8,0,0,1,12,0L187.63,80H232a8,8,0,0,1,7.93,9.06ZM89.63,80h76.74L128,36.15ZM222.86,96H33.14L47,200H209Z"/></svg>
<span class="kr__label">Combo</span>
</button>
</div>
</div>
<div class="kr__panels">
<div class="kr__panel kr__panel--1" role="tabpanel" id="krPanel1" aria-labelledby="krTab1" tabindex="0">
<p class="kr__row"><span class="kr__menu">Americano</span><b class="kr__price">3,000 won</b></p>
<p class="kr__row"><span class="kr__menu">Caffe latte</span><b class="kr__price">4,000 won</b></p>
<p class="kr__row"><span class="kr__menu">Cold brew</span><b class="kr__price">4,500 won</b></p>
</div>
<div class="kr__panel kr__panel--2" role="tabpanel" id="krPanel2" aria-labelledby="krTab2" tabindex="0">
<p class="kr__row"><span class="kr__menu">Yuzu tea</span><b class="kr__price">4,800 won</b></p>
<p class="kr__row"><span class="kr__menu">Chamomile</span><b class="kr__price">4,500 won</b></p>
<p class="kr__row"><span class="kr__menu">Iced tea</span><b class="kr__price">3,500 won</b></p>
</div>
<div class="kr__panel kr__panel--3" role="tabpanel" id="krPanel3" aria-labelledby="krTab3" tabindex="0">
<p class="kr__row"><span class="kr__menu">Cheesecake</span><b class="kr__price">5,500 won</b></p>
<p class="kr__row"><span class="kr__menu">Macaron</span><b class="kr__price">2,500 won</b></p>
<p class="kr__row"><span class="kr__menu">Brownie</span><b class="kr__price">4,200 won</b></p>
</div>
<div class="kr__panel kr__panel--4" role="tabpanel" id="krPanel4" aria-labelledby="krTab4" tabindex="0">
<p class="kr__row"><span class="kr__menu">Morning set</span><b class="kr__price">6,500 won</b></p>
<p class="kr__row"><span class="kr__menu">Afternoon set</span><b class="kr__price">7,500 won</b></p>
<p class="kr__row"><span class="kr__menu">Sharing set</span><b class="kr__price">12,000 won</b></p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 세로 카테고리 레일 — 서 있는 사람 손이 닿는 왼쪽 아래에 레일을 붙이고, 오른쪽에 그 분류의 메뉴를 편다.
// 선택 표시는 레일 칸 **뒤에** 깔린 먹 블록이다. 칸 자체는 크림색이라 블록이 4px 씩 삐져나와
// 테두리처럼 보이고, 글자는 언제나 크림 위 먹이라 미끄러지는 동안에도 읽힌다.
// 블록은 translateY 로 미끄러지고(연속 움직임) 오른쪽 패널은 블록이 **닿은 뒤** steps(1, end) 로 갈린다 —
// 이동 중에 패널을 바꾸면 그 프레임이 poster 로 뽑혀 "블록은 1번, 패널은 2번" 인 장면이 대표 이미지가 된다.
// 칸이 넷이라도 2초에 두 칸만 오간다. 넷을 다 돌면 한 칸에 0.5초라 어디에 섰는지 안 보인다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.kr {
--cell: clamp(30px, 9.5vw, 52px);
width: 100%; max-width: 420px;
display: grid; grid-template-columns: minmax(88px, 38%) 1fr; gap: $sp-3;
align-items: stretch;
}
// 레일을 아래에 붙인다 — 키오스크 앞에 선 손이 닿는 높이
.kr__rail { position: relative; align-self: end; }
// 선택 블록 — 칸보다 4px 씩 크게 깔려 테두리처럼 보인다. left/top 은 간격이 아니라 위치 오프셋이다
.kr__mark {
position: absolute; left: -4px; right: -4px; top: -4px;
height: calc(var(--cell) + 8px);
border-radius: $r-card; background: $color;
transition: transform $dur-quick $easing;
}
.kr__tabs {
position: relative; display: grid; grid-template-rows: repeat(4, var(--cell)); gap: $sp-2;
}
.kr__tab {
box-sizing: border-box; width: 100%; height: 100%;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2; cursor: pointer;
border: 2px solid rgba(23, 20, 26, .18); border-radius: $r-control;
background: $subject-cream; color: $subject-ink;
font-family: inherit; font-weight: 800; font-size: clamp(11px, 3vw, 14px);
line-height: 1.2; text-align: left;
}
.kr__tab:focus-visible { outline: 3px solid $subject-blue; outline-offset: 4px; }
.kr__tab[aria-selected="true"] { border-color: $color; }
.kr__ico { width: clamp(14px, 4.4vw, 20px); height: clamp(14px, 4.4vw, 20px); flex: 0 0 auto; }
// 패널 넷을 같은 격자 칸에 겹쳐 둔다 — 절대배치가 아니라서 높이는 가장 긴 패널이 정한다 (BD8 안전)
.kr__panels { display: grid; }
.kr__panel {
grid-area: 1 / 1;
box-sizing: border-box; padding: $sp-2;
display: flex; flex-direction: column; justify-content: center;
border: 2px solid rgba(23, 20, 26, .18); border-radius: $r-card;
background: $stage-paper; color: $subject-ink;
opacity: 0; visibility: hidden;
}
.kr__panel:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.kr[data-sel="1"] .kr__panel--1,
.kr[data-sel="2"] .kr__panel--2,
.kr[data-sel="3"] .kr__panel--3,
.kr[data-sel="4"] .kr__panel--4 { opacity: 1; visibility: visible; }
// 선택 블록의 정지 위치도 data-sel 한 곳이 정한다. 한 칸의 이동거리 = 칸 높이 + 칸 사이 간격
.kr[data-sel="2"] .kr__mark { transform: translateY(calc(var(--cell) + #{$sp-2})); }
.kr[data-sel="3"] .kr__mark { transform: translateY(calc((var(--cell) + #{$sp-2}) * 2)); }
.kr[data-sel="4"] .kr__mark { transform: translateY(calc((var(--cell) + #{$sp-2}) * 3)); }
.kr__row {
display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2;
margin: 0; padding: $sp-1 0;
font-weight: 700; font-size: clamp(11px, 3vw, 14px); line-height: 1.3;
}
.kr__row + .kr__row { border-top: 1px solid rgba(23, 20, 26, .12); }
.kr__price { font-weight: 800; font-style: normal; white-space: nowrap; }
// ── 미리보기 루프 ────────────────────────────────────────────────
// 블록은 25~40% 에 내려가고 65~80% 에 돌아온다 (복귀는 90% 안에 끝난다 — 24장의 끝은 95.8% 지점이다)
.kr.is-demo .kr__mark {
animation-name: krMark; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes krMark {
0%, 25% { transform: translateY(0); }
40%, 65% { transform: translateY(calc(var(--cell) + #{$sp-2})); }
80%, 100% { transform: translateY(0); }
}
// 패널 교체는 블록이 닿은 프레임(40% · 80%)에 컷으로. 두 패널이 반투명하게 겹치는 프레임을 만들지 않는다
.kr.is-demo .kr__panel--1 {
animation-name: krPanelA; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.kr.is-demo .kr__panel--2 {
animation-name: krPanelB; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes krPanelA {
0% { opacity: 1; visibility: visible; }
40% { opacity: 0; visibility: hidden; }
80%, 100% { opacity: 1; visibility: visible; }
}
@keyframes krPanelB {
0% { opacity: 0; visibility: hidden; }
40% { opacity: 1; visibility: visible; }
80%, 100% { opacity: 0; visibility: hidden; }
}
// 고른 칸의 테두리도 같은 시각표에 올린다 — 블록이 닿는 프레임에 함께 갈린다
.kr.is-demo .kr__tab {
animation-name: krTabEdge; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.kr.is-demo .kr__tab:nth-child(2) { animation-name: krTabEdge2; }
.kr.is-demo .kr__tab:nth-child(3),
.kr.is-demo .kr__tab:nth-child(4) { animation-name: none; }
@keyframes krTabEdge {
0% { border-color: $color; }
40% { border-color: rgba(23, 20, 26, .18); }
80%, 100% { border-color: $color; }
}
@keyframes krTabEdge2 {
0% { border-color: rgba(23, 20, 26, .18); }
40% { border-color: $color; }
80%, 100% { border-color: rgba(23, 20, 26, .18); }
}
@media (max-width: 340px) {
.kr { gap: $sp-2; }
.kr__tabs { gap: $sp-1; }
.kr[data-sel="2"] .kr__mark { transform: translateY(calc(var(--cell) + #{$sp-1})); }
.kr[data-sel="3"] .kr__mark { transform: translateY(calc((var(--cell) + #{$sp-1}) * 2)); }
.kr[data-sel="4"] .kr__mark { transform: translateY(calc((var(--cell) + #{$sp-1}) * 3)); }
@keyframes krMark {
0%, 25% { transform: translateY(0); }
40%, 65% { transform: translateY(calc(var(--cell) + #{$sp-1})); }
80%, 100% { transform: translateY(0); }
}
}
@media (prefers-reduced-motion: reduce) {
.kr.is-demo .kr__mark,
.kr.is-demo .kr__panel--1,
.kr.is-demo .kr__panel--2,
.kr.is-demo .kr__tab { animation: none !important; }
// 움직임 없이 첫 칸이 고른 상태로 서 있게 값을 못 박는다
.kr.is-demo .kr__mark { transform: translateY(0) !important; }
.kr.is-demo .kr__panel--1 { opacity: 1 !important; visibility: visible !important; }
.kr.is-demo .kr__panel--2 { opacity: 0 !important; visibility: hidden !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.kr {
--cell: clamp(30px, 9.5vw, 52px);
width: 100%;
max-width: 420px;
display: grid;
grid-template-columns: minmax(88px, 38%) 1fr;
gap: 12px;
align-items: stretch;
}
.kr__rail {
position: relative;
align-self: end;
}
.kr__mark {
position: absolute;
left: -4px;
right: -4px;
top: -4px;
height: calc(var(--cell) + 8px);
border-radius: 16px;
background: #17141a;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.kr__tabs {
position: relative;
display: grid;
grid-template-rows: repeat(4, var(--cell));
gap: 8px;
}
.kr__tab {
box-sizing: border-box;
width: 100%;
height: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
cursor: pointer;
border: 2px solid rgba(23, 20, 26, 0.18);
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 800;
font-size: clamp(11px, 3vw, 14px);
line-height: 1.2;
text-align: left;
}
.kr__tab:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 4px;
}
.kr__tab[aria-selected=true] {
border-color: #17141a;
}
.kr__ico {
width: clamp(14px, 4.4vw, 20px);
height: clamp(14px, 4.4vw, 20px);
flex: 0 0 auto;
}
.kr__panels {
display: grid;
}
.kr__panel {
grid-area: 1/1;
box-sizing: border-box;
padding: 8px;
display: flex;
flex-direction: column;
justify-content: center;
border: 2px solid rgba(23, 20, 26, 0.18);
border-radius: 16px;
background: #ffffff;
color: #17141a;
opacity: 0;
visibility: hidden;
}
.kr__panel:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.kr[data-sel="1"] .kr__panel--1,
.kr[data-sel="2"] .kr__panel--2,
.kr[data-sel="3"] .kr__panel--3,
.kr[data-sel="4"] .kr__panel--4 {
opacity: 1;
visibility: visible;
}
.kr[data-sel="2"] .kr__mark {
transform: translateY(calc(var(--cell) + 8px));
}
.kr[data-sel="3"] .kr__mark {
transform: translateY(calc((var(--cell) + 8px) * 2));
}
.kr[data-sel="4"] .kr__mark {
transform: translateY(calc((var(--cell) + 8px) * 3));
}
.kr__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin: 0;
padding: 4px 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 14px);
line-height: 1.3;
}
.kr__row + .kr__row {
border-top: 1px solid rgba(23, 20, 26, 0.12);
}
.kr__price {
font-weight: 800;
font-style: normal;
white-space: nowrap;
}
.kr.is-demo .kr__mark {
animation-name: krMark;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes krMark {
0%, 25% {
transform: translateY(0);
}
40%, 65% {
transform: translateY(calc(var(--cell) + 8px));
}
80%, 100% {
transform: translateY(0);
}
}
.kr.is-demo .kr__panel--1 {
animation-name: krPanelA;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.kr.is-demo .kr__panel--2 {
animation-name: krPanelB;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes krPanelA {
0% {
opacity: 1;
visibility: visible;
}
40% {
opacity: 0;
visibility: hidden;
}
80%, 100% {
opacity: 1;
visibility: visible;
}
}
@keyframes krPanelB {
0% {
opacity: 0;
visibility: hidden;
}
40% {
opacity: 1;
visibility: visible;
}
80%, 100% {
opacity: 0;
visibility: hidden;
}
}
.kr.is-demo .kr__tab {
animation-name: krTabEdge;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.kr.is-demo .kr__tab:nth-child(2) {
animation-name: krTabEdge2;
}
.kr.is-demo .kr__tab:nth-child(3),
.kr.is-demo .kr__tab:nth-child(4) {
animation-name: none;
}
@keyframes krTabEdge {
0% {
border-color: #17141a;
}
40% {
border-color: rgba(23, 20, 26, 0.18);
}
80%, 100% {
border-color: #17141a;
}
}
@keyframes krTabEdge2 {
0% {
border-color: rgba(23, 20, 26, 0.18);
}
40% {
border-color: #17141a;
}
80%, 100% {
border-color: rgba(23, 20, 26, 0.18);
}
}
@media (max-width: 340px) {
.kr {
gap: 8px;
}
.kr__tabs {
gap: 4px;
}
.kr[data-sel="2"] .kr__mark {
transform: translateY(calc(var(--cell) + 4px));
}
.kr[data-sel="3"] .kr__mark {
transform: translateY(calc((var(--cell) + 4px) * 2));
}
.kr[data-sel="4"] .kr__mark {
transform: translateY(calc((var(--cell) + 4px) * 3));
}
@keyframes krMark {
0%, 25% {
transform: translateY(0);
}
40%, 65% {
transform: translateY(calc(var(--cell) + 4px));
}
80%, 100% {
transform: translateY(0);
}
}
}
@media (prefers-reduced-motion: reduce) {
.kr.is-demo .kr__mark,
.kr.is-demo .kr__panel--1,
.kr.is-demo .kr__panel--2,
.kr.is-demo .kr__tab {
animation: none !important;
}
.kr.is-demo .kr__mark {
transform: translateY(0) !important;
}
.kr.is-demo .kr__panel--1 {
opacity: 1 !important;
visibility: visible !important;
}
.kr.is-demo .kr__panel--2 {
opacity: 0 !important;
visibility: hidden !important;
}
}var kr = document.getElementById('kr');
var krTabs = [].slice.call(document.querySelectorAll('.kr__tab'));
// 세로 카테고리 레일. 고른 칸은 data-sel 한 곳으로 표시하고, 선택 블록의 위치와 오른쪽 패널은
// 그 값 하나를 보고 CSS 가 그린다 (미리보기 루프 .is-demo 는 같은 자리를 키프레임으로 돌린다).
// 레일은 role="tablist" aria-orientation="vertical" 이라 위아래 화살표가 표준 조작이다.
function krSelect(idx, moveFocus) {
kr.classList.remove('is-demo');
kr.setAttribute('data-sel', String(idx + 1));
for (var i = 0; i < krTabs.length; i++) {
var on = i === idx;
krTabs[i].setAttribute('aria-selected', on ? 'true' : 'false');
// 로빙 탭인덱스 — 레일 전체가 Tab 한 번에 들어오고, 안에서는 화살표로 옮긴다
krTabs[i].setAttribute('tabindex', on ? '0' : '-1');
}
if (moveFocus) { krTabs[idx].focus({ preventScroll: true }); }
}
for (var i = 0; i < krTabs.length; i++) {
krTabs[i].addEventListener('click', function (e) {
krSelect(krTabs.indexOf(e.currentTarget), false);
});
}
document.getElementById('krTabs').addEventListener('keydown', function (e) {
var at = krTabs.indexOf(e.target);
if (at < 0) { return; }
var to = at;
if (e.key === 'ArrowDown') { to = (at + 1) % krTabs.length; }
else if (e.key === 'ArrowUp') { to = (at - 1 + krTabs.length) % krTabs.length; }
else if (e.key === 'Home') { to = 0; }
else if (e.key === 'End') { to = krTabs.length - 1; }
else { return; }
e.preventDefault();
krSelect(to, true);
});// CategoryRailVertical — vanilla 판(run/298/demo/03-kiosk-category-rail-vertical/index.html) 의 React 포트.
// 세로 카테고리 레일. 고른 칸은 data-sel 한 곳으로 표시하고, 선택 블록의 위치와 오른쪽 패널은
// 그 값 하나를 보고 CSS 가 그린다 (미리보기 루프 .isDemo 는 같은 자리를 키프레임으로 돌린다).
// 레일은 role="tablist" aria-orientation="vertical" 이라 위아래 화살표가 표준 조작이다.
// 키 핸들러는 vanilla 가 레일 통째에 걸었지만 여기서는 칸마다 건다 — 초점이 있는 칸이 곧 이벤트의
// 주인이라 같은 동작이고, target 에서 자리를 되찾을 필요가 없다. 값·수치는 vanilla 와 같다.
import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CategoryRailVertical.module.scss";
export type CategoryRailVerticalProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
// ambient 타입에는 React 이벤트 제네릭이 없다 — 키 핸들러가 실제로 쓰는 것만 지역 타입으로 적는다
type KeyEvt = { key: string; preventDefault(): void };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type Tab = { label: string; bg: string; fg: string };
type Row = { menu: string; price: string };
// CSS 모듈은 규칙이 없는 이름을 undefined 로 돌려준다 — vanilla 에서 스타일이 안 붙은 이름
// (.kr__label · .kr__menu 는 규칙 없이 의미만 나른다)까지 구조 그대로 옮기려고 빈 값을 걸러 낸다
function cx(...names: (string | undefined)[]): string {
return names.filter(Boolean).join(" ");
}
const RAIL_LABEL = "메뉴 분류";
const TABS: Tab[] = [
{
label: "커피",
bg: "M208,88v48a88,88,0,0,1-51.3,80H83.3A88,88,0,0,1,32,136V88Z",
fg: "M80,56V24a8,8,0,0,1,16,0V56a8,8,0,0,1-16,0Zm40,8a8,8,0,0,0,8-8V24a8,8,0,0,0-16,0V56A8,8,0,0,0,120,64Zm32,0a8,8,0,0,0,8-8V24a8,8,0,0,0-16,0V56A8,8,0,0,0,152,64Zm96,56v8a40,40,0,0,1-37.51,39.91,96.59,96.59,0,0,1-27,40.09H208a8,8,0,0,1,0,16H32a8,8,0,0,1,0-16H56.54A96.3,96.3,0,0,1,24,136V88a8,8,0,0,1,8-8H208A40,40,0,0,1,248,120ZM200,96H40v40a80.27,80.27,0,0,0,45.12,72h69.76A80.27,80.27,0,0,0,200,136Zm32,24a24,24,0,0,0-16-22.62V136a95.78,95.78,0,0,1-1.2,15A24,24,0,0,0,232,128Z",
},
{
label: "차",
bg: "M160,122.22V216a8,8,0,0,1-8,8H56a8,8,0,0,1-8-8V122.22a8,8,0,0,1,1.14-4.12L69.67,83.88A8,8,0,0,1,76.53,80h54.94a8,8,0,0,1,6.86,3.88l20.53,34.22A8,8,0,0,1,160,122.22Z",
fg: "M224,160a16,16,0,0,1-16-16V64A56,56,0,0,0,96,64v8H76.53a16.09,16.09,0,0,0-13.72,7.77L42.28,114A16.06,16.06,0,0,0,40,122.22V216a16,16,0,0,0,16,16h96a16,16,0,0,0,16-16V122.22a16.06,16.06,0,0,0-2.28-8.24L145.19,79.77A16.09,16.09,0,0,0,131.47,72H112V64a40,40,0,0,1,80,0v80a32,32,0,0,0,32,32,8,8,0,0,0,0-16ZM131.47,88,152,122.22V216H56V122.22L76.53,88H96v48a8,8,0,0,0,16,0V88Z",
},
{
label: "디저트",
bg: "M216,120a16,16,0,0,1-16,16H56a16,16,0,0,1,0-32V96a72,72,0,0,1,144,0v8A16,16,0,0,1,216,120Z",
fg: "M208,97.37V96A80,80,0,0,0,48,96v1.37A24,24,0,0,0,56,144h3.29l54.82,95.94a16,16,0,0,0,27.78,0L196.71,144H200a24,24,0,0,0,8-46.63ZM77.71,144H97.07l40.61,71.06L128,232Zm57.08,0,21.75,38.06-9.65,16.88L115.5,144Zm31,21.94L153.21,144h25.08ZM200,128H56a8,8,0,0,1,0-16,8,8,0,0,0,8-8V96a64,64,0,0,1,128,0v8a8,8,0,0,0,8,8,8,8,0,0,1,0,16Z",
},
{
label: "세트",
bg: "M232,88,216.93,201.06A8,8,0,0,1,209,208H47a8,8,0,0,1-7.93-6.94L24,88Z",
fg: "M136,120v56a8,8,0,0,1-16,0V120a8,8,0,0,1,16,0Zm36.84-.8-5.6,56A8,8,0,0,0,174.4,184a7.32,7.32,0,0,0,.81,0,8,8,0,0,0,7.95-7.2l5.6-56a8,8,0,0,0-15.92-1.6Zm-89.68,0a8,8,0,0,0-15.92,1.6l5.6,56a8,8,0,0,0,8,7.2,7.32,7.32,0,0,0,.81,0,8,8,0,0,0,7.16-8.76ZM239.93,89.06,224.86,202.12A16.06,16.06,0,0,1,209,216H47a16.06,16.06,0,0,1-15.86-13.88L16.07,89.06A8,8,0,0,1,24,80H68.37L122,18.73a8,8,0,0,1,12,0L187.63,80H232a8,8,0,0,1,7.93,9.06ZM89.63,80h76.74L128,36.15ZM222.86,96H33.14L47,200H209Z",
},
];
const PANELS: Row[][] = [
[
{ menu: "아메리카노", price: "3,000원" },
{ menu: "카페라떼", price: "4,000원" },
{ menu: "콜드브루", price: "4,500원" },
],
[
{ menu: "유자차", price: "4,800원" },
{ menu: "캐모마일", price: "4,500원" },
{ menu: "아이스티", price: "3,500원" },
],
[
{ menu: "치즈케이크", price: "5,500원" },
{ menu: "마카롱", price: "2,500원" },
{ menu: "브라우니", price: "4,200원" },
],
[
{ menu: "모닝 세트", price: "6,500원" },
{ menu: "오후 세트", price: "7,500원" },
{ menu: "나눔 세트", price: "12,000원" },
],
];
const PANEL_CLS: string[] = ["panel1", "panel2", "panel3", "panel4"];
export default function CategoryRailVertical({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: CategoryRailVerticalProps) {
const tabsRef = useRef<HTMLButtonElement[]>([]) as Box<HTMLButtonElement[]>;
const [sel, setSel] = useState(0);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 로빙 탭인덱스 — 레일 전체가 Tab 한 번에 들어오고, 안에서는 화살표로 옮긴다.
// 선택된 칸만 tabindex="0" 이라 그 처리는 아래 JSX 의 tabIndex 한 줄이 맡는다
function select(idx: number, moveFocus: boolean): void {
setDemo(false);
setSel(idx);
const target = tabsRef.current[idx];
if (moveFocus && target) { target.focus({ preventScroll: true }); }
}
function onTabKey(event: KeyEvt, at: number): void {
let to = at;
if (event.key === "ArrowDown") { to = (at + 1) % TABS.length; }
else if (event.key === "ArrowUp") { to = (at - 1 + TABS.length) % TABS.length; }
else if (event.key === "Home") { to = 0; }
else if (event.key === "End") { to = TABS.length - 1; }
else { return; }
event.preventDefault();
select(to, true);
}
const cls = cx(styles.root, demo ? styles.isDemo : "");
return (
<main className={styles.stage}>
<div className={cls} style={vars} data-sel={String(sel + 1)}>
<div className={styles.rail}>
<span className={styles.mark} aria-hidden="true"></span>
<div className={styles.tabs} role="tablist" aria-orientation="vertical" aria-label={RAIL_LABEL}>
{TABS.map((tab, i) => (
<button
key={tab.label}
type="button"
role="tab"
className={styles.tab}
id={`krTab${i + 1}`}
aria-selected={sel === i ? "true" : "false"}
aria-controls={`krPanel${i + 1}`}
tabIndex={sel === i ? 0 : -1}
ref={(el: HTMLButtonElement | null) => { if (el) { tabsRef.current[i] = el; } }}
onClick={() => select(i, false)}
onKeyDown={(event: KeyEvt) => onTabKey(event, i)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true">
<path d={tab.bg} opacity="0.2"/>
<path d={tab.fg}/>
</svg>
<span className={cx(styles.label)}>{tab.label}</span>
</button>
))}
</div>
</div>
<div className={styles.panels}>
{PANELS.map((rows, i) => (
<div
key={PANEL_CLS[i]}
className={cx(styles.panel, styles[PANEL_CLS[i]])}
role="tabpanel"
id={`krPanel${i + 1}`}
aria-labelledby={`krTab${i + 1}`}
tabIndex={0}
>
{rows.map((row) => (
<p key={row.menu} className={styles.row}>
<span className={cx(styles.menu)}>{row.menu}</span>
<b className={styles.price}>{row.price}</b>
</p>
))}
</div>
))}
</div>
</div>
</main>
);
}03 Vertical category rail — Measured area 4.647% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · Kiosk UI Design: 9 Finger-Sized Order Screens
The post this demo lives in
Kiosk UI Design: 9 Finger-Sized Order ScreensKiosk UI design is the craft of handing a standing stranger one decision per screen. These nine finger-sized order scree See all demos in this post


