GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Kiosk UI Design: 9 Finger-Sized Order Screens

Kiosk UI design is the craft of handing a standing stranger one decision per screen.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order follows the way a customer meets a terminal, not how hard each screen is to build. Nobody is standing there yet while 01 runs its promo loop, so that goes first, and 02 asks for a language before anything else because every label after it depends on the answer. Then the ordering itself: the rail that groups the menu (03), the tiles that hold it (04), the option sheet a tile opens (05), and the combo that sheet leads into (06). 07 belongs to the pickup counter rather than to the terminal, and the last two are about what happens when a person walks away (08) or cannot reach the top of the screen (09). Read them as kiosk ui design examples you can press rather than screenshots, since every number below came out of a Playwright run against the demos on this page. In-store terminals sit on a different axis from web commerce, so quantity steppers, coupons, and payment methods stay in Checkout Page Design — 9 Cart Patterns, No Library, the press feedback under a finger is in 9 CSS Button Click Feedback Effects, Copy-Paste, the pill-shaped choice controls that 03 and 05 deliberately avoid are in 9 Segmented Control UI Patterns — Copy-Paste, and what a terminal should show when it has nothing to show is in 9 Empty State UI Design Cards Drawn in Pure CSS. Swap the finger for a mouse and the same interaction pillar gives you 3D Hover Effect CSS: 9 Pointer-Following Scenes; to put a character on the idle screen of 01, Mascot Animation: 9 CSS Parts from Wave to Sleep has nine of them.

Each preview loop was sampled as 24 frames, which leaves 23 gaps to compare, and this is what the nine came back with:

# Screen Changed area Frames that moved Frame-to-frame intensity
01 Idle attract screen 29.122% 23 of 23 149.3
02 Language first screen 35.997% 15 of 23 103.6
03 Vertical category rail 4.647% 9 of 23 178.4
04 Menu tiles and the sold-out cell 18.788% 23 of 23 47.1
05 Stepped option sheet 14.037% 17 of 23 52.2
06 Combo upsell panel 34.771% 17 of 23 123.9
07 Order number board 31.229% 17 of 23 114.4
08 Idle timeout back to home 2.486% 18 of 23 177.9
09 Low reach mode 20.542% 15 of 23 98.8

01Idle attract screen

What an attract loop is for

While nobody touches it, three promo cards swap on a hard cut and a timer bar fills underneath, which is the waiting state of a terminal by a shop door. The swap runs on steps(1, end) so no sampled frame catches two promos half-faded over each other, and the bar and the nudging hand icon share the same two-second clock. One tap drops .is-demo, takes the attract button to 0×0, brings the order panel up at 400×206px, and puts focus on its heading.

Idle attract screen
Open in a new tab

01 · Idle attract screen — Measured area 29.122% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

steps(1, end)scaleXanimation-delay
// An idle attract screen is the screen that runs by itself while nobody is touching it. The moment a hand lands, the promos stop and the order screen takes over.
// The preview loop (.is-demo) is driven by CSS keyframes; when a person presses, this code drops the loop and swaps the screen.
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(); });

02Language first screen

Why the language comes first

Four language buttons rise in turn and only the chosen one carries a 4px blue border and a check mark, with the hint line under the grid switching into that language in the same frame. The names stay in their own scripts — 한국어, English, 日本語, Tiếng Việt — because a language list is the one label nobody should translate, so they sit in data-name and are drawn by CSS content where no string dictionary can sweep them up. Clicking, tabbing then pressing Space, and pressing Enter all moved the choice, and aria-pressed read true on exactly one cell in every pass.

Language first screen
Open in a new tab

02 · Language first screen — Measured area 35.997% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-pressedanimation-fill-modetranslateY
// A language name is a value you do not translate, so it lives in data-name and is drawn here (see the intentional-Korean note in index.html)
.kl__name::after { content: attr(data-name); }

// The check mark holds its place even in the cells nobody picked — the label does not shift when you choose one
.kl__check {
  width: clamp(14px, 4.4vw, 20px); height: clamp(14px, 4.4vw, 20px);
  flex: 0 0 auto; opacity: 0;
}

// The chosen cell — the static path. When a hand lands, JS moves aria-pressed and this rule draws it as is
.kl__opt[aria-pressed="true"] {
  border-width: 4px; border-color: $color;
  background: $subject-ink; color: $subject-cream;
}
.kl__opt[aria-pressed="true"] .kl__check { opacity: 1; }

03Vertical category rail

Where a standing hand reaches

A selection block slides down a column of tall category cells while the panel on the right cuts to that group in the frame where the block lands. The block is laid 4px outside the cell on every side, so its top always measures 4px above the cell it has reached, which was measured as a vertical difference of −4px against the fourth cell at 320px. Moving the choice walked the block top through 43, 150, 204, and 96px for a click, ArrowDown and two ArrowUps, and End and Home closed it back at 204 and 43px.

Vertical category rail
Open in a new tab

03 · Vertical category rail — Measured area 4.647% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-selectedaria-orientationtranslateY
// Vertical category rail. The chosen cell is recorded in one place, data-sel, and the position of the selection block
// and the right-hand panel are drawn by CSS from that one value (the preview loop .is-demo animates the same places with keyframes).
// The rail is role="tablist" aria-orientation="vertical", so the up and down arrows are the standard way to move.
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');
    // roving tabindex — the whole rail takes one Tab, and inside it the arrows move the choice
    krTabs[i].setAttribute('tabindex', on ? '0' : '-1');
  }
  if (moveFocus) { krTabs[idx].focus({ preventScroll: true }); }
}

04Menu tiles and the sold-out cell

What a sold-out cell must still do

One of six large tiles dims under a diagonal band and a sold-out stamp, and that cell refuses a click, an Enter, and a Space alike, with the cart line measuring 2 before each attempt and 2 after it. The other five sink 6px on press and the shadow under them collapses from 6px to 0 in the same instant. Marking the dead cell with aria-disabled instead of disabled keeps it in the Tab order, which is the whole point: ten Tab presses walked through all six tiles including the sold-out one.

Menu tiles and the sold-out cell
Open in a new tab

04 · Menu tiles and the sold-out cell — Measured area 18.788% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-disabledrepeating-linear-gradientanimation-delay
// The sold-out cell is marked with aria-disabled rather than the disabled attribute. disabled makes Tab skip that cell,
// which throws away the chance to read out why it cannot be pressed — focus stays, and only adding is blocked, by the one line here.
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) {
  // It is a button, so Enter and Space arrive as click too — no separate keyboard path is built.
  tile.addEventListener('click', function () { add(tile); });
});

05Stepped option sheet

Two steps instead of one long form

A large sheet under the menu line splits size and temperature into two steps, and the second segment above it fills as the step changes. Picks survive the walk in both directions: Large, then forward to Iced, then Back, then forward again, and both values came through, with exactly one checked cell on each step. The preview cuts between the two panes rather than sliding a sheet up from the bottom, because the seam where the loop repeats would yank a raised sheet back down every two seconds.

Stepped option sheet
Open in a new tab

05 · Stepped option sheet — Measured area 14.037% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-checkedsteps(1, end)translateX
// The sheet only swaps the option row for each step. What you picked is not thrown away when you walk back and forth —
// the radio state stays in the DOM and only the visible pane changes.
var picks = { size: 'Medium', temp: 'Hot' };

function go(to) {
  ko.classList.remove('is-demo');
  step = Math.max(0, Math.min(panes.length - 1, to));
  paint();
  var first = panes[step].querySelector('[aria-checked="true"]') || panes[step].querySelector('.ko__opt');
  if (first) { first.focus({ preventScroll: true }); }
}

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');
}

06Combo upsell panel

Making the refusal the same size

After a single item is picked, a combo card pops up and the price gap rolls upward through a one-line digit window, and taking the combo moved the total from 4,500 won to 6,000 won, a step of 1,500 won. The button that declines is not the small one — both measured 153×42px, a width difference of 0px, and both shrank together to 133×37px at 320px. Answering either way closes only the card, which then measures 0×0 while the order line and the total stay where they were.

Combo upsell panel
Open in a new tab

06 · Combo upsell panel — Measured area 34.771% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

overflow: hiddentranslateYaria-live
// Odometer — a window open only as tall as one digit cell, with the stacked amount lines pushed up by translateY.
// The window height (1.2em) and the line height (1.2) have to match so that one line shows. There are five lines, so -80% is the last one (1,500).
.ku__odo { display: block; overflow: hidden; height: 1.2em; }
.ku__roll { display: block; transform: translateY(-80%); }
// The lines carry different digit counts (300 · 1,500) — aligning them right keeps the gap to the unit (won) from opening while it rolls
.ku__roll b { display: block; text-align: right; font-weight: 800; line-height: 1.2; }

.ku__btns { display: grid; grid-template-columns: repeat(2, 1fr); gap: $sp-2; }
// The button grid nails down width:100% — the two cells have to be equally wide for "declining is the same size" to hold
.ku__btn {
  width: 100%; padding: clamp(8px, 3vw, 12px) $sp-2; cursor: pointer;
  border: 2px solid $subject-ink; border-radius: $r-control;
  font-family: inherit; font-weight: 800; font-size: clamp(11px, 3.2vw, 14px); line-height: 1.2;
}
.ku__btn--yes { background: $subject-ink; color: $subject-cream; }
.ku__btn--no { background: $stage-paper; color: $subject-ink; }

07Order number board

The board behind the counter

Press a number in the preparing column and it crosses to the ready column and blinks twice on arrival: preparing measured [A12, A13, A14] then [A13, A14], ready measured [A09] then [A12, A09], and the aria-live text swapped from A09 to A12 A09 in the same move. Focus lands on the next waiting number, so a whole rush can be cleared without reaching for a mouse. The board keeps the three most recent numbers and drops the oldest, which is the line that keeps a busy hour from growing the column past the screen.

Order number board
Open in a new tab

07 · Order number board — Measured area 31.229% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXaria-livesteps(1, end)
// Press a number in the "Preparing" column (Enter does the same — a native button) and that piece moves to the top of the "Ready" column.
// The column it lands in carries aria-live="polite", so someone who cannot see the screen still hears the number that just came out.
// The preview loop (.is-demo) is CSS keyframes while this is a real DOM move — the two are separate code.
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);
  // The board keeps only the most recent MAX_READY entries. Stacked forever, the column grows off the screen
  // (298-07 measured: at four cells the document height hit 239px at 320×200 — BD9, which measures before interaction, cannot see this)
  while (ready.children.length > MAX_READY) ready.removeChild(ready.lastElementChild);
  var next = making.querySelector('.kb__num');
  if (next) next.focus({ preventScroll: true });
});

08Idle timeout back to home

What happens when someone walks away

When nobody touches it, a countdown ring drains and the number inside falls one cut at a time: after 3.2 seconds of idling the count measured 20 then 17, --sweep measured 360deg then 306deg, and the digit column's translateY measured 0 then −96px. At zero it throws the picks away, returns to the first screen, and says so in a notice line instead of just blanking. Any touch or key refills the ring to 20, and both --sweep and --left are registered through @property with inherits: false, so the values have to be set on the elements that use them.

Idle timeout back to home
Open in a new tab

08 · Idle timeout back to home — Measured area 2.486% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

@propertyconic-gradientsteps(1, end)
function paint() {
  ring.style.setProperty('--sweep', (360 * left / TOTAL) + 'deg');
  nums.style.setProperty('--left', String(left));
  ring.setAttribute('aria-label', RING_LABEL.replace('{n}', String(left)));
}

function tick() {
  left = left - 1;
  if (left <= 0) { reset(NOTE_RESET); return; }   // once it runs out, drop the picks and go back to the first screen
  paint();
}

function touch() {
  kt.classList.remove('is-demo');                 // a person touching it drops the preview loop
  reset(NOTE_IDLE);
}

paint();
setInterval(tick, TICK);
document.addEventListener('pointerdown', touch);
document.addEventListener('keydown', touch);

09Low reach mode

Seated eye level without smaller targets

Pressing the low-reach toggle drops the whole button cluster by a measured 33.6px, from a top of 64px to 97.6px, and steps the labels from 13px to 16px in the frame where it lands. The tap targets do not shrink along with the drop — the three button heights measured 36, 36, and 36px in both modes, because this is a change of height, not a change of size. The space the cluster falls into is reserved in advance as padding-bottom: $drop, so nothing below it reflows while it moves.

Low reach mode
Open in a new tab

09 · Low reach mode — Measured area 20.542% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-pressedtranslateYclamp
$drop: clamp(20px, 7vw, 36px);              // how far the cluster drops = the space left empty below it
$btn-h: clamp(24px, 8vw, 36px);             // tap target height. this value stays the same when the mode changes
$label-sm: clamp(11px, 3.2vw, 13px);        // the default label
$label-lg: clamp(13px, 3.8vw, 16px);        // one size up, for low reach mode

.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__btn {
  display: flex; align-items: center; justify-content: center;
  width: 100%; min-height: $btn-h; padding: $sp-1 $sp-2; cursor: pointer;
  border: 2px solid $subject-ink; border-radius: $r-control;
  background: $stage-paper; color: $subject-ink; font-family: inherit;
}
.lr__label { font-weight: 800; font-size: $label-sm; line-height: 1.1; }
.lr.is-low .lr__label { font-size: $label-lg; }

Where it breaks — the trap

Three things cost more time here than the animations did, and none of them are peculiar to terminals. They are the ordinary hazards of touch screen ui design: an attribute that loses to a stylesheet, a list that only grows after a tap, and a blue that misses AA by a rounding error.

When both screens stood at once

01 holds two screens in one component and shows exactly one of them by flipping hidden from JS. That stops working the moment the hidden element has a display of its own, because the browser's own [hidden] { display: none } carries the weakest weight there is, so any author rule with a display in it wins and the supposedly hidden screen keeps standing. Both screens stacked on the stage until one line inside the component, .ka [hidden] { display: none; }, put the attribute back in charge; measured after that, a tap leaves the attract button at 0×0 and brings the order panel up at 400×206px. 06 carries the same line, which is why its combo card measures 0×0 once it has been answered rather than leaving a gap in the layout.

A list that only overflows after a tap

07 moves a row out of one column and into another, so the page it draws after four presses is a page no responsive check has ever seen. Letting the ready column grow to a fourth cell pushed the 320×200 document to 239px, and that overflow exists only for someone who has already touched the board. A ceiling of three in MAX_READY holds it: the same run measured the document at 200px with all three numbers moved across and the oldest one dropped. Gates that sample the document as it loads stayed quiet through the whole thing, which is the honest lesson of this item.

Blue on cream, a rounding error short of AA

The price blue in 04 is #2f6df6, and on the cream card #fff7e6 the tiles first sat on, it measures 4.25:1, under the 4.5:1 that small text has to clear. Nothing was wrong with the color; the ground under it was wrong. Putting white #ffffff tiles on the cream card took the same blue to a measured 4.53:1 and at the same time gave the six tiles an edge against the panel they sit on. The sold-out cell keeps its name and price on dimmed cream #f1e9da at 15.16:1 and its stamp is cream #fff7e6 on ink #17141a at 17.11:1, so that cell reads as dim rather than as unreadable, and across all nine screens the contrast pass came back with no failures at all. 06 gets there by keeping text off the orange stage entirely: cream card, ink type, 17.11:1 in the worst case. When the steps need lines between them instead of cells beside them, Node Based UI Design: 9 Patterns, No Library takes that job. The archive holds all nine originals with these three fixes already applied, and the password that opens it is 3abh2hs9.

One measuring note on the same tile: Playwright's page.click() treats aria-disabled="true" as not enabled and waits for the element to become actionable until it gives up, so the sold-out cell cannot be measured through it at all. Pressing the middle of its bounding box with page.mouse.click() is what a finger does, and that is where the numbers above came from.

Accessibility (reduced-motion)

What each screen keeps when motion is off

Under prefers-reduced-motion: reduce every loop stops, and each screen parks on a standing state that still says what is switched on. 01 rests on the first promo with its timer bar frozen about a third of the way across, 02 rests with the first language cell chosen and the cells at translateY(0), and 03 rests with the block on the top cell and only the first panel visible. 04 leaves the first tile pressed 6px down so that "this is what a press looks like" survives with the diagonal band standing still, 05 rests on step two with the second segment filled and Next dimmed, and 06 rests with the card fully open and the roll parked on the final +1,500. 07 puts every number back in place with transform: none, 08 freezes the ring at 234deg with 13 on the face, and 09 rests in the low layout with 16px labels and the toggle drawn as pressed. The rule and its browser support are on MDN's prefers-reduced-motion page.

Keyboard and screen reader paths

The rail in 03 runs a roving tabindex, so one Tab enters it and the arrows move inside it: Tab landed on the first tab and a second Tab went on to the panel, while ArrowUp, ArrowDown, Home and End each moved the block and the panel together. 04 marks the sold-out tile with aria-disabled rather than disabled precisely so it stays reachable, and 07 announces arrivals through aria-live="polite", where the announced text measured A09 before the move and A12 A09 after it. 09 carries its state in aria-pressed, which measured false, true, and false again as the toggle was clicked, pressed with Enter and pressed with Space, and 02 does the same for the language with the true count staying at exactly one. Every control is a native <button>, so touch, Enter and Space all arrive on one path — 08 is the only one that also listens for raw pointer events, because "someone is still here" has to fire for a touch anywhere on the screen, not only on a control. Sizes follow WCAG 2.2 target size (minimum) with room to spare: the language cells measured 196px wide, the two combo buttons 153×42px each, and the low-reach buttons 36px tall in both modes.

FAQ

Can I paste a kiosk ui design template straight into my project?

That is what the archive on this page is. Nine folders, each holding one index.html and one style.scss, with the class names and JS identifiers exactly as they read in the code above, no framework and no CDN call, and nothing to run but sass style.scss style.css. Swap the stage tokens for your palette and the menu strings for your menu, and the moving parts stay put, because everything that animates here is driven by transform, opacity, or a custom property registered with @property.

What screen size should a self-order terminal be designed for?

Every screen here was measured at 320×200, the narrowest frame we ship, and none of them overflowed it — 07 stayed at a document height of 200px with all three numbers moved across, and 09 stayed at 200px with the cluster dropped into its low position. Real terminals are far bigger than that, which is the reason sizes come from clamp() rather than from fixed pixels: the same part grows into a portrait panel without a second layout. The one thing that should not scale down with the viewport is the tap target, and 24×24 CSS pixels is the floor rather than the goal for a screen used with gloves, a cane tip, or a knuckle. The card skeleton that greets a person on a first screen, as 02 does, solves the same problem as the ones in 9 Login Form Design Patterns You Can Copy-Paste.

Does any of this need a mouse?

No. Every control is a native <button>, so a tap, a click, Enter, and Space arrive as the same click event, which is why none of these nine carry a second keyboard path. 08 is the exception that proves the rule: it listens on the document for pointerdown and keydown together, because a countdown has to be canceled by a touch anywhere on the screen rather than by a press on one control. Hover is never load-bearing in any of the nine, since a finger has no hover state to give.

Enter the archive password

The password is inside this article. You will find it as you read.