본문 바로가기
C.W.K.
Stream
Lesson 02 of 05 · published

Popup 디자인 패턴 — 작고, 빠르고, single-purpose

~12 min · popup, ux, accessibility, performance

Level 0Extension 입덕
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"popup 한테 주어진 시간은 200 밀리초야. 그 안에 쓸모 있어 보이지 않으면 user는 이미 다른 데로 가 있어. 이 lesson은 그 제약이 크기와 focus와 그리는 순서에 실제로 뭘 요구하는지, 그리고 대충 만든 popup이 신뢰를 잃는 여섯 가지 길에 대한 얘기야."

Size window

Chrome이 popup을 content에 auto-size, bound 안에서:

  • 너비: 최대 800 px까지 되는데 실제로 좋은 구간은 280–360 px 야. 그보다 넓어지면 popup이 아니라 잘못 뜬 tab 처럼 느껴져.
  • Height: Chrome이 scroll 하기 전 max ~600 px. 13" laptop screen에 scroll 없이 맞도록 480 px 아래 유지 시도.
  • Padding: 12–16 px generous. 8 px comfortable. 8 보다 작으면 요소 답답.

Popup이 content load 시 jitter 안 하도록 body에 명시적 dimension 설정:

body { width: 320px; min-height: 200px; padding: 12px; box-sizing: border-box; }

200 ms 예산

재야 할 건 누른 순간부터 그려지는 순간까지야. user가 toolbar 아이콘을 누른 때부터 popup이 의미 있는 첫 화면을 그릴 때까지, 느리다고 느끼기 전에 쓸 수 있는 시간이 200 밀리초쯤 돼. 그 예산을 지켜 주는 습관이 셋 있어:

  • Sync 렌더, async refine. Placeholder 값 ("0 clips" 나 "")으로 layout 즉시 표시. chrome.storage.local.get resolve 되면서 실제 data로 교체. User가 구조 먼저, 숫자 그 다음 봄.
  • CSS-in-JS 나 덩치 큰 스타일 framework는 피해. <style> 안에 평범한 CSS를 넣거나 작은 외부 파일 하나를 쓰면 JS가 돌기도 전에 화면이 그려져. 그리기 전에 초기화부터 하는 framework는 그 예산을 통째로 잡아먹을 수 있어.
  • 비싼 helper lazy-load. Clipboard helper 나 markdown renderer 있으면, module top level가 아닌 그게 필요한 click handler 안에서 import.

Focus와 keyboard

Popup이 keyboard reachable: user가 Alt+Shift+T (또는 configured shortcut) 누르고 Tab으로 navigate. 세 규칙:

  • 열리면 focus를 받을 수 있는 첫 요소가 focus를 가져가. 첫 버튼 말고 검색창에 focus를 주고 싶으면 그 input에 autofocus를 달아. Tab을 눌러 가며 확인해 보고.
  • Escape가 popup 우아하게 닫아야 함 (Chrome이 이미 알아서 해 줘. 다만 keydown을 가로챌 땐 Esc까지 막아 버리지는 마).
  • popup에 목록이 있다면 키보드로 오갈 수 있어야 해. 줄마다 tabindex="0"을 주고 Enter handler를 붙여. 안 그러면 user는 마우스로만 쓸 수 있어.

Popup 당 single purpose

Popup이 작아. Settings / library / quick-action을 한 tabbed view에 다 넣고 싶은 충동 저항. 규율 잡힌 ClipDeck popup:

  • 맨 위: 두 줄 status ("5 clips today, 0 this hour").
  • 가운데: 버튼으로 1–3 primary action (Save current selection / Open clip list / Pause on this site).
  • 맨 아래에는 곁가지 링크를 둬 — "Settings", "Help" 같은 것들. 버튼 말고 그냥 텍스트 링크로. 시선의 무게는 주된 동작에 남겨 둬야 하니까.

User가 뭔가 browse 필요하면 side panel. 뭔가 configure 필요하면 options page (Track 6). Popup은 one-shot intent 용.

열려 있는 동안 state update

popup이 몇 초라도 열려 있을 거면 side panel 처럼 chrome.storage.onChanged를 구독해 둬. 그사이 user가 다른 tab에서 뭔가 바꿀 수 있고, popup은 그걸 바로 비춰 줘야 하거든. 구독 해제는 따로 안 해도 돼. popup이 닫히면 그 JavaScript 자리가 통째로 죽으면서 listener도 같이 사라져.

Popup = one shot, one purpose. 구조 먼저 렌더, data로 refine. Sub-200 ms가 즉각 느껴짐. sub-100 ms가 native 느낌. Browse 나 configure는 다른 데로.
Popup auto-close 함정. Popup 안에서 chrome.tabs.create, chrome.windows.create, 또는 window 여는 어떤 chrome.action API 든 호출하면 popup 즉시 닫힘. 가끔 그게 원하는 거 (action 완료, 자기 dismiss). 가끔 안 (user가 두 가지 하고 싶었음). Two-step 상호작용에는 두 번째 step 미루든가 둘 다 side panel로 옮기든가.

Code

popup.html — primary action 셋, secondary link 둘·html
<!-- popup.html — 규율 잡힌 single-purpose layout -->
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>ClipDeck</title>
    <style>
      body { width: 320px; padding: 12px 16px; margin: 0; box-sizing: border-box; font-family: system-ui, sans-serif; color: #222; }
      .status { font-size: 12px; color: #666; margin-bottom: 12px; line-height: 1.4; }
      .status strong { color: #1a6bd6; }
      .primary { display: block; width: 100%; padding: 8px 12px; margin-bottom: 6px; font-size: 14px; border: 1px solid #1a6bd6; background: #1a6bd6; color: #fff; border-radius: 4px; cursor: pointer; }
      .primary.secondary { background: #fff; color: #1a6bd6; }
      .links { margin-top: 12px; padding-top: 12px; border-top: 1px solid #eee; font-size: 12px; }
      .links a { color: #666; margin-right: 12px; text-decoration: none; }
      .links a:hover { color: #1a6bd6; text-decoration: underline; }
    </style>
  </head>
  <body>
    <div class="status">
      <strong id="clipCount">0</strong> clips today \u2022 <span id="siteCount">0</span> on this site
    </div>
    <button class="primary" id="saveBtn">Save current selection</button>
    <button class="primary secondary" id="openPanelBtn">Open clip list</button>
    <button class="primary secondary" id="pauseBtn">Pause on this site</button>
    <div class="links">
      <a href="#" id="settingsLink">Settings</a>
      <a href="#" id="helpLink">Help</a>
    </div>
    <script src="popup.js"></script>
  </body>
</html>
popup.js — placeholder 값 먼저 렌더, 실제 data가 refine·javascript
// popup.js — 구조 먼저 렌더, 다음 data 로 refine
async function refreshStats() {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  const { clips = [] } = await chrome.storage.local.get("clips");
  const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
  const todayCount = clips.filter((c) => c.savedAt >= todayStart.getTime()).length;
  document.getElementById("clipCount").textContent = String(todayCount);
  if (tab?.url) {
    try {
      const host = new URL(tab.url).host;
      const siteCount = clips.filter((c) => {
        try { return new URL(c.url).host === host; } catch { return false; }
      }).length;
      document.getElementById("siteCount").textContent = String(siteCount);
    } catch { /* chrome:// page 등 */ }
  }
}

document.getElementById("openPanelBtn").addEventListener("click", async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (tab?.id) await chrome.sidePanel.open({ tabId: tab.id });
});

document.getElementById("saveBtn").addEventListener("click", async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id) return;
  // 실제 capture 는 content script 에 살고; 우리는 trigger 만.
  try {
    const captured = await chrome.tabs.sendMessage(tab.id, { type: "captureSelection" });
    if (!captured?.ok || !captured.payload) {
      alert("Select some text first.");
      return;
    }
    const saved = await chrome.runtime.sendMessage({
      type: "saveClip",
      payload: captured.payload,
    });
    if (saved?.ok === false) alert(saved.error || "Could not save clip.");
  } catch (err) {
    alert("ClipDeck cannot access this page.");
  }
});

document.getElementById("settingsLink").addEventListener("click", async (e) => {
  e.preventDefault();
  await chrome.runtime.openOptionsPage();
});

chrome.storage.onChanged.addListener((c, a) => { if (a === "local" && "clips" in c) refreshStats(); });
refreshStats();

External links

Exercise

clipdeck/popup.html을 첫 번째 code block으로, clipdeck/popup.js를 두 번째로 교체. Extension reload. Toolbar icon click — popup이 placeholder 0으로 즉시 렌더, 다음 실제 count가 ms 안에 들어옴. 세 primary 버튼 테스트: Save current selection (먼저 텍스트 선택, 후 click)이 clip 추가. Open clip list가 side panel 열고 popup 닫음. Pause on this site는 마지막에서 두 번째 lesson에서 wire, 지금은 그냥 있는지 확인. Popup 열기, 다른 window에서 Ctrl+Shift+K로 clip 저장, 다시 전환 — popup count가 re-open 없이 update.
Hint
Placeholder 0이 실제 숫자로 교체 안 되면 refreshStats()가 throw 중 — popup DevTools (popup 우클릭 → Inspect → Console) 열어 error 확인. 흔한 게 chrome:// special-page 케이스에서 new URL(tab.url) 동작하지만 host filter가 매칭 0 반환해 siteCount가 명백한 error 없이 0 유지. Popup이 예상보다 넓으면 padding이 명시적 width 안으로 먹도록 body에 box-sizing: border-box 설정 확인. Save current selection이 실제 web page 에서도 alert 하면 content script가 load 안 됨 — content.js가 content_scripts.matches 안에 있는지 확인.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.