Groo Design

Popover

트리거에 붙어 떠 있는 표면. 1280 이상에서 부가 정보 · 짧은 동작 · 고르는 패널(Input Button 의 달력 · 시각 · 목록)을 띄운다 — 같은 내용을 1280 미만에서는 Bottom Sheet 로 띄운다. 비모달이라 뒤 화면을 막지 않는다. 폼 · 상세는 Dialog, 줄의 동작 목록은 Menu(그 차례에), 짧은 도움말 말풍선은 Help Bubble(그 차례에)이다.

구조는 당근 SEED Popover(Apache-2.0 — 디자인 문서는 없고 React 문서 · rootage 만 있다)를 따른다 — 표면 · 머리(제목 · 설명 · 닫기) · 본문 · 바닥, 머리 · 본문 · 바닥 여백은 Dialog 와 같다. 값은 groo 토큰이고, SEED 와 다른 자리는 맨 아래 "SEED 와 다른 점" 에 적었다(2026-10-02 사용자 결정).

수치 원본은 popover.yaml이다. 수치 표 자리([표: …])와 그림 자리([그림: …])는 사이트가 그 파일로 그린다 — GitHub 에서는 그 파일 · 그림 코드로 가는 링크로 보인다.

hr.groo.app
휴가
연차 사용 규정
남은 연차 11일 · 올해 쓴 연차 4일휴가 내역 보기
연차 사용 규정

입사 1년 미만은 한 달에 1일씩 생기고, 1년이 지나면 15일이 생겨요. 쓰지 않은 연차는 다음 해 3월에 정산해요.

hr.groo.app
휴가 신청
연차
10월 12일 (월)
hr.groo.app
휴가
연차 사용 규정
남은 연차 11일 · 올해 쓴 연차 4일휴가 내역 보기
연차 사용 규정

입사 1년 미만은 한 달에 1일씩 생기고, 1년이 지나면 15일이 생겨요. 쓰지 않은 연차는 다음 해 3월에 정산해요.

hr.groo.app
휴가 신청
연차
10월 12일 (월)
연차 사용 규정 · 날짜 고르기 — 라이트 · 다크

직접 골라 보기

머리 · 바닥 · 본문 길이 · 트리거 자리(화면 위 · 아래 · 가장자리)를 고르면 스펙대로 그린 팝오버와 그 코드가 바뀐다. 실제로 열고 Tab 으로 빠져나가 닫을 수 있다.

휴가
점심 식사식비 · 현대카드 M−12,000원
지하철교통 · 국민 체크카드−1,450원
월급수입 · 국민 주계좌+3,200,000원
연차 사용 규정
머리
바닥
본문 길이
끝 흐림 scrollFog
트리거 자리
모드
브랜드

열면 초점이 팝오버 안으로 간다(가두지 않는다) — 마지막에서 Tab 을 누르면 다음 자리로 나가며 닫힌다. Esc 는 닫고 트리거로, 바깥을 누르면 누른 자리로(초점을 못 받는 자리면 트리거로) 간다. 1280 미만에서는 같은 내용을 시트로 띄운다(이 그림은 1280 이상의 창이다).

import { Info } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverBody, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

<Popover>
  <PopoverTrigger asChild>
    <Button variant="ghost" size="xsmall" layout="iconOnly" aria-label="연차 사용 규정"><Info /></Button>
  </PopoverTrigger>
  <PopoverContent title="연차 사용 규정">
    <PopoverBody>입사 1년 미만은 한 달에 1일씩 생기고, 1년이 지나면 15일이 생겨요.</PopoverBody>
  </PopoverContent>
</Popover>

Anatomy

hr.groo.app
휴가
연차 사용 규정
남은 연차 11일 · 올해 쓴 연차 4일휴가 내역 보기
연차 사용 규정

입사 1년 미만은 한 달에 1일씩 생기고, 1년이 지나면 15일이 생겨요. 쓰지 않은 연차는 다음 해 3월에 정산해요.

안내 — 머리 + 본문
hr.groo.app
휴가 신청
연차
10월 12일 (월)
고르는 패널 — 본문 + 바닥
ⓐ Containerⓑ Header — 제목 · 닫기ⓒ Bodyⓓ Footer — 고른 것을 넣을 때만

폭 320 ~ 480 · 높이 600 까지 · 모서리 20 · 그림자 s3 · 트리거와 8 · 화면 가장자리와 16 — 머리 위 24 · 좌우 24 · 아래 16, 제목 20 / 27 · 설명 14 / 19 · 닫기 아이콘 22(누르는 영역 52) · 바닥 위 16 · 아래 24 · 머리 · 바닥이 없으면 본문 위 · 아래 24

팝오버는 표면 · 머리 · 본문 · 바닥으로 — 트리거와 8 떨어진다
부위설명
ⓐ Container표면 — 트리거 아래(모자라면 위), 폭 320 ~ 480, 그림자 s3.
ⓑ Header머리 — 제목 · 설명 · 닫기. 없어도 된다(고르는 패널).
ⓒ Body본문 — 넘치면 이 안에서 스크롤. 넘칠 수 있는 본문은 끝을 흐린다(Scroll Fog — Dialog 와 같다).
ⓓ Footer바닥 — 버튼, 오른쪽 정렬. 없어도 된다.

부위

부위설명
root표면 — 트리거 아래(모자라면 위)
closeButton닫기 — 머리가 있을 때. 이름 "닫기"
header머리 — 제목 · 설명 · 닫기(없어도 된다)
title제목
description설명
body본문 — 넘치면 이 안에서 스크롤
footer바닥 — 버튼(없어도 된다)
focusRing키보드 포커스 링(웹)
notificationList알림 목록 — Desk 데스크톱 머리 벨이 여는 팝오버(1280 이상). 같은 내용을 1280 미만은 Bottom Sheet

Properties

크기 · 자리

폭은 320 ~ 480 이고 화면 가장자리에서 16 을 남긴다. 높이는 600 과 남은 공간 중 작은 쪽까지 — 넘치면 본문이 스크롤한다. 트리거와 8 떨어져 아래에 뜨고, 아래 공간이 모자라면 위로, 옆으로 넘치면 화면 안으로 민다. 모서리 20 · 그림자 s3 — 시트 · 대화상자와 달리 딤이 없어 그림자로 뜬다.

hr.groo.app
연차 사용 규정
연차 사용 규정

입사 1년 미만은 한 달에 1일씩 생겨요.

아래(기본)트리거 아래 8
hr.groo.app
연차 사용 규정
연차 사용 규정

입사 1년 미만은 한 달에 1일씩 생겨요.

아래가 모자라면 위트리거 위 8 — 높이는 남은 공간까지
hr.groo.app
연차 사용 규정
연차 사용 규정

입사 1년 미만은 한 달에 1일씩 생겨요.

옆으로 넘치면 민다화면 가장자리와 16 을 남긴다
자리 — 아래 · 모자라면 위 · 가장자리에서 민다

공통

부위속성값비고
root최소 너비320px가용 폭(화면 − 좌우 16)보다 넓지 않게
root최대 너비480px가용 폭까지
root최대 높이600px가용 높이까지 — 넘치면 본문만 스크롤
root모서리radius-r5 20px
root배경color-bg-layer-floating #FFFFFF · 다크 #2D3346
root그림자shadow-s3
root트리거와 거리spacing-x2 8px트리거와 8 — 화면 가장자리와는 16 을 남기고, 모자라면 반대편 · 옆으로 민다
rootz-index200z-floating — specs/z-index.md L3
header위 여백spacing-x6 24px
header좌우 여백spacing-x6 24px
header아래 여백spacing-x4 16px
header간격spacing-x1_5 6px닫기 버튼이 있으면 오른쪽 52
title글자text-t7 20px / 27px단어 단위 줄바꿈
title굵기700
title글자·아이콘color-fg-neutral #1A1F2E · 다크 #F5F6FA
description글자text-t4 14px / 19px
description굵기400
description글자·아이콘color-fg-neutral-muted #535866 · 다크 #B7BDCC
closeButton크기52px투명 상자 — 보이는 건 아이콘 22
closeButton모서리radius-r3 12px
closeButton아이콘 크기22px
closeButton색color-fg-neutral-subtle #62697A · 다크 #A2A8B7
closeButton위 거리27px아이콘이 위에서 27 · 오른쪽 24 — 제목 첫 줄 가운데와 맞는다
closeButton오른쪽 거리spacing-x6 24px
closeButton터치 영역52 × 52
closeButton전환 대상background-color, scale
closeButton전환 시간motion-duration-color-transition 150ms
closeButton전환 곡선motion-ease-easing cubic-bezier(0.35, 0, 0.35, 1)
body좌우 여백spacing-x6 24px
body위 여백spacing-x6 24px머리가 없을 때만 — 머리가 있으면 0
body아래 여백spacing-x6 24px바닥이 없을 때만 — 바닥이 있으면 0
body세로 넘침auto넘칠 수 있는 본문은 끝 흐림(Scroll Fog overlayBody — 늘 켜진 위 20 · 아래 80, 본문 안 여백도 그만큼 · 2026-10-03) · 위로 스크롤하면 머리 아래 1px stroke-neutral-subtle — Dialog 와 같다(dialog.yaml scrollFog)
notificationList너비380px벨 아래 — 머리 "알림" · 설명 "읽지 않은 알림 N개" · 목록 · 바닥 "알림 설정" · "모두 읽음"(2026-10-16 사용자 결정 Q-017-3b B)
notificationList최대 높이600px이 스펙의 최대 높이 그대로 — 넘치면 목록(본문)만 스크롤 · 끝 흐림. 지금 제품은 min(640, 화면 − 80)
notificationList정렬오른쪽 끝벨 오른쪽 끝에 맞추고 화면 끝 16 을 남긴다(align end)
notificationListbelow1280Bottom Sheet768 ~ 1279 는 같은 내용을 Bottom Sheet 로 — 이 스펙의 1280 경계 그대로. 폰(768 미만)의 벨은 알림 화면
notificationListrowPress읽음 표시줄을 누르면 읽음으로 — 이동하지 않는다(지금 그대로)
notificationListrowMoreButton ghost neutralSubtle iconOnly xsmall 32줄 끝 ⋮(ellipsis-vertical) — 보이는 32 · 누르는 44, 이름 "{알림 제목} 더보기". 시각 옆에 두고 줄의 읽음 버튼과 형제다(줄 버튼 안에 넣지 않는다). 폰 알림 화면 · 벨 시트 · 벨 팝오버 모두(2026-10-22 사용자 결정 — 알림 지우기 B)
notificationListrowDeleteMenu → Alert Dialog⋮ → 1280 미만 Menu Sheet(제목 = 알림 제목) · 이상 Menu 의 위험 줄 "삭제" 하나 → 메뉴가 닫힌 뒤 Alert Dialog "알림 삭제" · "삭제한 알림은 되돌릴 수 없어요." [취소][삭제]. 지우기는 확인에서만 나간다. 실패하면 확인창은 열린 채 두고 웹 · 앱의 오류 알림을 띄운다. 줄 밀기는 두지 않는다
footer위 여백spacing-x4 16px
footer좌우 여백spacing-x6 24px
footer아래 여백spacing-x6 24px
footer간격spacing-x2 8px
footer가로 맞춤flex-end
footer버튼 크기small 36Button small

State

상태모습
enabled열림
pressed닫기 버튼 — bg-layer-floating-pressed + 2px 거리 축소
focused키보드 포커스에만 링 2px · 띄움 2px

상태

상태closeButton 배경closeButton 배율링 두께링 간격링 색
enabled 열림—————
pressed 닫기 버튼 누름color-bg-layer-floating-pressed #F7F8FD · 다크 #353B4D2px 거리 축소 — 기준 52 — 모션 줄이기면 하지 않는다———
focused 웹 — 키보드 포커스에만 링——2px2pxcolor-stroke-focus-ring #1A1F2E · 다크 #F5F6FA

모션

전환시간곡선대상비고
열림motion-duration-d3 150msmotion-ease-enter cubic-bezier(0, 0, 0.15, 1)scale 0.95 → 1, opacity 0 → 1트리거 쪽 변을 기준점으로 커진다
닫힘motion-duration-d2 100msmotion-ease-exit cubic-bezier(0.35, 0, 1, 1)scale → 0.95, opacity → 0
누름 — 닫기 버튼motion-duration-pressed-scale 150msmotion-ease-pressed-scale cubic-bezier(0, 0, 0.15, 1)scale

Guidelines

트리거에 붙는 짧은 것

팝오버는 그 트리거에 대한 것만 담는다 — 칸 옆 안내 · 그 칸에 넣을 값 고르기(달력 · 시각 · 아이콘 격자 · 목록) · 짧은 동작, 그리고 그 버튼이 여는 목록 하나(Desk 데스크톱 머리 벨의 알림 목록 — 아래). 같은 내용이 1280 미만에서는 Bottom Sheet 로 뜬다(Input Button 이 폭으로 고른다). 폼 · 상세는 Dialog, 되돌릴 수 없는 확인은 Alert Dialog, 줄의 동작 목록은 Menu 다. 어느 떠 있는 표면을 쓸지 일곱을 견준 표는 DESIGN.md Components 의 "시트 · 대화상자 · 확인창 · 팝오버" 한 벌이다(2026-10-22 사용자 결정 3C — https://claude.ai/artifact/YRPbWzhgD4EDdKsVioU8Zj).

폼을 두지 않는 까닭 — 팝오버는 비모달이라 바깥 누르기 · Tab 으로 닫히고, 닫히면 입력이 사라진다. "바뀐 값이 있으면 닫기 전에 묻는다"(Field)와 함께 서지 못한다. 그래서 저장해야 남는 입력은 칸 하나라도 Dialog 다(1280 미만은 같은 폼이 시트로). 고르면 바로 들어가는 것(달력 · 시각 · 목록 · 아이콘)만 팝오버에 둔다 — 고르는 순간 값이 칸에 들어가 잃을 것이 없다(2026-10-22 사용자 결정 10C — https://claude.ai/artifact/YRPbWzhgD4EDdKsVioU8Zj). 옛 스펙(d593204^)은 "짧은 form(2–3 fields) 전용 — 4+ fields 는 dialog" 로 칸 수로 갈랐다 — 칸 수가 아니라 닫히면 잃는지가 경계라 이 경계는 기록으로만 남긴다.

hr.groo.app
휴가
연차 사용 규정
남은 연차 11일 · 올해 쓴 연차 4일휴가 내역 보기
연차 사용 규정

입사 1년 미만은 한 달에 1일씩 생기고, 1년이 지나면 15일이 생겨요. 쓰지 않은 연차는 다음 해 3월에 정산해요.

칸 옆 안내
hr.groo.app
휴가 신청
연차
10월 12일 (월)
고르는 패널
이렇게 — 그 트리거에 대한 것만 — 칸 옆 안내 · 그 칸에 넣을 값 고르기(달력 · 목록)
hr.groo.app
휴가
휴가 신청
휴가 신청
가족 행사
10월 12일 (월)
이렇게 하지 않는다 — 팝오버에 폼 — 바깥을 누르면 닫혀 쓰던 값이 사라진다. 폼 · 상세는 Dialog
쓰임 — 칸 옆 안내 · 고르는 패널 · 팝오버에 폼

비모달 — 뒤 화면을 막지 않는다

열면 초점이 팝오버 안으로 가지만 가두지 않는다 — 마지막 칸에서 Tab 을 누르면 페이지로 나가고 팝오버는 닫힌다. 딤 · 스크롤 잠금이 없고 뒤 화면을 숨기지 않는다. Esc 로 닫으면 초점이 트리거로 돌아가고, 바깥을 눌러 닫으면 누른 자리로 간다(그 자리가 초점을 받지 못하면 트리거로).

hr.groo.app
휴가
연차 사용 규정
남은 연차 11일 · 올해 쓴 연차 4일휴가 내역 보기
연차 사용 규정

입사 1년 미만은 한 달에 1일씩 생겨요.

열면초점이 팝오버 안으로 — 가두지 않는다(딤 · 스크롤 잠금 없음)
hr.groo.app
휴가
연차 사용 규정
남은 연차 11일 · 올해 쓴 연차 4일휴가 내역 보기
Esc · 바깥 누르기Esc 는 트리거로 — 바깥을 누르면 누른 자리로(초점을 못 받는 자리면 트리거로)
hr.groo.app
휴가
연차 사용 규정
남은 연차 11일 · 올해 쓴 연차 4일휴가 내역 보기
마지막에서 Tab다음 자리로 나가며 닫힌다 — 초점은 나간 자리에
닫기 — 바깥 · Esc · Tab 으로 빠져나가기

알림 목록 — 벨 아래(1280 이상) · 시트(미만)

Desk 데스크톱 머리의 벨을 누르면 1280 이상에서는 벨 아래 팝오버로, 768 ~ 1279 에서는 Bottom Sheet 로 알림 목록을 연다 — 이 스펙의 1280 경계 그대로다(2026-10-16 사용자 결정 — 구현 세션 질문 Q-017-3b B). 목록이 길고 개수가 정해지지 않았어도 트리거(벨) 하나가 여는 목록이라 팝오버에 둔다 — 폼 · 상세를 담지 않는다. 폭은 380, 높이는 이 스펙대로 600 과 남은 공간 중 작은 쪽까지이고 넘치면 본문(목록)만 스크롤하며 끝을 흐린다. 머리는 제목 "알림" · 설명 "읽지 않은 알림 N개" · 닫기, 바닥은 "알림 설정" · "모두 읽음"(Button small) — 시트도 같은 내용이다(시트의 바닥 버튼은 large 반씩). 줄을 누르면 읽음으로 표시한다(지금 그대로 — 이동하지 않는다). 폰(768 미만)의 벨은 알림 화면으로 간다.

줄 끝에는 ⋮ 하나를 둔다 — Button ghost · ghostColor="neutralSubtle" · iconOnly · xsmall(보이는 32 · 누르는 44), 이름 "{알림 제목} 더보기". 시각 옆에 놓고 줄의 읽음 버튼과 형제다 — 줄 버튼 안에 넣지 않아 따로 눌리고 따로 읽힌다. 누르면 1280 미만은 Menu Sheet(제목 = 알림 제목), 이상은 Menu 로 위험 줄 "삭제" 하나를 연다. 메뉴가 다 닫힌 뒤 Alert Dialog "알림 삭제" · "삭제한 알림은 되돌릴 수 없어요." [취소][삭제] 를 열고, 지우기는 확인에서만 나간다. 지우다 실패하면 확인창을 열어 둔 채 웹 · 앱의 오류 알림을 띄운다 — 다시 누르거나 취소할 수 있다. 줄 밀기로 지우지 않는다. 폰의 알림 화면 · 벨 시트 · 벨 팝오버가 모두 같다(2026-10-22 사용자 결정 — 알림 지우기 B, https://claude.ai/artifact/RXwXuA8Xm5oYXqjGafNvjU).

desk.groo.app
그루 담다
워크스페이스
기록
거래 검색
가계부모든 거래 내역
10월 지출1,240,000원
10월 수입3,200,000원
남은 예산310,000원
최근 거래
점점심 식사식비, 현대카드 M−12,000원
지지하철교통, 국민 체크카드−1,450원
월월급수입, 국민 주계좌+3,200,000원
편편의점식비, 현금−4,300원
영영화문화, 국민 체크카드−15,000원
스스타벅스카페, 현대카드 M−5,600원
알림
읽지 않은 알림 12개
카드 결제일이 다가와요방금지어낸카드 프리미엄 체크 · 10월 25일 결제 예정 1,234,567원 — 결제 계좌 잔액이 모자라요
예산을 넘었어요12분 전식비 예산의 104%를 썼어요 — 이번 달 남은 날 12일
반복 거래를 기록했어요1시간 전지어낸마트 월간 정기 배송 18,900원
일정 알림3시간 전오후 3시 · 지어낸치과 정기 검진
더치페이 요청어제지어낸 이름 열두글자사용자 님이 32,000원을 보냈어요
카드 실적을 채웠어요어제지어낸카드 플러스 · 이번 달 실적 30만원 달성
증권 연결이 끊겼어요2일 전가나증권 — 다시 연결해 주세요
예산 80% 사용3일 전교통 예산 · 10월
1280 — 벨 아래 8 · 벨 오른쪽 끝 맞춤 · 폭 380 · 높이 600 까지(목록만 스크롤 · 끝 흐림)
desk.groo.app
워크스페이스
기록
거래 검색
가계부모든 거래 내역
10월 지출1,240,000원
10월 수입3,200,000원
남은 예산310,000원
최근 거래
점점심 식사식비, 현대카드 M−12,000원
지지하철교통, 국민 체크카드−1,450원
월월급수입, 국민 주계좌+3,200,000원
편편의점식비, 현금−4,300원
영영화문화, 국민 체크카드−15,000원
스스타벅스카페, 현대카드 M−5,600원
알림
읽지 않은 알림 12개
카드 결제일이 다가와요방금지어낸카드 프리미엄 체크 · 10월 25일 결제 예정 1,234,567원 — 결제 계좌 잔액이 모자라요
예산을 넘었어요12분 전식비 예산의 104%를 썼어요 — 이번 달 남은 날 12일
반복 거래를 기록했어요1시간 전지어낸마트 월간 정기 배송 18,900원
일정 알림3시간 전오후 3시 · 지어낸치과 정기 검진
더치페이 요청어제지어낸 이름 열두글자사용자 님이 32,000원을 보냈어요
카드 실적을 채웠어요어제지어낸카드 플러스 · 이번 달 실적 30만원 달성
증권 연결이 끊겼어요2일 전가나증권 — 다시 연결해 주세요
예산 80% 사용3일 전교통 예산 · 10월
1024 — 같은 내용을 Bottom Sheet 로(딤 · 최대 480)
알림
새 알림이 없어요
아직 알림이 없어요
알림 0개 — 설명 '새 알림이 없어요' · 모두 읽음 막힘
알림 목록 — 1280 벨 아래 팝오버 · 1024 Bottom Sheet · 알림 0개

알림 목록

속성값비고
너비380px벨 아래 — 머리 "알림" · 설명 "읽지 않은 알림 N개" · 목록 · 바닥 "알림 설정" · "모두 읽음"(2026-10-16 사용자 결정 Q-017-3b B)
최대 높이600px이 스펙의 최대 높이 그대로 — 넘치면 목록(본문)만 스크롤 · 끝 흐림. 지금 제품은 min(640, 화면 − 80)
정렬오른쪽 끝벨 오른쪽 끝에 맞추고 화면 끝 16 을 남긴다(align end)
below1280Bottom Sheet768 ~ 1279 는 같은 내용을 Bottom Sheet 로 — 이 스펙의 1280 경계 그대로. 폰(768 미만)의 벨은 알림 화면
rowPress읽음 표시줄을 누르면 읽음으로 — 이동하지 않는다(지금 그대로)
rowMoreButton ghost neutralSubtle iconOnly xsmall 32줄 끝 ⋮(ellipsis-vertical) — 보이는 32 · 누르는 44, 이름 "{알림 제목} 더보기". 시각 옆에 두고 줄의 읽음 버튼과 형제다(줄 버튼 안에 넣지 않는다). 폰 알림 화면 · 벨 시트 · 벨 팝오버 모두(2026-10-22 사용자 결정 — 알림 지우기 B)
rowDeleteMenu → Alert Dialog⋮ → 1280 미만 Menu Sheet(제목 = 알림 제목) · 이상 Menu 의 위험 줄 "삭제" 하나 → 메뉴가 닫힌 뒤 Alert Dialog "알림 삭제" · "삭제한 알림은 되돌릴 수 없어요." [취소][삭제]. 지우기는 확인에서만 나간다. 실패하면 확인창은 열린 채 두고 웹 · 앱의 오류 알림을 띄운다. 줄 밀기는 두지 않는다

머리는 있을 때 제목 + 닫기

안내 팝오버는 머리에 제목과 닫기 버튼을 둔다. 고르는 패널(달력 · 목록)은 머리 없이 본문만 — 무엇을 고르는지는 트리거가 말한다. 바닥 버튼은 고른 것을 넣을 때("완료")만.

글

제목은 무엇에 대한 안내인지("연차 사용 규정"), 본문은 해요체 짧은 문장. 긴 안내 · 단계가 있는 설명은 팝오버가 아니라 페이지 · 도움말이다.

코드

레시피 recipes/shadcn/components/ui/popover.tsx 를 쓴다(Radix Popover 위). Input Button 의 고르는 패널은 useInputButtonSurface() 가 1280 이상일 때만 팝오버로 연다(Input Button). 아래 미리보기는 스펙 값으로 그린 모습이다.

안내

연차 사용 규정

남은 연차 11일 · 올해 쓴 연차 4일

import { Popover, PopoverBody, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

{/* 1280 이상 — 1280 미만에서는 같은 내용을 Bottom Sheet 로 띄운다(useInputButtonSurface() 로 고른다) */}
<Popover>
  <PopoverTrigger asChild>
    <Button variant="ghost" size="xsmall" layout="iconOnly" aria-label="연차 사용 규정"><Info /></Button>
  </PopoverTrigger>
  <PopoverContent title="연차 사용 규정">
    <PopoverBody>입사 1년 미만은 한 달에 1일씩 생기고, 1년이 지나면 15일이 생겨요.</PopoverBody>
  </PopoverContent>
</Popover>

알림 목록 — 1280 이상 팝오버 · 미만 시트

알림
읽지 않은 알림 12개
카드 결제일이 다가와요방금지어낸카드 프리미엄 체크 · 10월 25일 결제 예정 1,234,567원 — 결제 계좌 잔액이 모자라요
예산을 넘었어요12분 전식비 예산의 104%를 썼어요 — 이번 달 남은 날 12일
반복 거래를 기록했어요1시간 전지어낸마트 월간 정기 배송 18,900원
일정 알림3시간 전오후 3시 · 지어낸치과 정기 검진
더치페이 요청어제지어낸 이름 열두글자사용자 님이 32,000원을 보냈어요
카드 실적을 채웠어요어제지어낸카드 플러스 · 이번 달 실적 30만원 달성
증권 연결이 끊겼어요2일 전가나증권 — 다시 연결해 주세요
예산 80% 사용3일 전교통 예산 · 10월
import { Bell } from "lucide-react"
import { BottomSheet, BottomSheetBody, BottomSheetContent, BottomSheetFooter, BottomSheetTrigger } from "@/components/ui/bottom-sheet"
import { Button } from "@/components/ui/button"
import { useInputButtonSurface } from "@/components/ui/input-button"
import { Popover, PopoverBody, PopoverContent, PopoverFooter, PopoverTrigger } from "@/components/ui/popover"
import { TopNavigationIconButton } from "@/components/ui/top-navigation"

const surface = useInputButtonSurface() // 1280 미만 "sheet" · 이상 "popover"
const bell = (
  <TopNavigationIconButton notification={unread > 0} aria-label={unread > 0 ? "알림, 새 알림 있음" : "알림"}>
    <Bell />
  </TopNavigationIconButton>
)
const description = unread > 0 ? `읽지 않은 알림 ${unread}개` : "새 알림이 없어요"

{/* 같은 내용 — 넓으면 벨 아래 팝오버(폭 380), 좁으면 아래 시트. 줄을 누르면 읽음 표시 */}
{surface === "popover" ? (
  <Popover>
    <PopoverTrigger asChild>{bell}</PopoverTrigger>
    <PopoverContent title="알림" description={description} align="end" className="w-[380px]">
      <PopoverBody scrollFog>{notificationList}</PopoverBody>
      <PopoverFooter>
        <Button variant="neutralWeak" size="small" onClick={openNotificationSettings}>알림 설정</Button>
        <Button variant="neutralSolid" size="small" onClick={markAllRead} disabled={unread === 0}>모두 읽음</Button>
      </PopoverFooter>
    </PopoverContent>
  </Popover>
) : (
  <BottomSheet>
    <BottomSheetTrigger asChild>{bell}</BottomSheetTrigger>
    <BottomSheetContent title="알림" description={description}>
      <BottomSheetBody scrollFog>{notificationList}</BottomSheetBody>
      <BottomSheetFooter>
        <Button variant="neutralWeak" onClick={openNotificationSettings}>알림 설정</Button>
        <Button variant="neutralSolid" onClick={markAllRead} disabled={unread === 0}>모두 읽음</Button>
      </BottomSheetFooter>
    </BottomSheetContent>
  </BottomSheet>
)}

Behavior

인터랙션동작
트리거 누르기열고 닫는다. 150ms 로 트리거 쪽에서 커지며 나타난다.
열린 뒤초점이 팝오버 안으로(머리 닫기 버튼이 아니라 내용) 간다.
Tab팝오버 안을 지나 마지막에서 페이지로 나가면 닫힌다(초점은 나간 자리에 남는다).
Shift+Tab거꾸로 — 내용 첫 칸 다음은 머리 닫기 버튼, 그다음 트리거(팝오버는 열린 채). 보이는 순서(닫기는 위)와 같고, 닫기 버튼에 키보드로 닿는 길이 남는다. 2026-10-15 사용자 결정 — 구현 세션 질문 Q-006
Esc닫고, 초점은 트리거로 돌아간다.
바깥 누르기닫는다 — 초점은 누른 자리로 간다(누른 곳이 초점을 받지 못하면 트리거로).
대화상자 · 시트 안에서그 위에 뜬다(z-index L3) — 대화상자가 닫히면 함께 닫힌다.

Accessibility

기준검증
WCAG 1.4.3 Color contrast(글자 ≥ 4.5:1)제목 fg-neutral 16.41 · 다크 11.62, 설명 fg-neutral-muted 7.11 · 6.67 ✓
WCAG 1.4.11 Non-text contrast(≥ 3:1)표면 경계는 그림자 s3(딤 없음) — 표면을 알리는 유일한 표시가 아니다(머리 · 내용이 알린다). 키보드 포커스 링 Desk 8.38 · 5.28 · HR 8.17 · 5.26 ✓
WCAG 1.4.13 Content on Hover or Focus누를 때 열리고(호버로 열지 않는다) 바깥 · Esc 로 닫힌다 ✓
WCAG 2.5.8 Target Size — Minimum(AA ≥ 24×24)닫기 버튼 52 ✓ · 알림 줄 ⋮ 보이는 32(누르는 44) ✓
ARIArole="dialog"(aria-modal 없음), 제목 aria-labelledby(있을 때), 트리거 aria-haspopup="dialog" · aria-expanded · aria-controls. 머리 없는 고르는 패널은 aria-label 로 이름을 단다

Do / Don't

✅ Do

  • 트리거에 대한 짧은 안내 · 값 고르기 · 그 버튼이 여는 목록(벨의 알림)을 1280 이상에서 팝오버로 — 1280 미만은 같은 내용을 시트로.
  • 열면 초점을 안으로, 닫으면 트리거로.
  • 안내 팝오버는 제목 + 닫기.

❌ Don't

  • 팝오버에 폼 · 상세(Dialog) — 저장해야 남는 입력은 칸 하나라도. 바깥 누르기 · Tab 으로 닫혀 입력이 사라진다.
  • 호버로 열기(도움말 말풍선은 Help Bubble — 그 차례에).
  • 팝오버 위에 팝오버(고르는 패널 안의 작은 패널은 예외 — 달력의 월 · 연도).
  • 1280 미만에서 팝오버(시트).

Specification

popover.yaml 의 규칙을 하나도 빼지 않고 조건마다 그린다 — 웹 · 앱이 Popover 를 만들 때 이 값을 그대로 쓴다.

Base

부위상태속성값
rootenabled최대 높이maxHeight600px가용 높이까지 — 넘치면 본문만 스크롤
최소 너비minWidth320px가용 폭(화면 − 좌우 16)보다 넓지 않게
최대 너비maxWidth480px가용 폭까지
모서리radiusradius-r520px
배경backgroundbg-layer-floating#FFFFFF다크 #2D3346
그림자shadowshadow-s30 8px 24px -4px rgba(15, 18, 28, 0.10), 0 2px 6px -2px rgba(15, 18, 28, 0.05) · 다크 0 8px 24px -4px rgba(0, 0, 0, 0.50), 0 2px 6px -2px rgba(0, 0, 0, 0.25), inset 0 1px 0 0 rgba(255, 255, 255, 0.08)
z-indexzIndex200z-floating — specs/z-index.md L3
트리거와 거리offsetspacing-x28px트리거와 8 — 화면 가장자리와는 16 을 남기고, 모자라면 반대편 · 옆으로 민다
closeButtonenabled크기size52px투명 상자 — 보이는 건 아이콘 22
아이콘 크기iconSize22px
모서리radiusradius-r312px
색colorfg-neutral-subtle#62697A다크 #A2A8B7
터치 영역touchTarget52 × 52
전환 대상transitionPropertybackground-color, scale
전환 시간transitionDurationmotion-duration-color-transition150ms
전환 곡선transitionEasingmotion-ease-easingcubic-bezier(0.35, 0, 0.35, 1)
위 거리top27px아이콘이 위에서 27 · 오른쪽 24 — 제목 첫 줄 가운데와 맞는다
오른쪽 거리rightspacing-x624px
headerenabled좌우 여백paddingXspacing-x624px
위 여백paddingTopspacing-x624px
아래 여백paddingBottomspacing-x416px
간격gapspacing-x1_56px닫기 버튼이 있으면 오른쪽 52
titleenabled글자·아이콘foregroundfg-neutral#1A1F2E다크 #F5F6FA
글자typographytext-t720px / 27px단어 단위 줄바꿈
굵기fontWeight700
descriptionenabled글자·아이콘foregroundfg-neutral-muted#535866다크 #B7BDCC
글자typographytext-t414px / 19px
굵기fontWeight400
bodyenabled좌우 여백paddingXspacing-x624px
위 여백paddingTopspacing-x624px머리가 없을 때만 — 머리가 있으면 0
아래 여백paddingBottomspacing-x624px바닥이 없을 때만 — 바닥이 있으면 0
세로 넘침overflowYauto넘칠 수 있는 본문은 끝 흐림(Scroll Fog overlayBody — 늘 켜진 위 20 · 아래 80, 본문 안 여백도 그만큼 · 2026-10-03) · 위로 스크롤하면 머리 아래 1px stroke-neutral-subtle — Dialog 와 같다(dialog.yaml scrollFog)
footerenabled버튼 크기buttonSizesmall 36Button small
좌우 여백paddingXspacing-x624px
위 여백paddingTopspacing-x416px
아래 여백paddingBottomspacing-x624px
간격gapspacing-x28px
가로 맞춤justifyContentflex-end
notificationListenabled최대 높이maxHeight600px이 스펙의 최대 높이 그대로 — 넘치면 목록(본문)만 스크롤 · 끝 흐림. 지금 제품은 min(640, 화면 − 80)
너비width380px벨 아래 — 머리 "알림" · 설명 "읽지 않은 알림 N개" · 목록 · 바닥 "알림 설정" · "모두 읽음"(2026-10-16 사용자 결정 Q-017-3b B)
정렬align오른쪽 끝벨 오른쪽 끝에 맞추고 화면 끝 16 을 남긴다(align end)
below1280below1280Bottom Sheet768 ~ 1279 는 같은 내용을 Bottom Sheet 로 — 이 스펙의 1280 경계 그대로. 폰(768 미만)의 벨은 알림 화면
rowPressrowPress읽음 표시줄을 누르면 읽음으로 — 이동하지 않는다(지금 그대로)
rowMorerowMoreButton ghost neutralSubtle iconOnly xsmall 32줄 끝 ⋮(ellipsis-vertical) — 보이는 32 · 누르는 44, 이름 "{알림 제목} 더보기". 시각 옆에 두고 줄의 읽음 버튼과 형제다(줄 버튼 안에 넣지 않는다). 폰 알림 화면 · 벨 시트 · 벨 팝오버 모두(2026-10-22 사용자 결정 — 알림 지우기 B)
rowDeleterowDeleteMenu → Alert Dialog⋮ → 1280 미만 Menu Sheet(제목 = 알림 제목) · 이상 Menu 의 위험 줄 "삭제" 하나 → 메뉴가 닫힌 뒤 Alert Dialog "알림 삭제" · "삭제한 알림은 되돌릴 수 없어요." [취소][삭제]. 지우기는 확인에서만 나간다. 실패하면 확인창은 열린 채 두고 웹 · 앱의 오류 알림을 띄운다. 줄 밀기는 두지 않는다
closeButtonpressed배경backgroundbg-layer-floating-pressed#F7F8FD다크 #353B4D
배율scale2px 거리 축소기준 52 — 모션 줄이기면 하지 않는다
focusRingfocused링 두께width2px
링 색colorstroke-focus-ringDesk#0147AD다크 #7AA9F6HR#075B3C다크 #2ABE84
링 간격offset2px

SEED 와 다른 점

  • 바닥 버튼은 Button small 36 — SEED 예제는 medium 40. Button 결정을 따랐다.
  • 반투명 색을 불투명 짝으로(v102) — 닫기 버튼 누름 bg-layer-floating-pressed.
  • 1280 미만에서는 쓰지 않는다 — 같은 내용을 Bottom Sheet 로(Input Button 의 경계). SEED Popover 는 폭 규칙이 없다.
  • z-index 는 specs/z-index.md 의 L3(z-floating 200) — SEED 99999. 대화상자 · 시트(L2) 위, 확인창(L5) 아래.

Migration notes

2026-10-22 — 알림 줄 ⋮ 와 지우기(알림 지우기 B)

사용자가 알림 지우기 비교 페이지에서 정했다 — 줄 끝 ⋮ → "삭제" → 확인창, 웹 · 앱 같게(B). 웹 폰 알림 줄의 × 를 걷자 웹에서 알림을 지울 길이 없었고(데스크톱 벨에는 원래 없었다), 앱은 줄마다 × 로 확인 없이 바로 지웠다. 앱의 × 는 걷는다. 거절: 지우기 없음(A) · 폰은 밀기 + ⋮(C) · 편집 → 골라 삭제 · 모두 삭제(D). I-04 검수 뒤 디자인 세션이 맞춘 것 — ⋮ 는 Button xsmall(32) neutralSubtle(앱은 small 이었다), 지우기 실패는 확인창을 남기고 오류 알림(앱은 조용히 닫혔다).

2026-10-22 — 폼을 두지 않는 까닭과 경계

"폼 · 상세는 Dialog" 금지는 그대로 두고 까닭(비모달 — 닫히면 입력이 사라진다)과 경계(저장해야 남는 입력은 칸 하나라도 Dialog, 고르면 바로 들어가는 것만 팝오버)를 적었다. 옛 경계(d593204^ — "2–3 fields 까지 · 4+ fields 는 Dialog")는 기록으로만 남긴다(사용자 결정 10C — https://claude.ai/artifact/YRPbWzhgD4EDdKsVioU8Zj). 값 변경 없음.

2026-10-16 — 알림 목록(Q-017-3b B)

사용자가 구현 질문 Q-017 · Q-018 비교 페이지에서 정했다 — Desk 데스크톱 벨의 알림 목록은 1280 이상 팝오버 · 768 ~ 1279 Bottom Sheet(B). 지금 팝오버를 768 이상 모두에 두기(A — 1280 규칙의 예외) · 알림 화면으로 가기(C)는 고르지 않았다. "트리거에 대한 것만" 문장에 벨이 여는 목록을 더했다.

  • Desk 웹 — widgets/notification-manage/ui/NotificationsPopover.tsx 는 화면 고정 top 58 · right 20 · 380 × min(640, 화면 − 80), z-index auto · Esc 로 안 닫힘이다. 1280 이상은 이 Popover 부품(벨 아래 8 · 화면 끝 16 · 높이 600 까지 · Esc · 바깥 · z-floating)으로, 768 ~ 1279 는 Bottom Sheet 로 같은 내용을 연다(머리 "알림" · 읽지 않은 알림 N개 · 목록 · "알림 설정" · "모두 읽음").

2026-10-15 — 첫 칸에서 Shift+Tab

구현 세션 질문 Q-006 을 사용자가 정했다 — 지금대로 내용 첫 칸 → 머리 닫기 버튼 → 트리거. Behavior 에 줄을 더했다(레시피 · 웹은 이미 그렇다). 바로 트리거로 가는 안(닫기 버튼에 키보드로 못 닿는다) · 닫기 버튼을 DOM 뒤로 옮기는 안은 고르지 않았다.

2026-10-02 — SEED Popover 로 다시 정한다

사용자가 비교 페이지에서 SEED 모양을 골랐다 — 폭 320 ~ 480 · 최대 높이 600 · 모서리 20 · 그림자 s3 · 트리거와 8 · 머리 제목 20 / 27 + 닫기 · 비모달(초점은 안으로, 가두지 않음). 옛 Popover(288 · 모서리 8 · 1px 테두리 · shadow-md · 안쪽 12)는 걷었다 — 옛 스펙은 popover.history/v-pre-seed-overlay.*.

제품은 앱 적용 단계에서 옮긴다(2026-10-02 조사).

  • Desk 웹 — Popover 부품 6종(날짜 14 · 시각 6 · 아이콘 2 · 캘린더 3)이 360 폭에서도 팝오버로 뜬다(→ 1280 미만은 시트). 날짜 팝오버는 이름이 없고 첫 초점이 영어 "Go to the Previous Month" 버튼이다. 손으로 만든 월 고르기(데스크톱 5곳) · 알림 팝오버는 z-index 가 auto 라 다른 층 아래로 들어가고 Esc 로 닫히지 않는다.
  • HR 웹 — Popover 14곳(규정 안내 12 · 일정 필터 · 역할 고르기) + 날짜 · 시각 피커 21곳. 규정 안내는 이름이 없다.
  • 앱 적용 때 화면마다 정할 자리 — HR 규정 안내 12(Popover · Help Bubble), 데스크톱 필터(Popover · Dialog · Side Panel). 알림 팝오버는 2026-10-16 정했다(위).

이 페이지