C.W.K.
Stream
Lesson 02 of 05 · published

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

~12 min · popup, ux, accessibility, performance

Level 0Extension 입덕
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Popup 이 user 가 옮겨가기 전 useful 하게 느껴질 200 ms 가짐. Lesson 2 가 그 제약이 size / focus / render order, 그리고 sloppy popup 이 신뢰 잃는 6 가지 방법에 실제로 의미하는 거."

Size window

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

  • Width: max ~800 px, real-world sweet spot 은 280–360 px. 그보다 넓으면 popup 이 misplaced 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 예산

Click-to-paint 가 metric. User 가 toolbar icon click 한 순간부터 popup 이 첫 의미 있는 frame 렌더하는 순간까지, latency 알아채기 전 ~200 ms 가짐. 그 예산 사는 세 습관:

  • Sync 렌더, async refine. Placeholder 값 ("0 clips" 나 "") 으로 layout 즉시 표시. chrome.storage.local.get resolve 되면서 실제 data 로 교체. User 가 구조 먼저, 숫자 그 다음 봄.
  • CSS-in-JS 나 큰 스타일 framework 피하기. <style> 태그의 vanilla CSS (또는 작은 외부 sheet) 가 JS 돌기도 전에 렌더. Paint 전 hydrate 하는 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. 세 규칙:

  • 첫 focusable 요소가 열릴 때 focus 받음. 첫 버튼 대신 search input 에 focus 원하면 input 에 autofocus 추가. Tab 으로 확인.
  • Escape 가 popup 우아하게 닫아야 함 (Chrome 이 이미 처리, 그러나 keydown intercept 하면 Esc 막지 안 함).
  • Popup 이 list 포함하면, row 가 keyboard-navigable: 각 row 가 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).
  • 맨 아래: secondary link — "Settings" / "Help" / "View on GitHub". Plain text link, 버튼 아님. 시각 무게를 primary action 에 유지.

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 close 에 unsubscribe 자동 — popup JavaScript context 죽고, 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 {
    await chrome.tabs.sendMessage(tab.id, { type: "captureSelection" });
  } 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

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

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