06Tab 자동완성 후보
Tab 을 누르면 preventDefault 로 초점 이동을 막고 후보를 격자로 펼칩니다. 화살표로 고르는 동안 aria-activedescendant 가 지금 후보를 가리켜 스크린리더도 같은 줄을 읽습니다.
쓰임웹 기반 셸·SQL 콘솔의 입력칸, 명령 검색 상자
preventDefaultaria-activedescendantgrid-template-columns

<main class="stage">
<div class="tc is-demo" id="tc">
<p class="tc__line">
<span class="tc__sign" aria-hidden="true">$</span>
<input class="tc__input" id="cmd" type="text" value="git check" autocomplete="off" spellcheck="false"
role="combobox" aria-expanded="false" aria-controls="cands" aria-label="명령 입력칸. Tab 을 누르면 후보가 열립니다">
</p>
<p class="tc__hint">Tab 을 누르면 후보가 열립니다</p>
<ul class="tc__cands" id="cands" role="listbox" aria-label="완성 후보">
<li class="tc__cand" id="c0" role="option" aria-selected="true" style="--i: 0">checkout</li>
<li class="tc__cand" id="c1" role="option" aria-selected="false" style="--i: 1">check-attr</li>
<li class="tc__cand" id="c2" role="option" aria-selected="false" style="--i: 2">check-ignore</li>
<li class="tc__cand" id="c3" role="option" aria-selected="false" style="--i: 3">check-mailmap</li>
<li class="tc__cand" id="c4" role="option" aria-selected="false" style="--i: 4">check-ref-format</li>
<li class="tc__cand" id="c5" role="option" aria-selected="false" style="--i: 5">checkout-index</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// Tab 자동완성 후보 — Tab 을 누르면 preventDefault 로 초점 이동을 막고 후보를 격자로 펼친다.
// 후보 목록은 절대배치가 아니라 흐름 안에 둔다 — 띄우면 부모 높이 계산에서 빠져 320px 에서
// scrollHeight 만 넘치는 함정에 걸린다 (237 실측 9개 중 6개).
// 고른 후보는 색뿐 아니라 aria-selected 와 왼쪽 막대로도 표시하고, 입력칸의
// aria-activedescendant 가 그 id 를 가리켜 스크린리더가 같은 줄을 읽는다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $chart-3;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$dim: rgba(255, 255, 255, .6);
@include stage($stage-ink);
.tc {
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 408px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .05); font-family: $mono;
}
.tc__line { display: flex; align-items: center; gap: $sp-2; margin: 0; }
.tc__sign { flex: 0 0 auto; color: $color; font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1.4; }
.tc__input {
flex: 1 1 auto; min-width: 0; box-sizing: border-box; padding: $sp-1 $sp-2;
border: 1px solid transparent; border-radius: $r-sm;
background: rgba(255, 255, 255, .06); color: $subject-cream;
font-family: inherit; font-weight: 600; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.4;
}
.tc__input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 1px; }
.tc__hint { margin: 0; color: $dim; font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.3; }
.tc.is-open .tc__hint { visibility: hidden; }
.tc__cands {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: $sp-1 $sp-2;
margin: 0; padding: 0; list-style: none; visibility: hidden;
}
.tc.is-open .tc__cands { visibility: visible; }
.tc__cand {
position: relative; box-sizing: border-box; padding: $sp-1 $sp-2;
border-radius: $r-xs; color: $dim; cursor: pointer;
font-weight: 600; font-size: clamp(9px, 2.6vw, 11px); line-height: 1.5;
white-space: nowrap; overflow: hidden;
}
// 고른 후보의 왼쪽 막대 — 칸 왼쪽 변에 붙은 표시라 그쪽을 원점으로 쓴다 (blog-demo 규칙 8 의 예외)
.tc__cand::before {
content: ""; position: absolute; left: 0; top: 2px; bottom: 2px; width: 2px;
border-radius: $r-pill; background: $color; transform: scaleY(0); transform-origin: center top;
transition: transform $duration $easing;
}
.tc__cand[aria-selected="true"] { background: rgba(255, 255, 255, .1); color: $subject-cream; }
.tc__cand[aria-selected="true"]::before { transform: scaleY(1); }
// ── 격자 미리보기 루프 (2초)
// 0~16% 닫힌 채로 「git ch」, 17% 에 후보가 컷으로 열리며 여섯 칸이 --i 만큼 늦게 내려앉는다.
// 고름은 40%·68% 에 컷으로 옮겨 간다 — 반투명 교차를 만들지 않으려고 페이드 대신 컷이다 (237 실측)
.tc.is-demo .tc__cands,
.tc.is-demo .tc__hint {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.tc.is-demo .tc__cands { animation-name: tcOpen; }
.tc.is-demo .tc__hint { animation-name: tcHint; }
@keyframes tcOpen {
0%, 16% { visibility: hidden; }
17%, 100% { visibility: visible; }
}
@keyframes tcHint {
0%, 16% { visibility: visible; }
17%, 100% { visibility: hidden; }
}
// 칸이 내려앉는 건 transform 뿐이다 — opacity 로 띄우면 반투명 중간 프레임이 poster 로 뽑힌다
.tc.is-demo .tc__cand {
animation-name: tcDrop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 40ms);
}
@keyframes tcDrop {
0%, 17% { transform: translateY(-6px); }
27%, 100% { transform: translateY(0); }
}
.tc.is-demo .tc__cand:nth-child(1) { animation-name: tcDrop, tcPick1; }
.tc.is-demo .tc__cand:nth-child(2) { animation-name: tcDrop, tcPick2; }
.tc.is-demo .tc__cand:nth-child(4) { animation-name: tcDrop, tcPick3; }
.tc.is-demo .tc__cand:nth-child(1),
.tc.is-demo .tc__cand:nth-child(2),
.tc.is-demo .tc__cand:nth-child(4) {
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
animation-delay: calc(var(--i) * 40ms), 0s;
}
@keyframes tcPick1 {
0%, 39% { background: rgba(255, 255, 255, .1); color: $subject-cream; }
40%, 100% { background: transparent; color: $dim; }
}
@keyframes tcPick2 {
0%, 39% { background: transparent; color: $dim; }
40%, 67% { background: rgba(255, 255, 255, .1); color: $subject-cream; }
68%, 100% { background: transparent; color: $dim; }
}
@keyframes tcPick3 {
0%, 67% { background: transparent; color: $dim; }
68%, 100% { background: rgba(255, 255, 255, .1); color: $subject-cream; }
}
// 왼쪽 막대는 연속 보간이라 steps 짝과 한 요소에 섞지 않고 따로 건다 (266-09 실측)
.tc.is-demo .tc__cand:nth-child(1)::before { animation-name: tcMark1; }
.tc.is-demo .tc__cand:nth-child(2)::before { animation-name: tcMark2; }
.tc.is-demo .tc__cand:nth-child(4)::before { animation-name: tcMark3; }
.tc.is-demo .tc__cand::before {
animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes tcMark1 {
0%, 17% { transform: scaleY(0); }
27%, 39% { transform: scaleY(1); }
40%, 100% { transform: scaleY(0); }
}
@keyframes tcMark2 {
0%, 40% { transform: scaleY(0); }
50%, 67% { transform: scaleY(1); }
68%, 100% { transform: scaleY(0); }
}
@keyframes tcMark3 {
0%, 68% { transform: scaleY(0); }
78%, 100% { transform: scaleY(1); }
}
@media (max-width: 340px) {
.tc { padding: $sp-2; gap: $sp-1; }
.tc__cands { gap: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.tc.is-demo .tc__cands,
.tc.is-demo .tc__hint,
.tc.is-demo .tc__cand,
.tc.is-demo .tc__cand::before { animation: none !important; }
.tc.is-demo .tc__cands { visibility: visible !important; }
.tc.is-demo .tc__hint { visibility: hidden !important; }
.tc__cand::before { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tc {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 408px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
}
.tc__line {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
}
.tc__sign {
flex: 0 0 auto;
color: #2b9d74;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.4;
}
.tc__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
padding: 4px 8px;
border: 1px solid transparent;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
color: #fff7e6;
font-family: inherit;
font-weight: 600;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.4;
}
.tc__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.tc__hint {
margin: 0;
color: rgba(255, 255, 255, 0.6);
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.3;
}
.tc.is-open .tc__hint {
visibility: hidden;
}
.tc__cands {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 8px;
margin: 0;
padding: 0;
list-style: none;
visibility: hidden;
}
.tc.is-open .tc__cands {
visibility: visible;
}
.tc__cand {
position: relative;
box-sizing: border-box;
padding: 4px 8px;
border-radius: 4px;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-weight: 600;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
}
.tc__cand::before {
content: "";
position: absolute;
left: 0;
top: 2px;
bottom: 2px;
width: 2px;
border-radius: 999px;
background: #2b9d74;
transform: scaleY(0);
transform-origin: center top;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tc__cand[aria-selected=true] {
background: rgba(255, 255, 255, 0.1);
color: #fff7e6;
}
.tc__cand[aria-selected=true]::before {
transform: scaleY(1);
}
.tc.is-demo .tc__cands,
.tc.is-demo .tc__hint {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tc.is-demo .tc__cands {
animation-name: tcOpen;
}
.tc.is-demo .tc__hint {
animation-name: tcHint;
}
@keyframes tcOpen {
0%, 16% {
visibility: hidden;
}
17%, 100% {
visibility: visible;
}
}
@keyframes tcHint {
0%, 16% {
visibility: visible;
}
17%, 100% {
visibility: hidden;
}
}
.tc.is-demo .tc__cand {
animation-name: tcDrop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 40ms);
}
@keyframes tcDrop {
0%, 17% {
transform: translateY(-6px);
}
27%, 100% {
transform: translateY(0);
}
}
.tc.is-demo .tc__cand:nth-child(1) {
animation-name: tcDrop, tcPick1;
}
.tc.is-demo .tc__cand:nth-child(2) {
animation-name: tcDrop, tcPick2;
}
.tc.is-demo .tc__cand:nth-child(4) {
animation-name: tcDrop, tcPick3;
}
.tc.is-demo .tc__cand:nth-child(1),
.tc.is-demo .tc__cand:nth-child(2),
.tc.is-demo .tc__cand:nth-child(4) {
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
animation-delay: calc(var(--i) * 40ms), 0s;
}
@keyframes tcPick1 {
0%, 39% {
background: rgba(255, 255, 255, 0.1);
color: #fff7e6;
}
40%, 100% {
background: transparent;
color: rgba(255, 255, 255, 0.6);
}
}
@keyframes tcPick2 {
0%, 39% {
background: transparent;
color: rgba(255, 255, 255, 0.6);
}
40%, 67% {
background: rgba(255, 255, 255, 0.1);
color: #fff7e6;
}
68%, 100% {
background: transparent;
color: rgba(255, 255, 255, 0.6);
}
}
@keyframes tcPick3 {
0%, 67% {
background: transparent;
color: rgba(255, 255, 255, 0.6);
}
68%, 100% {
background: rgba(255, 255, 255, 0.1);
color: #fff7e6;
}
}
.tc.is-demo .tc__cand:nth-child(1)::before {
animation-name: tcMark1;
}
.tc.is-demo .tc__cand:nth-child(2)::before {
animation-name: tcMark2;
}
.tc.is-demo .tc__cand:nth-child(4)::before {
animation-name: tcMark3;
}
.tc.is-demo .tc__cand::before {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes tcMark1 {
0%, 17% {
transform: scaleY(0);
}
27%, 39% {
transform: scaleY(1);
}
40%, 100% {
transform: scaleY(0);
}
}
@keyframes tcMark2 {
0%, 40% {
transform: scaleY(0);
}
50%, 67% {
transform: scaleY(1);
}
68%, 100% {
transform: scaleY(0);
}
}
@keyframes tcMark3 {
0%, 68% {
transform: scaleY(0);
}
78%, 100% {
transform: scaleY(1);
}
}
@media (max-width: 340px) {
.tc {
padding: 8px;
gap: 4px;
}
.tc__cands {
gap: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.tc.is-demo .tc__cands,
.tc.is-demo .tc__hint,
.tc.is-demo .tc__cand,
.tc.is-demo .tc__cand::before {
animation: none !important;
}
.tc.is-demo .tc__cands {
visibility: visible !important;
}
.tc.is-demo .tc__hint {
visibility: hidden !important;
}
.tc__cand::before {
transition: none !important;
}
}var tc = document.getElementById('tc');
var cmd = document.getElementById('cmd');
var cands = Array.prototype.slice.call(document.querySelectorAll('.tc__cand'));
var open = false, at = 0;
function mark(index) {
at = (index + cands.length) % cands.length;
cands.forEach(function (li, i) { li.setAttribute('aria-selected', String(i === at)); });
// 지금 고른 후보를 입력칸이 가리켜야 스크린리더가 같은 줄을 읽는다
cmd.setAttribute('aria-activedescendant', cands[at].id);
}
function openList() {
tc.classList.remove('is-demo');
open = true;
tc.classList.add('is-open');
cmd.setAttribute('aria-expanded', 'true');
// 후보가 모두 나누어 가진 앞글자까지는 셸이 대신 채워 준다
if (cmd.value.indexOf('git check') !== 0) { cmd.value = 'git check'; }
mark(0);
}
cmd.addEventListener('keydown', function (e) {
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); openList(); return; }
if (!open) { return; }
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); mark(at + 1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); mark(at - 1); }
else if (e.key === 'Enter') {
e.preventDefault();
cmd.value = 'git ' + cands[at].textContent;
open = false;
tc.classList.remove('is-open');
cmd.setAttribute('aria-expanded', 'false');
cmd.removeAttribute('aria-activedescendant');
}
});
cands.forEach(function (li, i) {
li.addEventListener('click', function () { openList(); mark(i); cmd.focus({ preventScroll: true }); });
});// TabCompletion — vanilla 판(run/294/demo/06-terminal-tab-completion/index.html) 의 React 포트.
// Tab 을 누르면 preventDefault 로 초점 이동을 막고 후보를 격자로 펼친다.
// 고른 후보는 색뿐 아니라 aria-selected 와 왼쪽 막대로도 표시하고, 입력칸의
// aria-activedescendant 가 그 id 를 가리켜 스크린리더가 같은 줄을 읽는다.
import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TabCompletion.module.scss";
export type TabCompletionProps = {
/** 격자 미리보기용 2초 자동 루프. 칸에서 Tab 을 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type IndexVar = CSSProperties & { "--i"?: number };
/** React 이벤트 제네릭이 ambient 에 없어 쓰는 필드만 지역 타입으로 선언한다 */
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
type InputEvt = { target: { value: string } };
type FocusBox = { focus(options: { preventScroll: boolean }): void };
const CANDS = ["checkout", "check-attr", "check-ignore", "check-mailmap", "check-ref-format", "checkout-index"];
/** 후보가 모두 나누어 가진 앞글자 — 셸이 여기까지는 대신 채워 준다 */
const STEM = "git check";
export default function TabCompletion({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2b9d74",
}: TabCompletionProps) {
const [live, setLive] = useState(demo);
const [open, setOpen] = useState(false);
const [at, setAt] = useState(0);
const [value, setValue] = useState(STEM);
const inputRef = useRef<FocusBox>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function mark(index: number) {
setAt((index + CANDS.length) % CANDS.length);
}
function openList() {
setLive(false);
setOpen(true);
if (value.indexOf(STEM) !== 0) { setValue(STEM); }
mark(0);
}
function handleKeyDown(e: KeyEvt) {
if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); openList(); return; }
if (!open) { return; }
if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); mark(at + 1); }
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); mark(at - 1); }
else if (e.key === "Enter") {
e.preventDefault();
setValue(`git ${CANDS[at]}`);
setOpen(false);
}
}
function handleCand(index: number) {
openList();
mark(index);
const input = inputRef.current;
if (input) { input.focus({ preventScroll: true }); }
}
const rootCls = [styles.tc, live ? styles.isDemo : "", open ? styles.isOpen : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={rootCls}>
<p className={styles.line}>
<span className={styles.sign} aria-hidden="true">$</span>
<input
className={styles.input}
ref={inputRef}
type="text"
value={value}
onChange={(e: InputEvt) => { setValue(e.target.value); }}
onKeyDown={handleKeyDown}
autoComplete="off"
spellCheck={false}
role="combobox"
aria-expanded={open ? "true" : "false"}
aria-controls="cands"
aria-activedescendant={open ? `c${at}` : undefined}
aria-label="명령 입력칸. Tab 을 누르면 후보가 열립니다"
/>
</p>
<p className={styles.hint}>Tab 을 누르면 후보가 열립니다</p>
<ul className={styles.cands} id="cands" role="listbox" aria-label="완성 후보">
{CANDS.map((cand, i) => {
const iv: IndexVar = { "--i": i };
return (
<li
className={styles.cand}
id={`c${i}`}
key={cand}
role="option"
aria-selected={i === at ? "true" : "false"}
style={iv}
onClick={() => { handleCand(i); }}
>
{cand}
</li>
);
})}
</ul>
</div>
</main>
);
}06 Tab 자동완성 후보 — 측정 누적면적 11.728% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 터미널 UI 9종 — 설치 명령 상자부터 튜토리얼 창까지
이 데모가 속한 글
터미널 UI 9종 — 설치 명령 상자부터 튜토리얼 창까지터미널 UI는 웹 화면 안에 검은 명령창처럼 보이도록 짜 넣는 조각입니다. 여기 아홉 가지는 설치 명령 복사 상자부터 로그 창·문자 진행 막대·크기를 바꾸는 창·튜토리얼 창까지, 붙여 넣으면 바로 도는 형태로 만들어 이 글의 데모 전부 보기


