GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

키오스크 UI 디자인 9종 — 손가락 기준으로 키운 주문 화면

키오스크 UI 디자인은 서서 쓰는 큰 화면을 손가락 기준으로 키운 주문 흐름으로 짜는 일입니다. 여기 아홉 화면은 유휴 광고부터 언어 고르기·메뉴 타일·품절 칸·호출판·되돌아가기까지, 붙여 넣으면 바로 도는 형태로 만들어 두었습니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

늘어놓은 기준은 CSS 난이도가 아니라 손님이 매장 단말 앞에 서서 밟는 차례입니다. 아직 아무도 안 만지는 화면이 맨 앞이고(01), 손이 닿으면 언어를 묻고(02), 왼쪽 레일로 분류를 좁힌 뒤(03) 타일에서 메뉴를 고릅니다(04). 고른 메뉴의 옵션을 두 걸음으로 받고(05) 세트로 올릴지 한 번 묻고(06), 값을 치른 손님은 호출판 앞에서 번호를 기다립니다(07). 마지막 둘은 사람이 떠났을 때와 사람이 앉아 있을 때입니다 — 손을 떼면 화면이 스스로 처음으로 돌아가고(08), 휠체어 눈높이에 맞춰 버튼 묶음이 통째로 내려앉습니다(09). 키오스크 화면 구성이 웹 화면과 갈라지는 지점이 여기입니다. 수량 스테퍼·쿠폰·배송지를 다루는 장바구니 UI 9종과 달리 매장 단말은 화면이 통째로 갈리고, 누르는 칸도 마우스 포인터가 아니라 손가락 너비에 맞춥니다. 눌린 칸이 어떻게 답하는지는 CSS 버튼 눌림 효과 9종이 더 깊이 다루고, 03 레일과 05 옵션 줄의 이웃은 세그먼트 컨트롤 선택 UI 9종, 08 이 되돌아간 뒤 서는 화면은 빈 화면 디자인 9종에 있습니다. 무대 색은 잉크 둘·종이 넷·노랑 둘·오렌지 하나로 나눠, 어두운 호출판과 밝은 주문 화면이 한 격자 안에서 서로를 가리지 않게 했습니다. 같은 interaction 기둥에서 손가락 대신 마우스가 주인공이 되면 마우스를 따라오는 CSS 3D 회전 장면이 되고, 01 유휴 화면에 캐릭터를 세우고 싶다면 마스코트 애니메이션에 아홉 가지 표정이 있습니다.

01유휴 화면

아무도 안 만지는 동안 도는 화면

광고 세 장이 steps(1, end) 컷으로 갈리고 그 아래 남은 시간 막대가 scaleX 로 왼쪽 변에서 차오릅니다. 화면을 누르면 미리보기 루프가 떨어지고 400×206px짜리 주문 칸이 열리며 포커스가 「무엇을 드릴까요」로 옮겨 가고, 이어지는 Tab 은 타일 네 개를 지나 「처음 화면으로」 버튼에서 끝납니다. 컷과 연속 움직임을 같은 2초 시계에 묶어 누적면적 29.122%·움직인 프레임 23/23 을 기록했습니다.

유휴 화면
새 창에서 열기

01 · 유휴 화면 — 측정 누적면적 29.122% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

steps(1, end)scaleXanimation-delay
function start() {
  ka.classList.remove('is-demo');
  attract.hidden = true;
  order.hidden = false;
  head.focus({ preventScroll: true });
}

function home() {
  order.hidden = true;
  attract.hidden = false;
  attract.focus({ preventScroll: true });
}

attract.addEventListener('click', start);
back.addEventListener('click', function (e) { e.stopPropagation(); home(); });

02언어 고르는 첫 화면

네 언어를 한 화면에 세우는 칸

네 칸이 translateY 로 차례로 떠오르고 고른 칸에만 4px 테두리·먹 배경·체크가 한 프레임에 함께 붙어, 누적면적 35.997%·움직인 프레임 15/23 이 나왔습니다. 日本語 칸을 누르면 aria-pressed 가 그 칸으로 옮겨 가고 켜진 칸은 언제나 정확히 하나이며, 아래 안내 줄도 「日本語で注文画面が開きます」로 함께 바뀝니다. 언어 이름은 한국어·English·日本語·Tiếng Việt처럼 각 언어 표기 그대로 둡니다 — 번역해 두면 정작 그 언어를 쓰는 사람이 자기 줄을 못 찾습니다.

언어 고르는 첫 화면
새 창에서 열기

02 · 언어 고르는 첫 화면 — 측정 누적면적 35.997% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aria-pressedanimation-fill-modetranslateY
var LANG_HINT = {
  ko: '한국어로 주문 화면이 열립니다',
  en: 'The order screen opens in English',
  ja: '日本語で注文画面が開きます',
  vi: 'Màn hình đặt món sẽ mở bằng tiếng Việt'
};

function klPick(btn) {
  kl.classList.remove('is-demo');
  for (var i = 0; i < klOpts.length; i++) {
    klOpts[i].setAttribute('aria-pressed', klOpts[i] === btn ? 'true' : 'false');
  }
  var text = LANG_HINT[btn.getAttribute('data-lang')];
  if (text) { klHint.textContent = text; }
}

03세로 카테고리 레일

서 있는 손이 닿는 왼쪽 아래

왼쪽에 세운 큰 칸 뒤로 먹 블록이 미끄러져 옮겨 다니고 오른쪽 메뉴 판이 같은 박자로 갈립니다. 디저트 칸을 누르면 블록의 top 이 43px 에서 150px 로 내려가는데, 블록을 칸보다 4px씩 크게 깔아 두어 43·96·150·204px 어느 자리에서도 칸과 정확히 −4px 차를 지킵니다. 움직이는 것이 블록 하나뿐이라 누적면적은 4.647% 로 낮고, 대신 프레임 사이 변화 강도는 178.4 로 아홉 중 가장 높습니다.

세로 카테고리 레일
새 창에서 열기

03 · 세로 카테고리 레일 — 측정 누적면적 4.647% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aria-selectedaria-orientationtranslateY
.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[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)); }

04메뉴 타일과 품절 칸

못 누르는 칸을 못 누르게 보이기

여섯 칸 중 한 칸만 흐려지고 repeating-linear-gradient 사선 띠와 품절 도장이 덮입니다. 품절 칸은 클릭·Enter·Space 어느 쪽으로 눌러도 담은 개수가 2개에서 2개로 그대로지만, aria-disabled 라서 포커스는 그대로 받습니다. 눌림이 칸에서 칸으로 옮겨 다니고 띠가 쉬지 않고 흐르는 덕에 움직인 프레임은 23/23 이고, 한 번에 변하는 면적이 작아 강도는 47.1 로 아홉 중 가장 낮습니다.

메뉴 타일과 품절 칸
새 창에서 열기

04 · 메뉴 타일과 품절 칸 — 측정 누적면적 18.788% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aria-disabledrepeating-linear-gradientanimation-delay
function add(tile) {
  if (tile.getAttribute('aria-disabled') === 'true') return;
  mt.classList.remove('is-demo');
  picked += 1;
  bag.textContent = BAG_LABEL.replace('{n}', picked);
  tile.classList.add('is-tapped');
  setTimeout(function () { tile.classList.remove('is-tapped'); }, 180);
}

tiles.forEach(function (tile) {
  // 버튼이라 Enter·Space 도 click 으로 들어온다 — 키보드 경로를 따로 만들지 않는다.
  tile.addEventListener('click', function () { add(tile); });
});

05옵션 고르는 큰 시트

한 화면에 다 늘어놓지 않는 이유

크기와 온도를 두 걸음으로 나눠 보여 주고, 걸음이 바뀌면 위쪽 걸음 표시 한 칸이 함께 찹니다. 크게를 고르고 넘어가 차갑게를 지정한 뒤 뒤로·다시 다음을 눌러도 두 값이 그대로 남아, 켜진 칸 수는 언제나 2 입니다. 두 판을 같은 격자 칸에 겹쳐 두고 steps(1, end) 로 갈아 끼워 시트가 들썩이지 않게 했고, 누적면적 14.037%·17/23 프레임이 움직였습니다.

옵션 고르는 큰 시트
새 창에서 열기

05 · 옵션 고르는 큰 시트 — 측정 누적면적 14.037% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aria-checkedsteps(1, end)translateX
function pick(group, btn) {
  ko.classList.remove('is-demo');
  var opts = btn.parentNode.querySelectorAll('.ko__opt');
  for (var i = 0; i < opts.length; i++) {
    var on = opts[i] === btn;
    opts[i].setAttribute('aria-checked', on ? 'true' : 'false');
    opts[i].classList.toggle('is-on', on);
    opts[i].tabIndex = on ? 0 : -1;
  }
  picks[group] = btn.getAttribute('data-v');
}

06세트 권유 칸

거절 버튼을 작게 만들지 않는다

단품을 고른 뒤 카드가 자기 중심에서 튀어오르고, 차액이 숫자 칸을 굴려 +1,500 까지 올라갑니다. 세트로 담기를 누르면 합계가 4,500원에서 6,000원으로 바뀌고 카드는 0×0 으로 접히는데, 두 버튼의 폭 차이는 480px 무대(153×42)에서도 320px 화면(133×37)에서도 0px 입니다. 오렌지 무대 위에 작은 글자를 직접 얹지 않고 크림 카드와 먹 판 위로만 올려, 잰 열두 곳 중 가장 낮은 대비가 #17141a 바탕 위 크림 글자 17.11:1 입니다.

세트 권유 칸
새 창에서 열기

06 · 세트 권유 칸 — 측정 누적면적 34.771% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

overflow: hiddentranslateYaria-live
.ku__odo { display: block; overflow: hidden; height: 1.2em; }
.ku__roll { display: block; transform: translateY(-80%); }
.ku__roll b { display: block; text-align: right; font-weight: 800; line-height: 1.2; }

@keyframes kuRoll {
  0% { transform: translateY(0); }
  24% { transform: translateY(0); animation-timing-function: $ease-out; }
  64% { transform: translateY(-80%); animation-timing-function: steps(1, end); }
  86% { transform: translateY(0); }
  100% { transform: translateY(0); }
}

07주문번호 호출판

번호 하나가 건너가는 순간

만드는 중 칸의 A12 를 누르면 그 조각이 나왔어요 칸 맨 위로 건너가 두 목록이 [A13, A14] 와 [A12, A09] 로 갈리고, 읽어 주는 칸의 글자도 「A09」에서 「A12 A09」로 바뀝니다. 남은 번호는 빈자리를 메우며 한 줄씩 올라서고 포커스는 다음 번호 A13 으로 옮겨 갑니다. 내려가기·건너가기·올라서기를 조금씩 어긋나게 겹쳐 누적면적 31.229%·17/23 프레임이 움직였습니다.

주문번호 호출판
새 창에서 열기

07 · 주문번호 호출판 — 측정 누적면적 31.229% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateXaria-livesteps(1, end)
making.addEventListener('click', function (e) {
  var btn = e.target.closest('.kb__num');
  if (!btn || btn.disabled) return;
  kb.classList.remove('is-demo');
  var row = btn.parentNode;
  btn.className = 'kb__num kb__num--ready';
  btn.disabled = true;
  ready.insertBefore(row, ready.firstChild);
  // 호출판은 최근 MAX_READY 건만 남긴다. 무한히 쌓으면 칸이 화면 밖으로 자란다
  while (ready.children.length > MAX_READY) ready.removeChild(ready.lastElementChild);
  var next = making.querySelector('.kb__num');
  if (next) next.focus({ preventScroll: true });
});

08손을 떼면 처음으로

남은 시간을 고리로 보여 주기

남은 시간 고리는 conic-gradient 한 장이고, 각도는 @property 로 등록한 --sweep 이 움직입니다. 손을 떼고 3.2초를 기다리면 남은 초가 20에서 17로, 고리가 360deg 에서 306deg 로, 숫자줄이 translateY 0 에서 −96px 로 함께 갑니다. 고리가 다 닳으면 안내가 「고른 것을 지우고 첫 화면으로 돌아갔습니다」로 바뀌고, 움직이는 것이 고리와 숫자뿐이라 누적면적은 2.486% 로 아홉 중 가장 작습니다.

손을 떼면 처음으로
새 창에서 열기

08 · 손을 떼면 처음으로 — 측정 누적면적 2.486% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

@propertyconic-gradientsteps(1, end)
@property --sweep {
  syntax: "<angle>";
  inherits: false;
  initial-value: 360deg;
}
@property --left {
  syntax: "<integer>";
  inherits: false;
  initial-value: 20;
}

.kt__ring {
  position: relative; flex: 0 0 auto;
  width: $ring-size; height: $ring-size; border-radius: 50%;
  background: conic-gradient($color var(--sweep), rgba(23, 20, 26, .16) 0);
}
// 0 부터 20 까지 스물한 칸이 쌓인 줄. 남은 초가 줄면 그만큼 위로 밀린다
.kt__nums { display: block; transform: translateY(calc((#{$total} - var(--left)) * -100% / 21)); }

09낮은 화면 모드

앉은 눈높이로 내리되 칸은 그대로

낮은 화면 버튼을 누르면 버튼 묶음의 top 이 64px 에서 97.6px 로 33.6px만큼 내려앉고 라벨이 13px 에서 16px 로 커집니다. 그런데 누르는 칸의 높이는 36·36·36px 그대로입니다 — 앉은 눈높이에 맞추는 것이지 칸을 줄이는 일이 아니기 때문입니다. 내려앉는 거리는 $drop 한 값이고 묶음 아래에 같은 만큼 자리를 미리 비워 두어 레이아웃을 다시 계산하지 않으며, 누적면적 20.542%·15/23 을 기록했습니다.

낮은 화면 모드
새 창에서 열기

09 · 낮은 화면 모드 — 측정 누적면적 20.542% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aria-pressedtranslateYclamp
$drop: clamp(20px, 7vw, 36px);              // 묶음이 내려앉는 거리 = 묶음 아래 비워 둔 자리
$btn-h: clamp(24px, 8vw, 36px);             // 누르는 칸 높이. 모드가 바뀌어도 이 값은 그대로다
$label-sm: clamp(11px, 3.2vw, 13px);        // 기본 글자
$label-lg: clamp(13px, 3.8vw, 16px);        // 낮은 화면 모드의 한 단계 큰 글자

.lr__body { padding-bottom: $drop; }

.lr__group {
  display: grid; gap: $sp-2; margin: 0; padding: 0; list-style: none;
  transform: translateY(0); transition: transform $dur-base $easing;
}
.lr.is-low .lr__group { transform: translateY($drop); }

.lr__label { font-weight: 800; font-size: $label-sm; line-height: 1.1; }
.lr.is-low .lr__label { font-size: $label-lg; }

어디서 깨지는가 — 함정

hidden 속성이 display 에 지는 자리

가장 오래 붙잡은 것은 [hidden] 이 display 에 진다는 사실이었습니다. 01 의 유휴 화면과 주문 칸은 hidden 속성 하나로 갈아 끼우는데, 두 칸에 각각 display 값을 준 순간 브라우저 기본 규칙인 [hidden] { display: none } 이 특이도에서 밀려 두 화면이 겹쳐 섰습니다. 멈춘 화면만 들여다봐서는 안 보이고 실제로 눌러 상자 크기를 재야 드러나는 종류라, .ka [hidden] { display: none; } 한 줄을 부품 안에 못 박아 두었습니다. 화면을 통째로 갈아 끼우는 일이 키오스크 ui 만들기의 첫 관문이라, 06 의 권유 카드에도 같은 한 줄을 넣어 닫힌 카드가 0×0 이 되게 했습니다.

조작한 뒤에야 무대를 넘기는 목록

두 번째는 정지 화면 검사가 보지 못하는 넘침입니다. 07 호출판은 처음에 세 칸과 한 칸으로 서 있어 320×200 안에 깔끔하게 들어가지만, 번호를 옮겨 나왔어요 칸이 네 칸이 되자 문서 높이가 239px 로 자라 화면 밖으로 삐져나갔습니다. 반응형 게이트는 조작 전 화면만 재기 때문에 이 장면 앞에서 침묵합니다 — 그래서 MAX_READY = 3 상한을 두고 오래된 번호부터 지웁니다. 재는 방법에도 함정이 있었습니다. 04 품절 칸은 aria-disabled 만 붙고 disabled 속성은 없는데, Playwright 의 page.click() 은 그 칸을 아직 쓸 수 없는 요소로 보고 30초를 기다리다 죽습니다. 좌표를 직접 눌러야 「눌러도 담은 개수가 2개에서 2개」라는 사실을 잴 수 있었습니다.

파랑 가격이 크림 위에서 반 칸 모자란 일

세 번째는 색입니다. 04 의 가격표는 #2f6df6 파랑인데, 크림 판 #fff7e6 위에 바로 얹으면 4.25:1 로 본문 기준 4.5:1 에 못 미칩니다. 크림 판 위에 흰 타일 #ffffff 을 한 겹 얹어 가격이 흰 바탕에 놓이게 하자 4.53:1 이 되어 타일 경계도 살고 대비도 넘었습니다. 아홉 부품의 화면 글자 아흔다섯 곳을 이 기준으로 다시 재서 미달은 0건입니다. 05 에서는 반대로 하나를 버렸습니다. 옵션 시트를 아래에서 끌어올리는 연출은 루프가 이어붙는 지점에서 시트가 한 번에 내려가 튀기 때문에 쓰지 않고, 걸음 표시가 한 칸씩 차는 것으로만 단계를 알립니다. 단계를 칸이 아니라 선으로 잇는 화면이 필요하면 플로우차트 UI가 그 일을 맡습니다. 여기까지 손본 아홉 벌 원본을 zip 으로 묶어 두었고, 그 압축을 푸는 비밀번호는 3abh2hs9 입니다.

접근성

화면 움직임을 줄이는 prefers-reduced-motion: reduce 설정에서는 아홉 부품의 키프레임을 모두 끄고, 대신 무엇이 켜져 있는지 한눈에 읽히는 한 장을 세워 둡니다. 빈 화면으로 멈추지 않게 부품마다 남길 자리를 따로 정했습니다.

부품 움직임을 껐을 때 남는 화면
01 유휴 화면 첫 광고와 34% 찬 남은 시간 막대
02 언어 고르기 떠오름 없이 첫 칸이 고른 상태
03 카테고리 레일 블록이 첫 칸에 선 커피 판
04 메뉴 타일 첫 타일이 6px 내려앉은 「지금 눌린 칸」
05 옵션 시트 2걸음이 열린 자리 (뒤로 살아 있고 다음은 흐림)
06 세트 권유 다 펼쳐진 카드와 최종 차액 +1,500
07 호출판 두 칸이 각자 제자리에 선 번호판
08 되돌아가기 일곱 칸쯤 닳은 고리 (sweep 234deg · left 13)
09 낮은 화면 내려앉고 글자가 한 단계 커진 낮은 배치

말로 전해야 하는 것들은 이렇게 나눴습니다. 03 레일은 role="tablist" 와 aria-orientation="vertical" 에 로빙 tabindex 를 얹어, Tab 한 번이면 레일 전체가 들어오고 안에서는 ArrowUp·ArrowDown·Home·End 로 옮겨 다닙니다 — 실측에서 Home 은 블록을 43px 자리로, End 는 204px 자리로 보냈습니다. 04 품절 칸은 disabled 속성 대신 aria-disabled="true" 라 포커스를 그대로 받습니다. disabled 를 주면 Tab 이 그 칸을 건너뛰어 왜 못 누르는지 읽어 줄 기회 자체가 사라집니다. 07 의 나왔어요 칸은 aria-live="polite" 라 화면을 못 보는 사람도 새로 나온 번호를 듣고, 09 의 전환 버튼과 02 의 언어 칸은 aria-pressed 로 지금 켜진 것이 무엇인지 알립니다. 누르는 칸의 크기는 WCAG 2.2 Target Size (Minimum) 의 24×24 CSS 픽셀을 바닥값으로 잡되 서서 쓰는 단말이라 실제로는 더 크게 두었고, 09 의 버튼은 좁은 화면에서 26.3px, 480px 무대에서 36px 입니다.

FAQ

키오스크 ui 템플릿을 그대로 복사해도 되나요

아홉 부품은 전부 붙여 넣으면 도는 완결본이라 그대로 써도 됩니다. 따로 내려받는 화면 꾸러미 대신 이 글의 zip 이 그 자리를 채우는데, 부품마다 HTML·SCSS·컴파일된 CSS·React 판이 한 벌씩 들어 있어 색과 문구만 갈면 됩니다. 다만 화면에 보이는 글자는 매장 말로 바꾸고, 08 의 TOTAL 처럼 매장 사정이 정하는 값은 꼭 다시 잡으세요.

화면 크기는 무엇을 기준으로 잡나요

이 아홉 벌은 480×300 무대에서 재고, 320×200 까지 줄여 다시 쟀습니다. 실제 단말은 세로로 긴 대형 화면이 흔한데, 부품마다 clamp() 로 글자·아이콘·칸 높이를 묶어 두어 가로로 넓히든 세로로 세우든 같은 코드가 따라옵니다. 좁은 쪽에서는 간격을 올리지 않고 내리는 쪽으로만 손봤습니다 — 320×200 에서 쓸 수 있는 세로가 174px 남짓이라 칸을 키우면 그 자리에서 넘칩니다. 02 처럼 첫 화면에서 사람을 맞는 카드 골격은 로그인 화면 디자인 9가지의 카드와 같은 문제를 풉니다.

마우스 없이 터치만으로 다 되나요

됩니다. 아홉 부품의 누르는 곳은 전부 네이티브 <button> 이라 탭과 Enter·Space 가 같은 click 으로 들어오고, 03 레일과 05 옵션 줄만 화살표 키를 따로 받습니다. 손가락인지 마우스인지에 따라 동작을 가르고 싶다면 MDN Pointer events 의 pointerType 으로 touch·mouse·pen 을 구분할 수 있는데, 08 은 그 구분이 필요 없어 pointerdown 하나만 듣습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.