07충돌 감지 표
같은 조합에 두 가지 동작이 걸리면 그 두 줄에 경고 배지가 붙는 표입니다. 경고 문장은 aria-live 로 한 번 읽히고, 한쪽을 바꾸면 배지가 같이 사라집니다.
쓰임환경설정의 단축키 목록, 플러그인 키맵 관리 화면
aria-livegrid-template-columnsoutline

<main class="stage">
<div class="cf is-demo" id="cf">
<p class="cf__cap">단축키 목록</p>
<div class="cf__table" role="table" aria-label="단축키 목록">
<div class="cf__row" role="row">
<span class="cf__act" role="cell">명령 실행</span>
<span class="cf__bind" role="cell"><kbd class="cf__key">Ctrl</kbd><kbd class="cf__key"><span class="cf__letterText">K</span></kbd></span>
<span class="cf__state" role="cell">
<span class="cf__warn">충돌</span>
<span class="cf__ok">확인</span>
</span>
</div>
<div class="cf__row" role="row">
<span class="cf__act" role="cell">줄 복제</span>
<span class="cf__bind" role="cell"><kbd class="cf__key">Ctrl</kbd><kbd class="cf__key"><span class="cf__letterText">D</span></kbd></span>
<span class="cf__state" role="cell">
<span class="cf__warn">충돌</span>
<span class="cf__ok">확인</span>
</span>
</div>
<div class="cf__row" role="row">
<span class="cf__act" role="cell">빠른 열기</span>
<span class="cf__bind" role="cell"><kbd class="cf__key">Ctrl</kbd><kbd class="cf__key"><span class="cf__letterText" id="lastLetter">K</span><span class="cf__letterAlt" aria-hidden="true">J</span></kbd></span>
<span class="cf__state" role="cell">
<span class="cf__warn">충돌</span>
<span class="cf__ok">확인</span>
</span>
</div>
</div>
<p class="cf__live" aria-live="polite">같은 조합이 두 줄에 있습니다</p>
<button type="button" class="cf__swap" id="swap">마지막 줄을 Ctrl+J 로</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 충돌 감지 표 — 같은 조합이 두 줄에 걸리면 그 줄에 경고 배지와 outline 이 붙는다.
// 표는 <td> 대신 div 격자로 짠다 — <td> 에 직접 grid 를 주면 Chrome 이 한 행의 칸을 익명 셀로 묶는다 (259 실측).
// 상태 배지는 「충돌」과 「확인」 두 장을 같은 칸에 겹쳐 두고 visibility 로만 고른다 — 칸 폭이 흔들리지 않는다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.cf { display: flex; flex-direction: column; gap: $sp-2; width: min(100%, 320px); }
.cf__cap {
margin: 0; align-self: flex-start; border-radius: $r-pill; padding: $sp-1 $sp-3;
// 주황 무대 위 크림 글자는 3.11:1 이라 잉크 알약 배경 위로 옮겼다 (실측)
background: $subject-ink; font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1.4; color: $subject-cream;
}
.cf__table { display: flex; flex-direction: column; gap: $sp-1; }
.cf__row {
display: grid; grid-template-columns: 1fr auto 44px; align-items: center; gap: $sp-2;
box-sizing: border-box; min-height: 30px; padding: 0 $sp-2;
background: $subject-cream; border-radius: $r-sm;
outline: 2px solid transparent; outline-offset: 0;
}
.cf__row.is-conflict { outline-color: #905347; }
.cf__act { font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); color: $subject-ink; }
.cf__bind { display: flex; align-items: center; gap: $sp-1; }
.cf__key {
position: relative; display: grid; place-items: center; box-sizing: border-box;
height: 20px; min-width: 20px; padding: 0 $sp-1;
border-radius: $r-xs; border: 1px solid rgba(23, 20, 26, .24); background: rgba(23, 20, 26, .06);
color: $subject-ink; font-family: inherit; font-weight: 800; font-size: 10px; line-height: 1;
}
.cf__key > span { grid-area: 1 / 1; }
.cf__letterAlt { visibility: hidden; }
.cf__state { display: grid; place-items: center; }
.cf__state > span {
grid-area: 1 / 1; border-radius: $r-pill; padding: $sp-1 $sp-2;
font-weight: 800; font-size: 10px; line-height: 1; visibility: hidden;
}
.cf__warn { background: #905347; color: $color; }
.cf__ok { background: rgba(23, 20, 26, .10); color: $subject-ink; }
.cf__row.is-conflict .cf__warn { visibility: visible; }
.cf__row.is-ok .cf__ok { visibility: visible; }
.cf__live {
margin: 0; align-self: flex-start; border-radius: $r-pill; padding: $sp-1 $sp-3;
background: $subject-ink; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.4; color: $subject-cream; visibility: hidden;
}
.cf.has-conflict .cf__live { visibility: visible; }
.cf__swap {
align-self: flex-start; border: 0; cursor: pointer;
background: $subject-ink; color: $subject-cream; border-radius: $r-pill;
padding: $sp-1 $sp-3; font-family: inherit; font-weight: 800; font-size: 10px; line-height: 1.4;
transition: transform $duration $easing;
}
.cf__swap:active { transform: translateY(2px); }
.cf__swap:focus-visible { outline: 3px solid $subject-cream; outline-offset: 2px; }
.cf__swap:disabled { cursor: default; background: rgba(23, 20, 26, .34); }
// ── 격자 미리보기 루프: 0~15% 깨끗한 표 · 16% 충돌 배지 + outline · 64% 마지막 줄이 Ctrl+J 로 바뀌며 해소.
// 배지는 사라지지 않고 「충돌」→「확인」으로 바뀐다 — 루프에서 주인공을 지우면 그 빈 칸이 poster 가 된다 (255 실측).
.cf.is-demo .cf__row,
.cf.is-demo .cf__warn,
.cf.is-demo .cf__ok,
.cf.is-demo .cf__live,
.cf.is-demo .cf__row:nth-child(3) .cf__letterText,
.cf.is-demo .cf__letterAlt {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.cf.is-demo .cf__row:nth-child(1),
.cf.is-demo .cf__row:nth-child(3) { animation-name: cfOutline; }
@keyframes cfOutline {
0%, 15% { outline-color: transparent; }
16%, 63% { outline-color: #905347; }
64%, 100% { outline-color: transparent; }
}
.cf.is-demo .cf__row:nth-child(1) .cf__warn,
.cf.is-demo .cf__row:nth-child(3) .cf__warn { animation-name: cfWarnBadge; }
@keyframes cfWarnBadge {
0%, 15% { visibility: hidden; }
16%, 63% { visibility: visible; }
64%, 100% { visibility: hidden; }
}
.cf.is-demo .cf__row:nth-child(1) .cf__ok,
.cf.is-demo .cf__row:nth-child(3) .cf__ok { animation-name: cfOkBadge; }
@keyframes cfOkBadge {
0%, 63% { visibility: hidden; }
64%, 100% { visibility: visible; }
}
.cf.is-demo .cf__row:nth-child(2) .cf__ok { animation-name: cfOkAlways; }
@keyframes cfOkAlways { 0%, 100% { visibility: visible; } }
.cf.is-demo .cf__live { animation-name: cfLive; }
@keyframes cfLive {
0%, 15% { visibility: hidden; }
16%, 63% { visibility: visible; }
64%, 100% { visibility: hidden; }
}
// 글자가 바뀌는 것은 마지막 줄뿐이다 — 선택자를 그 줄로 좁히지 않으면 다른 줄의 K·D 까지 사라진다
.cf.is-demo .cf__row:nth-child(3) .cf__letterText { animation-name: cfLetterOld; }
.cf.is-demo .cf__letterAlt { animation-name: cfLetterNew; }
@keyframes cfLetterOld { 0%, 63% { visibility: visible; } 64%, 100% { visibility: hidden; } }
@keyframes cfLetterNew { 0%, 63% { visibility: hidden; } 64%, 100% { visibility: visible; } }
// 컷만 두면 움직인 프레임이 모자라 바뀌는 줄이 한 번 튀어오르는 보조 움직임을 붙인다 (252 실측)
.cf.is-demo .cf__row:nth-child(3) .cf__bind {
animation-name: cfBindPop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes cfBindPop {
0%, 10% { transform: translateX(0); }
16% { transform: translateX(-4px); }
24%, 58% { transform: translateX(0); }
64% { transform: translateX(-4px); }
72%, 100% { transform: translateX(0); }
}
.cf.is-demo .cf__swap {
animation-name: cfSwapState; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes cfSwapState {
0%, 63% { background: $subject-ink; }
64%, 100% { background: rgba(23, 20, 26, .34); }
}
@media (max-width: 340px) {
// 알약 배경 두 개가 세로 여백을 더 쓰게 되어 320×200 에서 15px 넘쳤다 — 좁은 화면에서는
// 올리지 않고 줄이는 쪽으로만 푼다 (BD17↔BD9 상쇄 규칙)
.cf { gap: $sp-1; }
.cf__row { grid-template-columns: 1fr auto 40px; gap: $sp-1; padding: 0 $sp-1; min-height: 26px; }
.cf__state > span { padding: 0 $sp-1; }
.cf__cap { padding: 0 $sp-2; }
.cf__live { padding: 0 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cf.is-demo .cf__row,
.cf.is-demo .cf__warn,
.cf.is-demo .cf__ok,
.cf.is-demo .cf__live,
.cf.is-demo .cf__letterText,
.cf.is-demo .cf__letterAlt,
.cf.is-demo .cf__bind,
.cf.is-demo .cf__swap { animation: none !important; }
.cf__swap { transition: none !important; }
}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);
}
.cf {
display: flex;
flex-direction: column;
gap: 8px;
width: min(100%, 320px);
}
.cf__cap {
margin: 0;
align-self: flex-start;
border-radius: 999px;
padding: 4px 12px;
background: #17141a;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.4;
color: #fff7e6;
}
.cf__table {
display: flex;
flex-direction: column;
gap: 4px;
}
.cf__row {
display: grid;
grid-template-columns: 1fr auto 44px;
align-items: center;
gap: 8px;
box-sizing: border-box;
min-height: 30px;
padding: 0 8px;
background: #fff7e6;
border-radius: 8px;
outline: 2px solid transparent;
outline-offset: 0;
}
.cf__row.is-conflict {
outline-color: #905347;
}
.cf__act {
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
color: #17141a;
}
.cf__bind {
display: flex;
align-items: center;
gap: 4px;
}
.cf__key {
position: relative;
display: grid;
place-items: center;
box-sizing: border-box;
height: 20px;
min-width: 20px;
padding: 0 4px;
border-radius: 4px;
border: 1px solid rgba(23, 20, 26, 0.24);
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font-family: inherit;
font-weight: 800;
font-size: 10px;
line-height: 1;
}
.cf__key > span {
grid-area: 1/1;
}
.cf__letterAlt {
visibility: hidden;
}
.cf__state {
display: grid;
place-items: center;
}
.cf__state > span {
grid-area: 1/1;
border-radius: 999px;
padding: 4px 8px;
font-weight: 800;
font-size: 10px;
line-height: 1;
visibility: hidden;
}
.cf__warn {
background: #905347;
color: #fff7e6;
}
.cf__ok {
background: rgba(23, 20, 26, 0.1);
color: #17141a;
}
.cf__row.is-conflict .cf__warn {
visibility: visible;
}
.cf__row.is-ok .cf__ok {
visibility: visible;
}
.cf__live {
margin: 0;
align-self: flex-start;
border-radius: 999px;
padding: 4px 12px;
background: #17141a;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.4;
color: #fff7e6;
visibility: hidden;
}
.cf.has-conflict .cf__live {
visibility: visible;
}
.cf__swap {
align-self: flex-start;
border: 0;
cursor: pointer;
background: #17141a;
color: #fff7e6;
border-radius: 999px;
padding: 4px 12px;
font-family: inherit;
font-weight: 800;
font-size: 10px;
line-height: 1.4;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__swap:active {
transform: translateY(2px);
}
.cf__swap:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 2px;
}
.cf__swap:disabled {
cursor: default;
background: rgba(23, 20, 26, 0.34);
}
.cf.is-demo .cf__row,
.cf.is-demo .cf__warn,
.cf.is-demo .cf__ok,
.cf.is-demo .cf__live,
.cf.is-demo .cf__row:nth-child(3) .cf__letterText,
.cf.is-demo .cf__letterAlt {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cf.is-demo .cf__row:nth-child(1),
.cf.is-demo .cf__row:nth-child(3) {
animation-name: cfOutline;
}
@keyframes cfOutline {
0%, 15% {
outline-color: transparent;
}
16%, 63% {
outline-color: #905347;
}
64%, 100% {
outline-color: transparent;
}
}
.cf.is-demo .cf__row:nth-child(1) .cf__warn,
.cf.is-demo .cf__row:nth-child(3) .cf__warn {
animation-name: cfWarnBadge;
}
@keyframes cfWarnBadge {
0%, 15% {
visibility: hidden;
}
16%, 63% {
visibility: visible;
}
64%, 100% {
visibility: hidden;
}
}
.cf.is-demo .cf__row:nth-child(1) .cf__ok,
.cf.is-demo .cf__row:nth-child(3) .cf__ok {
animation-name: cfOkBadge;
}
@keyframes cfOkBadge {
0%, 63% {
visibility: hidden;
}
64%, 100% {
visibility: visible;
}
}
.cf.is-demo .cf__row:nth-child(2) .cf__ok {
animation-name: cfOkAlways;
}
@keyframes cfOkAlways {
0%, 100% {
visibility: visible;
}
}
.cf.is-demo .cf__live {
animation-name: cfLive;
}
@keyframes cfLive {
0%, 15% {
visibility: hidden;
}
16%, 63% {
visibility: visible;
}
64%, 100% {
visibility: hidden;
}
}
.cf.is-demo .cf__row:nth-child(3) .cf__letterText {
animation-name: cfLetterOld;
}
.cf.is-demo .cf__letterAlt {
animation-name: cfLetterNew;
}
@keyframes cfLetterOld {
0%, 63% {
visibility: visible;
}
64%, 100% {
visibility: hidden;
}
}
@keyframes cfLetterNew {
0%, 63% {
visibility: hidden;
}
64%, 100% {
visibility: visible;
}
}
.cf.is-demo .cf__row:nth-child(3) .cf__bind {
animation-name: cfBindPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes cfBindPop {
0%, 10% {
transform: translateX(0);
}
16% {
transform: translateX(-4px);
}
24%, 58% {
transform: translateX(0);
}
64% {
transform: translateX(-4px);
}
72%, 100% {
transform: translateX(0);
}
}
.cf.is-demo .cf__swap {
animation-name: cfSwapState;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes cfSwapState {
0%, 63% {
background: #17141a;
}
64%, 100% {
background: rgba(23, 20, 26, 0.34);
}
}
@media (max-width: 340px) {
.cf {
gap: 4px;
}
.cf__row {
grid-template-columns: 1fr auto 40px;
gap: 4px;
padding: 0 4px;
min-height: 26px;
}
.cf__state > span {
padding: 0 4px;
}
.cf__cap {
padding: 0 8px;
}
.cf__live {
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf.is-demo .cf__row,
.cf.is-demo .cf__warn,
.cf.is-demo .cf__ok,
.cf.is-demo .cf__live,
.cf.is-demo .cf__letterText,
.cf.is-demo .cf__letterAlt,
.cf.is-demo .cf__bind,
.cf.is-demo .cf__swap {
animation: none !important;
}
.cf__swap {
transition: none !important;
}
}var cf = document.getElementById('cf');
var swap = document.getElementById('swap');
var lastLetter = document.getElementById('lastLetter');
var rows = Array.prototype.slice.call(document.querySelectorAll('.cf__row'));
function bindingOf(row) {
return 'Ctrl+' + row.querySelector('.cf__letterText').textContent;
}
// 조합마다 몇 줄이 쓰는지 세어, 두 줄 이상이면 그 줄을 충돌로 본다
function applyConflicts() {
var counts = new Map();
rows.forEach(function (row) {
var key = bindingOf(row);
counts.set(key, (counts.get(key) || 0) + 1);
});
var hit = 0;
rows.forEach(function (row) {
var dup = counts.get(bindingOf(row)) > 1;
row.classList.toggle('is-conflict', dup);
row.classList.toggle('is-ok', !dup);
if (dup) { hit = hit + 1; }
});
cf.classList.toggle('has-conflict', hit > 0);
swap.disabled = hit === 0;
}
swap.addEventListener('click', function () {
cf.classList.remove('is-demo');
lastLetter.textContent = 'J';
applyConflicts();
});
applyConflicts();// ConflictTable — vanilla 판(run/288/demo/07-shortcut-conflict-table/index.html) 의 React 포트.
// 같은 조합이 두 줄에 걸리면 그 줄에 경고 배지와 outline 이 붙고, 경고문은 aria-live 로 한 번 읽힌다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ConflictTable.module.scss";
export type ConflictTableProps = {
/** 격자 미리보기용 2초 자동 루프. 바꾸기 버튼을 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ACTIONS = ["명령 실행", "줄 복제", "빠른 열기"];
/** 조합마다 몇 줄이 쓰는지 세어, 두 줄 이상이면 그 줄을 충돌로 본다 */
function conflictFlags(letters: string[]): boolean[] {
const counts = new Map<string, number>();
letters.forEach((letter) => {
const key = "Ctrl+" + letter;
counts.set(key, (counts.get(key) || 0) + 1);
});
return letters.map((letter) => (counts.get("Ctrl+" + letter) || 0) > 1);
}
export default function ConflictTable({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: ConflictTableProps) {
const [live, setLive] = useState(demo);
const [letters, setLetters] = useState<string[]>(["K", "D", "K"]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const flags = conflictFlags(letters);
const hasConflict = flags.some(Boolean);
function applySwap() {
setLive(false);
setLetters(["K", "D", "J"]);
}
const cls = [styles.root, live ? styles.isDemo : "", hasConflict ? styles.hasConflict : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={cls}>
<p className={styles.cap}>단축키 목록</p>
<div className={styles.table} role="table" aria-label="단축키 목록">
{ACTIONS.map((action, i) => (
<div
className={[styles.row, flags[i] ? styles.isConflict : styles.isOk].join(" ")}
role="row"
key={action}
>
<span className={styles.act} role="cell">{action}</span>
<span className={styles.bind} role="cell">
<kbd className={styles.key}>Ctrl</kbd>
<kbd className={styles.key}>
<span className={styles.letterText}>{letters[i]}</span>
{i === 2 ? <span className={styles.letterAlt} aria-hidden="true">J</span> : null}
</kbd>
</span>
<span className={styles.state} role="cell">
<span className={styles.warn}>충돌</span>
<span className={styles.ok}>확인</span>
</span>
</div>
))}
</div>
<p className={styles.live} aria-live="polite">같은 조합이 두 줄에 있습니다</p>
<button type="button" className={styles.swap} disabled={!hasConflict} onClick={applySwap}>
마지막 줄을 Ctrl+J 로
</button>
</div>
</main>
);
}07 충돌 감지 표 — 측정 누적면적 7.233% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 키보드 단축키 UI 9종 — 키캡부터 도움말 시트까지
이 데모가 속한 글
키보드 단축키 UI 9종 — 키캡부터 도움말 시트까지단축키 UI는 키보드로 화면을 다루게 하는 키캡·표시·안내를 짜는 일입니다. 여기 아홉 가지는 키캡 세 스타일부터 플랫폼 자동 전환·녹화·충돌 표·도움말 시트까지 CSS 와 약간의 JS 로 만들어 둔 것이라, 붙여 이 글의 데모 전부 보기


