05Keyboard arrow highlight — the highlighted position value moves through the list by each key press
Each up or down arrow key press increases or decreases the highlighted item's order value by one, and once that order value passes the end of the list it wraps back to the first value.
Best forNavigating a menu without a mouse, an accessible right-click menu for a table
moveHighlight(dir)(hi + dir + n) % nArrowUp / ArrowDown

<main class="stage">
<div class="cm is-demo" id="cm">
<div class="cm__field" id="cmField" tabindex="0">
<span class="cm__hint">Right-click → ↑↓ arrow keys</span>
</div>
<div class="cm__menu" id="cmMenu" role="menu">
<button class="cm__item" type="button" role="menuitem" data-i="0">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>
<span>Copy</span>
</button>
<button class="cm__item" type="button" role="menuitem" data-i="1">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg></span>
<span>Paste</span>
</button>
<button class="cm__item" type="button" role="menuitem" data-i="2">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="m221.66 90.34l-56 56l-40-40l56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg></span>
<span>Rename</span>
</button>
<button class="cm__item cm__item--danger" type="button" role="menuitem" data-i="3">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>
<span>Delete</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 현재 강조 인덱스에 화살표 방향(+1/-1)을 더한 뒤 항목 개수로 나눈 나머지(모듈로)를
// 새 인덱스로 삼아 순환시키고, 그 인덱스의 항목에만 강조 클래스를 건다 (moveHighlight()).
@include stage($stage-ink);
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: $r-card;
background: $subject-pink;
color: $subject-ink;
display: grid;
place-items: center;
padding: $sp-3;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
}
.cm__hint { opacity: .75; font-weight: 500; font-size: 12px; }
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 168px;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-1;
display: grid;
gap: $sp-1;
opacity: 0;
transform: scale(.96);
transform-origin: top left;
pointer-events: none;
transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-out;
z-index: 5;
&.is-open { opacity: 1; transform: scale(1); pointer-events: auto; }
}
.cm__item {
position: relative;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 0;
background: none;
border-radius: $r-xs;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
cursor: pointer;
text-align: left;
transition: background $dur-quick $ease-out;
&--hi { background: $subject-blue; color: $subject-cream; }
&--danger { color: $error; }
&--hi.cm__item--danger { color: $subject-cream; background: $error; }
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: $subject-blue;
svg { display: block; width: 100%; height: 100%; }
}
.cm__item--danger .cm__ic { color: $error; }
.cm__item--hi .cm__ic { color: $subject-cream; }
@media (max-width: 340px) {
.cm__field { width: 176px; height: 108px; font-size: 12px; padding: $sp-2; }
.cm__menu { min-width: 144px; }
.cm__item { padding: $sp-1 $sp-2; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cm__menu, .cm__item { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: 16px;
background: #ff9ebb;
color: #17141a;
display: grid;
place-items: center;
padding: 12px;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
}
.cm__hint {
opacity: 0.75;
font-weight: 500;
font-size: 12px;
}
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 168px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
display: grid;
gap: 4px;
opacity: 0;
transform: scale(0.96);
transform-origin: top left;
pointer-events: none;
transition: opacity 220ms ease-out, transform 220ms ease-out;
z-index: 5;
}
.cm__menu.is-open {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.cm__item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 0;
background: none;
border-radius: 4px;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
cursor: pointer;
text-align: left;
transition: background 220ms ease-out;
}
.cm__item--hi {
background: #2f6df6;
color: #fff7e6;
}
.cm__item--danger {
color: #c2361a;
}
.cm__item--hi.cm__item--danger {
color: #fff7e6;
background: #c2361a;
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: #2f6df6;
}
.cm__ic svg {
display: block;
width: 100%;
height: 100%;
}
.cm__item--danger .cm__ic {
color: #c2361a;
}
.cm__item--hi .cm__ic {
color: #fff7e6;
}
@media (max-width: 340px) {
.cm__field {
width: 176px;
height: 108px;
font-size: 12px;
padding: 8px;
}
.cm__menu {
min-width: 144px;
}
.cm__item {
padding: 4px 8px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cm__menu, .cm__item {
transition: none;
}
}(function () {
var field = document.getElementById('cmField');
var menu = document.getElementById('cmMenu');
var root = document.getElementById('cm');
var items = Array.prototype.slice.call(menu.querySelectorAll('.cm__item'));
var GAP = 6, hi = -1;
function positionAt(x, y) {
var mw = menu.offsetWidth, mh = menu.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
var px = Math.max(GAP, Math.min(x, vw - mw - GAP));
var py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu.style.left = px.toFixed(0) + 'px';
menu.style.top = py.toFixed(0) + 'px';
}
function setHi(i) {
if (hi >= 0) items[hi].classList.remove('cm__item--hi');
hi = i;
if (hi >= 0) items[hi].classList.add('cm__item--hi');
}
function open(x, y) {
positionAt(x, y);
menu.classList.add('is-open');
setHi(0);
}
function close() { menu.classList.remove('is-open'); setHi(-1); }
// 계산 겹: (현재 인덱스 + 방향 + 개수) % 개수 로 다음 인덱스를 구해 위/아래 끝에서 반대편으로
// 넘어가는 순환 이동을 만든다.
function moveHighlight(dir) {
if (!menu.classList.contains('is-open')) return;
var n = items.length;
setHi((hi + dir + n) % n);
}
field.addEventListener('contextmenu', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
open(e.clientX, e.clientY);
});
document.addEventListener('keydown', function (e) {
if (!menu.classList.contains('is-open')) return;
if (e.key === 'ArrowDown') { e.preventDefault(); root.classList.remove('is-demo'); stopAutoplay(); moveHighlight(1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); root.classList.remove('is-demo'); stopAutoplay(); moveHighlight(-1); }
else if (e.key === 'Escape') { close(); }
});
document.addEventListener('pointerdown', function (e) {
if (menu.classList.contains('is-open') && !menu.contains(e.target) && e.target !== field) close();
});
// 자동 루프: 메뉴를 연 뒤 방향키 이동을 흉내내 강조 막대가 위→아래로 순환 이동하는 것을 보여준다.
var step = 0, autoTimer = null;
function autoTick() {
if (step === 0) { var r = field.getBoundingClientRect(); open(r.left + 16, r.top + 16); }
else if (step < 6) { moveHighlight(1); }
else { close(); }
step = (step + 1) % 7;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 220); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./KeyboardArrowHighlight.module.scss";
const ICON_COPY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>';
const ICON_PASTE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg>';
const ICON_RENAME = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="m221.66 90.34l-56 56l-40-40l56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>';
const ICON_TRASH = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>';
export type KeyboardArrowHighlightProps = {
color?: string;
};
export default function KeyboardArrowHighlight({
color = "#2f6df6",
}: KeyboardArrowHighlightProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const field = root.querySelector<HTMLDivElement>(`.${styles.field}`);
const menu = root.querySelector<HTMLDivElement>(`.${styles.menu}`);
if (!field || !menu) return;
const items = Array.prototype.slice.call(menu.querySelectorAll(`.${styles.item}`)) as HTMLElement[];
const GAP = 6;
let hi = -1;
function positionAt(x: number, y: number) {
const mw = menu!.offsetWidth, mh = menu!.offsetHeight;
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
const px = Math.max(GAP, Math.min(x, vw - mw - GAP));
const py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu!.style.left = px.toFixed(0) + "px";
menu!.style.top = py.toFixed(0) + "px";
}
function setHi(i: number) {
if (hi >= 0) items[hi].classList.remove(styles.hi);
hi = i;
if (hi >= 0) items[hi].classList.add(styles.hi);
}
function open(x: number, y: number) { positionAt(x, y); menu!.classList.add(styles.open); setHi(0); }
function close() { menu!.classList.remove(styles.open); setHi(-1); }
// 계산 겹: (현재 인덱스 + 방향 + 개수) % 개수 로 다음 인덱스를 구해 위/아래 끝에서 반대편으로
// 넘어가는 순환 이동을 만든다.
function moveHighlight(dir: number) {
if (!menu!.classList.contains(styles.open)) return;
const n = items.length;
setHi((hi + dir + n) % n);
}
function onContextMenu(e: MouseEvent) { e.preventDefault(); stopAutoplay(); open(e.clientX, e.clientY); }
function onKeyDown(e: KeyboardEvent) {
if (!menu!.classList.contains(styles.open)) return;
if (e.key === "ArrowDown") { e.preventDefault(); stopAutoplay(); moveHighlight(1); }
else if (e.key === "ArrowUp") { e.preventDefault(); stopAutoplay(); moveHighlight(-1); }
else if (e.key === "Escape") { close(); }
}
function onDocDown(e: PointerEvent) {
if (menu!.classList.contains(styles.open) && !menu!.contains(e.target as Node) && e.target !== field) close();
}
field.addEventListener("contextmenu", onContextMenu);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("pointerdown", onDocDown);
let step = 0;
let autoTimer: number | null = null;
function autoTick() {
if (step === 0) { const r = field!.getBoundingClientRect(); open(r.left + 16, r.top + 16); }
else if (step < 6) { moveHighlight(1); }
else { close(); }
step = (step + 1) % 7;
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 220); autoTick(); }
return () => {
stopAutoplay();
field.removeEventListener("contextmenu", onContextMenu);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("pointerdown", onDocDown);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.cm}>
<div className={styles.field} tabIndex={0}>
<span className={styles.hint}>우클릭 → ↑↓ 방향키</span>
</div>
<div className={styles.menu} role="menu">
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_COPY }} />
<span>복사</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_PASTE }} />
<span>붙여넣기</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_RENAME }} />
<span>이름 바꾸기</span>
</button>
<button type="button" className={`${styles.item} ${styles.danger}`} role="menuitem">
<span className={styles.ic} dangerouslySetInnerHTML={{ __html: ICON_TRASH }} />
<span>삭제</span>
</button>
</div>
</div>
</div>
);
}05 Keyboard arrow highlight — the highlighted position value moves through the list by each key press — Measured area 19.524% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · Context Menu UI: 9 Right-Click Menu Patterns
The post this demo lives in
Context Menu UI: 9 Right-Click Menu PatternsOne menu position value is set from the clicked coordinate value, one flips its direction value near a screen edge, and See all demos in this post
If you like this effect - similar demos
Transitions03 Item stagger cascade — each row's delay grows with its position value in the listCart Drawer UI Patterns: 9 Motion Techniques
Transitions07 Swipe-follow toggle — while a horizontal drag-distance value grows, the highlight's position value follows toward the side tab by that same ratioCSS Bottom Navigation Bar: 9 Transition Patterns
Transitions03 Selection-value highlight toggle — when a row's selection value turns on, its highlight-color value deepens, and it returns to the original value when offCollapsible Table Row: 9 UI Patterns