Skip to content

토스트 (Toast) ​

토스트의 톤(성공·실패·안내)과 쌓이는 방식을 확인하고, 언제 쓰는지와 부르는 방법을 찾아보세요.

Alpine 스토어 + CSS · Web / Mobile · 진행 중(WIP) · 문서 포맷 시범 적용

Interactive playground ​

옵션을 바꾸면 미리보기와 코드가 함께 바뀌어요.
미리보기 테마

직원 관리

토스트는 화면 위쪽 가운데에 뜹니다. 뒤 화면을 가리지도, 막지도 않아요.

모달과 달리 답을 요구하지 않습니다 — 잠깐 알리고 스스로 사라집니다.

직원 정보를 저장했습니다
쌓이는 순서는 오래된 것이 위입니다(admin·drive 의 push). 직원 앱만 반대로 최신이 위예요 — 두 구현 참고.
// 호출부는 스토어 한 줄이 전부다 — 마크업은 앱 셸에 한 번만 둔다.
Alpine.store("toast").success("직원 정보를 저장했습니다");

// 기본 수명 3000ms. 그대로 두면 알아서 사라진다.

「토스트 띄우기」를 눌러 보세요. 실제 호출부가 쓰는 것은 Alpine.store("toast").success(...) 한 줄이고, 마크업은 앱 셸에 한 번만 둡니다. 3초가 지나면 스스로 사라지고, ✕ 로 먼저 내릴 수도 있습니다.

규칙 (강제) · 코딩 에이전트용

⚠️ 이 문서는 현황 정리본입니다. 토스트는 정본이 없고 앱마다 사본이라, 아래는 「지켜야 할 규칙」과 「지금 어긋나 있는 것」이 섞여 있습니다. 새 코드는 규칙을 따르고, 레거시는 위반이 아니라 마이그레이션 대상입니다.

  • 토스트는 답을 요구하지 않을 때만 쓴다. 사용자의 결정을 받아야 하면 컨펌 다이얼로그(askConfirm)이고, 화면 안에 남아야 하는 설명이면 notice다. 토스트는 이미 끝난 일을 알린다 — 저장했다 · 실패했다 · 준비 중이다.
  • 마크업을 새로 짜지 말 것. 호출부는 Alpine.store("toast").success(...) / .error(...) / .info(...) 한 줄이면 된다. 컨테이너는 앱 셸에 한 번만 둔다.
  • 톤은 셋뿐 — success · error · info. 네 번째를 만들지 말 것. 카드는 셋 다 같은 흰 카드이고 아이콘 색만 다르다(success 초록 · error 빨강 · info 노랑). 카드 배경을 톤 색으로 칠하지 말 것.
  • 아이콘은 레지스트리 3종 고정 — ic-check-circle-fill · ic-x-circle-fill · ic-info-circle-fill. 이모지(✅/❌)로 대신하지 말 것.
  • z 는 토큰 --ds-z-toast(80) 를 쓴다. 토스트는 모달(50)·컨펌(70)·전역 팝업(75)보다 위다 — 모달 위에서 일어난 저장 결과를 알려야 하기 때문. ⚠️ admin·drive 는 지금 z-index: 999999 를 하드코딩하고 있다(검토 필요 사항) — 새 코드에서 따라 하지 말 것.
  • 수명은 3000ms 가 기본. 사용자가 읽고 있는데 지워지면 안 되는 긴 문구는 토스트가 아니라 notice/모달로 옮긴다. 수명을 늘려 해결하지 말 것.
  • .ds-toast 는 아직 정본(design-system.css)에 없다. admin·drive main.css 두 곳에 복사돼 있다 — 스타일을 고치면 두 사본과 docs 데모(ds-toast-demo.css)까지 세 곳을 함께 고쳐야 한다.
  • 직원 앱(employee-app)은 다른 구현이다 — 검은 알약에 톤 색이 없고, 쌓임 순서가 반대(최신이 위)이며 최대 3개다. 두 계열을 한 번에 바꾸려 하지 말고 두 구현을 먼저 읽을 것.
  • 급여 시트의 붙여넣기 요약(paste-toast.html)은 토스트가 아니다 — 이름만 toast 인 시트 전용 알림 줄이다. 이 규격을 적용하지 말 것.
  • 직원 앱 상단 배너($store.inAppBanner)도 토스트가 아니다 — 앱이 앞에 떠 있을 때 새 메시지 · 새 알림을 OS 배너 대신 그리는 한 장짜리 카드다. 스택 · 톤이 없고, 띄울지는 휴대폰 알림 설정(isPushAllowed)이 정한다. 토스트로 새 메시지를 알리지 말고, 배너를 다른 피드백에 쓰지 말 것 → 앱 안 상단 배너.

언제 쓰나 ​

토스트는 끝난 일을 스치듯 알리는 자리입니다. 답을 받지 않고, 화면을 막지 않고, 스스로 사라집니다.

상황쓰는 것
저장·등록·삭제가 끝났다토스트 success
요청이 실패했다(다시 시도하면 되는 것)토스트 error
아직 준비 안 된 기능을 눌렀다토스트 info
사용자의 결정이 필요하다(삭제할까요?)컨펌 다이얼로그
화면에 남아 있어야 하는 설명·경고Notice
입력값 하나가 틀렸다필드 아래 에러 메시지

토스트로 오류를 알리고 끝내지 마세요

토스트는 3초 뒤 사라집니다. 사용자가 무엇을 해야 하는지를 담아야 할 오류(권한 없음 · 필수값 누락 · 재로그인 필요)를 토스트에만 실으면, 놓친 사람은 되짚을 방법이 없습니다. 그런 것은 화면에 남는 notice나 모달로 옮기세요.

부르는 법 ​

호출부가 아는 것은 스토어 한 줄뿐입니다.

ts
Alpine.store("toast").success("직원 정보를 저장했습니다");
Alpine.store("toast").error("저장에 실패했어요. 잠시 후 다시 시도해 주세요.");
Alpine.store("toast").info("댓글 기능은 아직 준비 중이에요.");

HTML 안 Alpine 식에서는 $store.toast.success('직원을 추가했습니다') 로 씁니다 — 실제로 admin 의 직원 추가 패널이 이렇게 씁니다.

문구는 한국어 해요체입니다. 서버가 던진 message 를 그대로 실을 때도 마찬가지고, 그 규칙은 코드 품질 기준에 있습니다.

두 구현 ​

같은 이름이지만 계열이 둘입니다. 새로 만들 때 어느 쪽을 따라갈지 먼저 정하세요.

admin · drive (.ds-toast)employee-app
모양흰 카드 + 톤 아이콘 + 닫기 ✕검은 알약, 아이콘·닫기 없음
톤success · error · info (아이콘 색)표현 없음 — 스토어는 톤을 받지만 마크업이 무시
위치화면 위쪽 가운데화면 아래쪽 가운데(하단 내비 위)
쌓임push — 오래된 것이 위unshift — 최신이 위
최대 개수제한 없음3 — 넘치면 오래된 것부터 밀려남
같은 문구 반복그대로 쌓임수명만 갱신(도배 방지)
수명3000ms · false 로 고정 가능3000ms 고정
스토어각 앱 main.ts 안 객체 리터럴stores/toast-store.ts 의 createToastStore()

직원 앱의 스택 규칙은 실측에서 나왔습니다

예전에는 슬롯이 하나라 새 토스트가 앞의 것을 갈아끼웠습니다. 부팅 때 여러 화면이 거의 동시에 오류를 던지면서 — 실측(권한 없는 계정) 0.0s 근무표 오류 → 0.1s 사용자가 누른 안내가 그걸 지움 → 3.0s 메신저 오류가 그 안내를 지움 — 사용자가 방금 일으킨 피드백이 관계없는 화면의 오류에 먹혔습니다. 그래서 각 토스트가 자기 수명을 갖고 쌓이도록 바뀌었습니다(2026-08-21).

톤 셋 ​

카드는 셋 다 완전히 같습니다 — 흰 배경 · 1px 테두리 · 그림자 · 14/500. 다른 것은 왼쪽 아이콘의 색과 모양뿐입니다.

톤클래스아이콘색
성공.ds-toast--successic-check-circle-fill--color-hr-green-600
실패.ds-toast--erroric-x-circle-fill--color-hr-red-600
안내.ds-toast--infoic-info-circle-fill--color-hr-yellow-500

info 가 파랑이 아니라 노랑입니다

다른 화면의 안내(notice --info)는 파랑 계열인데 토스트의 info 만 노랑입니다. 의도된 구분이 아니라 갈라진 것으로 보이고, 정본 승격 때 함께 정할 사안입니다(검토 필요 사항).

규격 ​

ds-toast-demo.css 와 admin·drive main.css 가 같은 값을 갖습니다.

대상값
컨테이너position: fixed · 상단 가운데(top: 0; left: 50%; translateX(-50%)) · flex-column · 사이 12px · 패딩 24 32, 위쪽 72 · pointer-events: none
카드반경 8 · 패딩 12 · 최소 높이 48 · 최대 폭 480 · width: fit-content · 흰 배경 · 1px --ds-line-opacity-normal · --ds-shadow-lg
본문14 / 500 · 좌 8 / 우 24 · word-break: keep-all · line-height 1.4
아이콘24×24
닫기 ✕버튼 24×24(패딩 4) · svg 16×16 · --color-hr-gray-600
등장150ms cubic-bezier(.4,0,.2,1) · translateY(-40px) → 0 + 페이드

컨테이너가 pointer-events: none 이고 카드만 auto 라, 토스트가 떠 있어도 그 옆 화면은 그대로 클릭됩니다. 이 한 쌍을 깨뜨리지 마세요 — 컨테이너가 상단 폭 전체를 덮고 있어서, none 을 빼면 보이지 않는 띠가 클릭을 먹습니다.

앱에 붙이는 법 ​

1) 스토어 등록 — 앱 main.ts 에서 Alpine.start() 앞에 둡니다.

ts
Alpine.store("toast", toastStore);

지금은 toastStore 객체가 admin·drive 각각의 main.ts 안에 통째로 복사돼 있습니다(두 사본이 글자까지 같습니다). 세 번째 앱이 필요해지면 복사하지 말고 @hereby/components 로 빼는 것을 먼저 논의하세요.

2) 컨테이너 마크업 — 앱 셸 HTML 에 한 번만 둡니다.

html
<div class="ds-toast-container" aria-live="polite" aria-atomic="true">
  <template x-for="t in $store.toast.items" :key="t.id">
    <div class="ds-toast" :class="'ds-toast--' + t.variant" role="status"
      x-transition:enter="ds-toast-anim-enter"
      x-transition:enter-start="ds-toast-anim-enter-start"
      x-transition:enter-end="ds-toast-anim-enter-end">
      <span class="ds-toast__ic" aria-hidden="true">
        <template x-if="t.variant === 'success'">
          <span class="ds-ico" aria-hidden="true" x-html="$icon('ic-check-circle-fill')"></span>
        </template>
        <!-- error → ic-x-circle-fill · info → ic-info-circle-fill -->
      </span>
      <div class="ds-toast__body" x-text="t.message"></div>
      <button class="ds-toast__close" type="button" aria-label="닫기"
        @click="$store.toast.dismiss(t.id)">
        <span class="ds-ico" aria-hidden="true" x-html="$icon('ic-close')"></span>
      </button>
    </div>
  </template>
</div>

3) CSS — .ds-toast* 는 정본에 없으므로 지금은 앱 main.css 로 옮겨야 합니다. 이게 문제라는 것을 알고 있고, 승격 계획은 검토 필요 사항에 있습니다.

접근성 — aria-live 를 빼지 마세요

컨테이너의 aria-live="polite" + aria-atomic="true" 가 없으면 스크린리더는 토스트가 떴다는 것을 아무 방법으로도 알 수 없습니다. 토스트는 포커스를 가져가지 않기 때문입니다. 카드의 role="status" 도 같은 이유로 짝입니다.

스토어 API ​

admin · drive

ts
push(variant, message, autoClose = 3000): number   // id 반환
success(message, autoClose?): number
error(message, autoClose?): number
info(message, autoClose?): number
dismiss(id): void
items: { id, variant, message }[]

autoClose 에 false 를 넘기면 사라지지 않습니다 — 직접 dismiss(id) 로 내려야 합니다. 긴 작업의 진행 안내에 쓰고 싶어질 수 있는데, 그건 토스트가 아니라 스피너나 카운터 배너 자리입니다.

employee-app (createToastStore())

ts
show(message, variant = "info", duration = 3000): void
success(message) / error(message) / info(message): void
dismiss(id): void
hide(): void            // 전부 내림(화면 전환 등)
items: { id, message, variant, timer }[]   // [0]이 최신
get message: string     // 하위 호환 — 최신 것의 문구
get visible: boolean    // 하위 호환 — 하나라도 떠 있나

message · visible 은 단일 슬롯 시절의 판독용 getter입니다. 새 코드는 items 를 직접 그리세요.

앱 안 상단 배너 — 직원 앱 ​

직원 앱이 앞에 떠 있는 동안 ① 지금 보고 있지 않은 대화방의 새 메시지 ② 새 알림(알림함)이 오면, OS 배너 대신 앱이 상단에 카드 한 장을 내립니다(2026-10-12 디자이너 결정). 토스트와는 축이 다릅니다 — 토스트는 내가 방금 한 일의 결과, 배너는 밖에서 온 소식입니다.

항목값
자리헤더 버튼 줄과 같은 선(상태바 + 8) · 좌우 12
카드흰 면 · 모서리 16 · 안쪽 12 · --ds-shadow-lg
내용썸네일(app-thumb 40) · 제목(b4 600, 한 줄) · 「지금」 · 본문(b4, 한 줄 말줄임)
썸네일1:1 = 보낸 사람 머리글자 · 단체방 = ic-users · 이름 숨김 표시 = ic-message · 알림 = ic-bell(주황)
크기헤더처럼 화면 설정과 무관하게 고정 — 층에 zoom: var(--app-nav-zoom), 디자인 값 ÷1.2
층--ds-z-fab(44) — 헤더 · 하단 내비 위, 모달 · 컨펌 아래
수명4초. 연달아 오면 최신으로 갈아 끼우고 4초를 다시 셉니다(쌓지 않음)
닫기 · 누르기위로 밀면 닫힘 · 누르면 그 방(메신저) / 그 알림의 화면으로 이동하고 알림은 읽음
움직임위에서 내려오고 위로 올라감(280ms). 모션 줄이기 설정이면 투명도만

띄우지 않는 경우 — 보고 있는 대화방의 메시지 · 알림을 끈 방 · 내가 보낸 메시지 · 알림 화면을 보고 있을 때(목록 맨 앞에만 끼움) · 앱이 백그라운드일 때(OS 알림 몫) · 시안용 더미(dummy-). 그리고 휴대폰 알림 설정을 그대로 따릅니다 — 푸시 스위치 · 전체 알림 받기 · 종류별 스위치 · 방해 금지 시간 · 메신저 알림 표시(이름 + 본문 / 이름만 / 일반). 서버 푸시 관문과 같은 규칙(isPushAllowed)입니다.

OS 배너는 앱이 앞에 있을 때 나오지 않습니다 — 네이티브는 presentationOptions: ["badge"](capacitor.config.ts, 네이티브 재빌드 필요), 웹은 서비스 워커가 보이는 창이 있으면 알림을 생략합니다.

출처: apps/employee-app/src/stores/in-app-banner-store.ts(상태) · utils/in-app-banner.ts(규칙) · in-app-banner.ts(연결) · views/app-shell.html · styles/main.css 의 .app-inapp-banner*. 직원 앱 전용이라 design-system.css 정본에는 없습니다.

토스트가 아닌 것 ​

  • 급여 시트 붙여넣기 요약 — apps/hr-system/src/views/tabs/sheets/paste-toast.html. 이름만 toast 이고, 붙여넣기 뒤 생성/건너뜀/오류 건수를 알리는 시트 전용 줄입니다. 화면 아래쪽에 뜨고 유틸 조합(bg-slate-900 text-white text-xs)으로 그려지며 z-50 입니다. 이 규격을 토스트로 착각해 따라 하지 마세요.
  • 알림(notification) — NotificationBell 의 인박스는 서버가 만든 기록이고, 토스트는 지금 이 화면의 즉시 피드백입니다. 둘은 다른 축입니다.

검토 필요 사항 ​

  • 정본이 없습니다. .ds-toast* 는 packages/static/styles/design-system.css 에 없고 admin · drive main.css 두 곳에 복사돼 있습니다. 문서 데모까지 세면 세 벌입니다(docs/.vitepress/theme/ds-toast-demo.css). 두 앱 사본의 차이는 animation 을 본 블록에 합쳤는지(drive) 따로 얹었는지(admin)뿐이고 계산값은 같습니다 — 지금이 정본으로 올리기 가장 쉬운 시점입니다.
  • 스토어도 복사본입니다. toastStore 객체가 admin main.ts 와 drive main.ts 에 각각 들어 있고 글자까지 같습니다. @hereby/components 로 빼는 것이 맞습니다.
  • z 값이 토큰을 안 씁니다. 토큰 --ds-z-toast: 80 이 있는데 admin·drive 컨테이너는 z-index: 999999 를 하드코딩했습니다. 직원 앱만 var(--ds-z-toast) 를 씁니다. 999999 는 어떤 팝업 위에도 뜨지만, 그 값이 필요한 이유가 기록돼 있지 않습니다.
  • 직원 앱은 톤을 버립니다. 스토어는 variant 를 받지만 마크업(app-shell.html)이 쓰지 않아 성공·실패가 똑같은 검은 알약으로 보입니다. 실패를 실패로 못 알아봅니다.
  • info 만 노랑입니다. notice 의 --info 는 파랑 계열인데 토스트만 --color-hr-yellow-500 입니다. 정본 승격 때 함께 정해야 합니다.
  • 쌓임 순서가 계열별로 반대입니다. admin·drive 는 오래된 것이 위, 직원 앱은 최신이 위. 한 제품 안에서 두 규칙이 도는 상태입니다.

변경 이력 ​

날짜내용
2026-10-12앱 안 상단 배너 — 직원 앱 절 추가 — 토스트가 아닌 한 장짜리 상단 카드(새 메시지 · 새 알림), 규칙 요약에 구분 한 줄
2026-09-09페이지 신규 작성. 제품에 이미 쓰이고 있는 두 계열(admin·drive .ds-toast / employee-app)과 시트 전용 paste-toast 를 실제 코드에서 읽어 정리했습니다. 라이브 Playground 신설(ds-toast-demo.css = admin main.css verbatim)
2026-08-21(직원 앱) 단일 슬롯 → 스택. 부팅 시 오류가 사용자 피드백을 지우는 실측 사례에서 나왔고, 최대 3개·같은 문구 중복 방지가 함께 들어갔습니다