GODRICH

css 검색창 디자인 9종 — Cmd+K로 열리는 팔레트

css 검색창 디자인 가운데 커맨드 팔레트는 화면 위에 떠서 명령과 문서를 한 칸에서 찾게 하는 오버레이입니다. 여기 아홉 개 중 하나는 결과가 하나도 없을 때 오히려 줄이 늘어나고, 다른 하나는 목록 없이 입력칸만 먼저 띄웁니다.

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

아홉 개의 순서는 인기순이 아니라 사용자가 팔레트를 쓰는 시간 순서입니다. 단축키로 열자마자 아직 아무것도 안 쳤을 때 무엇을 보여 줄지(01)가 먼저고, 그다음이 치기 시작했을 때의 매칭 방식(02)입니다. 목록이 상자보다 길어지면 스크롤이 커서를 따라가야 하고(03), 줄 오른쪽 끝에는 다음번엔 팔레트 없이도 쓰라고 단축키 배지가 붙습니다(04). 명령 하나로 안 끝나면 한 단계 들어가고(05), 이름만으로 못 고르면 미리보기가 붙습니다(06). 07은 검색이 빗나갔을 때, 08과 09는 같은 팔레트를 다른 화면 크기에 맞춰 다시 배치한 경우입니다. 아홉 개 전부 role="dialog"role="listbox"를 달고 화살표·Enter·Escape가 실제로 듣습니다.

01열자마자 뜨는 최근 항목

단축키로 열면 아직 한 글자도 치지 않았는데 「최근」과 「이동」 머리글 아래 지난번에 쓴 명령이 먼저 서 있습니다. 묶음은 role="group"aria-labelledby로 묶어 스크린 리더도 머리글을 줄 목록의 이름으로 읽습니다. 여닫는 단축키는 맥의 ⌘와 윈도의 Ctrl을 둘 다 봅니다.

role=listboxrole=grouparia-labelledby
document.addEventListener('keydown', function (e) {
  var k = e.key.toLowerCase();
  if ((e.metaKey || e.ctrlKey) && k === 'k') {
    live();
    pal.classList.toggle('is-shut');
    if (!pal.classList.contains('is-shut')) input.focus({ preventScroll: true });
    e.preventDefault();
    return;
  }
  if (pal.classList.contains('is-shut')) return;
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
    live(); mark(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
  } else if (e.key === 'Enter' && opts[at]) {
    live();
    opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opts[at])); });
    opts[at].classList.add('is-run');
    e.preventDefault();
  } else if (e.key === 'Escape') {
    live(); pal.classList.add('is-shut');
  }
});

02띄엄띄엄 쳐도 걸리는 검색

글자를 이어서 치지 않고 건너뛰어 쳐도 순서만 맞으면 걸립니다. 찾는 방식이 부분 문자열이 아니라 부분 수열이라서, 질의의 글자를 하나씩 indexOf(c, from)으로 뒤로만 밀어 가며 찾고 하나라도 못 찾으면 그 줄을 뺍니다. 맞은 자리는 구간이 아니라 글자 하나씩 <mark>로 감싸야 띄엄띄엄 걸렸다는 게 눈에 보입니다.

markaria-activedescendantopacity
function hits(name, q) {
  var lower = name.toLowerCase(), need = q.toLowerCase();
  var idx = [], i = 0;
  for (var c = 0; c < need.length; c++) {
    var k = lower.indexOf(need[c], i);
    if (k < 0) return null;
    idx.push(k); i = k + 1;
  }
  return idx;
}
function paint(name, idx) {
  var html = '', last = 0;
  idx.forEach(function (k) {
    html += name.slice(last, k) + '<mark class="pal__m is-on">' + name[k] + '</mark>';
    last = k + 1;
  });
  return html + name.slice(last);
}

03커서를 따라가는 스크롤

일곱 줄이 들어 있지만 상자는 네 줄 높이만 보여 주고, 커서가 아래 끝에 닿는 순간부터 목록이 한 줄씩 밀려 올라갑니다. 이 일을 하는 건 scrollIntoViewblock: 'nearest' 한 줄이고, 이미 보이는 줄에서는 아무 일도 하지 않기 때문에 화면이 덜컥거리지 않습니다. Home과 End도 받아 양 끝으로 한 번에 갑니다.

scrollIntoViewblock: nearesttranslateY
function move(i) {
  pal.classList.remove('is-demo');
  at = Math.max(0, Math.min(i, opts.length - 1));
  opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
  input.setAttribute('aria-activedescendant', opts[at].id);
  // block: 'nearest' 라서 이미 보이는 줄이면 스크롤이 아예 일어나지 않는다
  opts[at].scrollIntoView({ block: 'nearest' });
}

04오른쪽 끝에 줄 맞춘 단축키 배지

명령 이름이 두 글자든 여섯 글자든 키캡 배지는 오른쪽 끝에서 한 줄로 맞습니다. 줄을 맞추는 건 이름 쪽에 준 flex가 아니라 배지 묶음에 준 margin-left: auto이고, 남는 가로 자리를 그 마진이 통째로 먹습니다. 배지에 적힌 조합을 실제로 치면 그 줄이 실행되므로 치트시트가 그림이 아닙니다.

margin-left: autokbdtranslateY
// 이름 길이와 무관하게 배지는 늘 오른쪽 끝 — 남는 자리를 auto 마진이 통째로 먹는다
.pal__keys { margin-left: auto; display: inline-flex; gap: $sp-1; }
.pal__kbd {
  box-sizing: border-box;
  min-width: 16px; height: 16px;
  display: inline-flex; align-items: center; justify-content: center;
  padding: 0 $sp-1;
  border-radius: $r-xs;
  background: rgba(255, 247, 230, .14);
  box-shadow: 0 1px 0 rgba(255, 247, 230, .28);
  font-family: inherit;
  font-size: 9px; font-weight: 800;
  color: rgba(255, 247, 230, .72);
}

05한 단계 들어가는 브레드크럼

첫 줄을 고르면 입력칸 앞에 알약 칩이 하나 생기고 목록이 그 명령의 대상 목록으로 갈립니다. 돌아오는 길은 뒤로 가기 버튼이 아니라 빈 입력칸에서의 Backspace라서, 손이 입력칸을 떠나지 않습니다. 지금 어느 단계인지는 화면에서 숨긴 aria-live="polite" 문단이 읽어 줍니다.

BackspacetranslateXaria-live
document.addEventListener('keydown', function (e) {
  var r = rows();
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
    pal.classList.remove('is-demo'); move(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
  } else if (e.key === 'Enter' && r[at]) {
    if (!deep && at === 0) { deep = true; paint(); } else { r[at].setAttribute('aria-selected', 'true'); }
    e.preventDefault();
  } else if (e.key === 'Backspace' && deep && !input.value) {
    deep = false; paint(); input.focus({ preventScroll: true }); e.preventDefault();
  } else if (e.key === 'Escape') {
    deep = false; paint(); input.value = ''; input.focus({ preventScroll: true });
  }
});

06오른쪽에 붙는 미리보기 패널

커서가 선 줄의 내용이 오른쪽 반쪽에 바로 뜨고, 줄이 바뀔 때마다 패널 안쪽만 통째로 갈립니다. 왼쪽 목록과 오른쪽 패널은 grid-template-columns: 1fr 104px 한 줄로 나눴고, 입력칸에 aria-describedby를 걸어 지금 보이는 미리보기가 무엇인지도 읽히게 했습니다. 교체는 서서히 섞는 대신 한 프레임에 끊습니다.

grid-template-columnssteps(1, end)aria-describedby
function show(i) {
  pal.classList.remove('is-demo');
  at = Math.max(0, Math.min(i, opts.length - 1));
  opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
  pvs.forEach(function (p, k) { p.classList.toggle('is-on', k === at); });
  input.setAttribute('aria-activedescendant', opts[at].id);
}

07빈손으로 끝나지 않는 결과 없음

맞는 줄이 하나도 없으면 안내 한 줄만 남기지 않고, 방금 친 글자를 그대로 담은 줄 두 개가 대신 올라옵니다. 두 줄에도 role="option"이 달려 있어 화살표와 Enter가 그대로 듣습니다. 안내문과 선택지는 여기서 서로 다른 물건입니다.

hiddenrole=optionopacity
function render(q) {
  pal.classList.remove('is-demo');
  var shown = 0;
  rows.forEach(function (o, i) {
    var hit = !q || names[i].indexOf(q) >= 0;
    o.hidden = !hit;
    if (hit) shown++;
  });
  empty.hidden = !(q && shown === 0);
  // 제안 줄에 방금 친 글자를 그대로 넣는다 — "그 이름으로" 가 이 상태의 값이다
  document.querySelectorAll('.pal__q').forEach(function (s) { s.textContent = q; });
  at = -1;
}

08화면 한가운데 큰 입력칸

처음에는 큰 입력칸 하나만 떠 있다가 글자가 들어오는 순간 아래로 결과가 펼쳐집니다. 펼치는 값은 높이도 scaleY도 아닌 clip-pathinset 아래쪽 값이고, 패널이 차지할 84px 자리는 접혀 있을 때도 그대로 잡아 둡니다. 그래서 펼쳐질 때 아래 내용이 밀리지 않습니다.

steps(1, end)clip-path: inset()$ease-spring
.sp__panel {
  position: relative;
  box-sizing: border-box;
  height: 84px;
  margin: $sp-2 0 0;
  clip-path: inset(0 0 100% 0 round 16px);
}
.sp.is-open .sp__panel { clip-path: inset(0 0 0 0 round 16px); }

09아래에서 올라오는 시트형 팔레트

폰 폭에서는 가운데 모달 대신 시트가 아래에서 올라와 화면 바닥에 붙습니다. 위 모서리만 둥글리고 맨 위 손잡이 막대는 장식이 아니라 aria-label이 붙은 진짜 닫기 버튼입니다. 아래 패딩에 env(safe-area-inset-bottom)을 더해 두었기 때문에, 노치 화면에서도 마지막 줄이 홈 인디케이터에 걸리지 않습니다.

translateYrole=dialog$r-card
.ph__sheet {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  box-sizing: border-box;
  padding: $sp-2 $sp-2 calc(#{$sp-3} + env(safe-area-inset-bottom));
  border-radius: $r-card $r-card 0 0;
  background: #fff;
  box-shadow: $shadow-raised;
  transform: translateY(100%);
}
.ph.is-open .ph__sheet { transform: translateY(0); }

어디서 깨지는가 — 함정

이 편에서 제일 오래 잡고 있던 건 애니메이션이 아니라 대표 이미지로 잘려 나가는 한 프레임이었습니다. 여기 쓰는 렌더 도구는 2초 루프를 24장으로 뜯은 뒤 앞 장과 가장 많이 달라진 장을 대표 이미지로 고릅니다. 06번은 커서가 줄 사이를 지나는 동안 오른쪽 패널이 바뀌도록 짜여 있었는데, 하필 그 교체 순간이 가장 크게 달라진 장이라 커서는 둘째 줄에 있고 패널은 셋째 줄 내용인 장면이 대표 이미지로 뽑혔습니다. 고친 방법은 두 가지였습니다. 먼저 커서가 줄에 닿아 선 뒤에 패널이 바뀌도록 시점을 어긋나게 두고, 미리보기마다 바탕색까지 다르게 줘서 교체 순간이 확실히 가장 큰 변화가 되게 했습니다. 여기서 하나 더 걸렸는데, 24장의 마지막 장이 2초의 95.8% 지점이라 96%에 넣어 둔 「처음으로 되돌아오는 교체」는 아예 촬영되지 않았습니다. 되돌리는 시점을 90%로 당기고 나서야 한 바퀴가 끊기지 않았습니다.

둘째 함정은 입력칸의 글자 두 겹입니다. 02·07·08번은 자동 재생에서 「사람이 치고 있는 글자」를 보여 주려고 입력칸 위에 글자를 한 겹 얹었는데, 그 아래 placeholder가 그대로 남아 두 글자가 겹쳐 보였습니다. 「월간 리포트」와 「명령 검색」이 겹쳐 읽을 수 없는 낱말이 된 화면이 그대로 대표 이미지가 됐습니다. 자동 재생 동안에만 ::placeholder를 투명으로 두면 끝나는 일이었지만, 캡처를 열어 보기 전까지는 아무 게이트도 이걸 잡아 주지 않았습니다.

셋째는 폰 폭입니다. 01번은 입력칸 하나에 머리글 둘, 줄 넷이라 세로가 186px이 나왔는데, 320×200 화면에서 무대 여백을 빼면 쓸 수 있는 높이는 174px입니다. 12px 모자라서 폰에서는 마지막 줄이 잘렸습니다. 줄 높이를 24px에서 22px로, 머리글은 line-height를 12px로 고정하고, 카드의 세로 패딩만 8px에서 4px로 줄여 164px로 맞췄습니다. 가로 패딩은 그대로 뒀기 때문에 넓은 화면에서의 인상은 바뀌지 않았습니다. 이 세 가지를 다 반영한 소스는 압축 비밀번호 vswx5meq로 열리는 zip 안에 아홉 벌 그대로 들어 있습니다.

넷째는 08번에서 나왔습니다. 결과 패널을 접을 때 처음에는 scaleY(0)을 썼는데, 위 모서리를 축으로 줄이면 그 안의 글자까지 같이 눌려 납작해집니다. 절반쯤 접힌 순간의 글자가 세로로 찌그러진 채 남아 있어서, 접는 동작이 아니라 화면이 망가진 것처럼 보였습니다. clip-path: inset()은 요소를 그린 다음 잘라내기만 하므로 글자 비율이 그대로입니다. 대신 overflow: hidden과 달리 둥근 모서리를 따로 적어 줘야 해서 round 16px를 붙였습니다.

접근성

아홉 개 모두 초점은 입력칸에 붙여 둔 채로 움직입니다. 화살표를 눌러도 focus()가 줄로 옮겨 가지 않고, 지금 가리키는 줄은 입력칸의 aria-activedescendant가 바뀌면서 알려집니다. 이렇게 하는 이유는 입력칸이 계속 글자를 받아야 하기 때문이고, 이 패턴의 정의와 요구 사항은 MDN의 combobox 역할 문서에 정리돼 있습니다. 오버레이 자체에는 role="dialog"aria-modal="true"가 붙어 뒤 화면이 아니라 이 상자가 지금의 맥락임을 알립니다.

아홉 개에서 하는 일
⌘K · Ctrl+K 팔레트 열고 닫기 (01·09). 맥과 윈도를 한 번에 받으려면 metaKeyctrlKey를 둘 다 본다
↑ ↓ 줄 이동. 03은 여기서 scrollIntoView({ block: 'nearest' })가 함께 돈다
Enter 선 줄 실행. aria-selected가 그 줄로 옮겨 간다
Escape 닫기 또는 입력 비우기. 초점은 focus({ preventScroll: true })로 돌려준다
Backspace 05에서만 특별하다 — 입력칸이 비어 있으면 글자가 아니라 한 단계를 지운다

prefers-reduced-motion: reduce에서는 자동 재생 루프만 멈추고 지금 상태는 그대로 남깁니다. 05의 칩은 미끄러지지 않고 바로 자리에 서고, 08의 패널은 잘려 나가는 과정 없이 펼쳐진 채로 나타나며, 09의 시트는 올라오는 길 없이 붙은 상태로 있습니다. 어느 줄이 선택돼 있는지는 움직임을 다 꺼도 aria-selected가 계속 말해 줍니다. 초점을 되돌릴 때 preventScroll을 붙이는 이유는 이 데모들이 글 안에 iframe으로 들어 있어서, 옵션 없이 focus()를 부르면 읽던 사람의 화면이 데모 쪽으로 끌려가기 때문입니다.

같은 화면에서 함께 쓰는 부품은 대시보드 카테고리에, 눌러서 여는 부품은 클릭 카테고리에 모아 두었습니다.

FAQ

검색창 하나에 왜 role="dialog"까지 붙이나요?

떠 있는 상자가 뒤 화면을 가리는 순간부터 그것은 입력칸이 아니라 화면 하나이기 때문입니다. role="dialog"aria-modal="true"가 없으면 스크린 리더는 뒤 화면의 링크와 버튼을 계속 읽을 수 있는 것으로 안내하고, 사용자는 자기가 어디에 있는지 알 수 없게 됩니다. 반대로 페이지 안에 그대로 앉아 아무것도 가리지 않는 검색칸이라면 dialog 없이 combobox만으로도 충분합니다.

부분 수열 검색이 부분 문자열보다 항상 나은가요?

목록이 짧으면 오히려 나쁩니다. 건너뛰며 맞추는 방식은 걸리는 줄이 많아지므로, 선택지가 열 개 안쪽이면 엉뚱한 줄이 위에 오는 일이 잦습니다. 02번처럼 쓰려면 걸린 자리의 개수와 간격으로 점수를 매겨 정렬까지 해 주는 편이 낫고, 이 편의 02번은 정렬 없이 순서만 지킨 최소 구현입니다.

아홉 개를 한 화면에 같이 넣어도 되나요?

단축키가 겹치지 않는지만 보면 됩니다. 01번과 09번이 같은 ⌘K를 듣고 있어서 그대로 두 개를 올리면 한 번 눌렀을 때 둘 다 열립니다. zip 안에서는 데모마다 따로 돌게 파일이 나뉘어 있으니, 실제 화면에 얹을 때는 단축키를 듣는 곳을 하나로 모으고 나머지는 그 하나가 열어 주는 방식으로 바꾸는 것이 안전합니다.

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

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