토스트(스낵바) 직접 만들기
화면을 만드는데 디자인 시안에 'snackbar(스낵바)'라는 게 계속 나왔다.
화면 아래에 잠깐 떴다 사라지는 안내 띠인데, 솔직히 처음 보는 단어였다.
'토스트'는 들어봤어도 직접 만들어본 적은 없었고, "토스트랑 스낵바는 같은 건가, 다른 건가?"부터 막혔다.
처음엔 "라이브러리 하나 가져다 쓰면 되겠지" 싶었는데, 파고들수록 alert이랑 뭐가 다른지,
어떻게 저절로 사라지는지, 위치는 어떻게 바꾸는지 궁금한 게 줄줄이 나왔다.
결국 직접 만들어보면서 하나씩 알게 됐다. 토스트를 처음 건드리는 사람 입장에서 막혔던 지점들을 순서대로 정리해둔다.
토스트/스낵바는 화면에 잠깐 떴다 사라지는 안내 띠다.
숨겨둔 요소를 잠깐 보여줬다가 setTimeout으로 도로 숨기는 게 전부고, 스낵바는 거기에 바로가기(액션) 버튼이 붙은 진화형이다.
마크업/CSS가 8할이라 처음 만들기에도 부담이 없다.
아래 코드는 그대로 복사해
.html파일로 저장하면 브라우저에서 바로 돌아간다.
화면에 잠깐 떴다가 알아서 사라지는 안내 띠. 보통 화면 아래쪽에 뜬다.
평소 쓰던 alert(), confirm() 팝업이랑 헷갈리기 쉬운데 용도가 다르다.
| alert / confirm | 토스트 / 스낵바 | |
|---|---|---|
| 생김새 | 화면 가운데 팝업 박스 | 화면 가장자리(보통 하단) 가로 띠 |
| 닫는 법 | 사용자가 "확인"을 눌러야 사라짐 | 2~3초 뒤 알아서 사라짐 (X는 선택) |
| 화면 막기 | 누를 때까지 막음 (모달) | 안 막음. 그 위로 계속 조작 가능 |
| 쓰는 상황 | "변경하시겠습니까?" 같은 질문/결정 | "변경되었습니다" 같은 결과 통보 |
흐름은 보통 이렇게 짝이 된다:
작업 → alert 로 한 번 확인받고 → 처리 → 토스트로 "됐어요" 알려주고 슥 사라짐.
뿌리는 같은데 할 수 있는 게 다르다.
| 일반 토스트 (Toast) | 스낵바 (Snackbar) | |
|---|---|---|
| 출신 | 안드로이드 원조 (2008년경) | 머티리얼 디자인 (2014년) |
| 내용 | 텍스트(+아이콘)만 | 텍스트 + 액션 버튼 + 닫기(X) |
| 상호작용 | 없음 (그냥 떴다 사라짐) | 있음 (바로가기 누르기 / X로 닫기) |
웹에서는 둘을 엄격히 안 나누고 그냥 "토스트"라고 뭉뚱그려 부르는 경우가 많다.
문서에 snackbar(=토스트) 라고 같이 적혀 있어도 놀라지 말 것 — 같은 걸 가리키는 두 이름이다.
참고: 스낵바 형태(다크 띠 · 하단 · 텍스트+액션 · 자동소멸)는 구글 머티리얼 디자인이 정립한 패턴이다.
토스트 라이브러리(Toastify, iziToast, react-toastify 등)는 거의 다 4가지 타입을 표준으로 쓴다.
타입마다 색과 아이콘이 약속돼 있어서, 사용자가 색만 봐도 성공인지 오류인지 안다.
| type | 색 | 아이콘 | 쓰는 상황 |
|---|---|---|---|
success | 초록 | ✔ | 작업 성공 ("저장되었습니다") |
error | 빨강 | ✖ | 실패/오류 ("저장에 실패했습니다") |
warning | 주황 | ⚠ | 주의 필요 ("입력값을 확인해 주세요") |
info | 파랑 | ⓘ | 일반 정보 ("새 버전이 있습니다") |
스낵바는 요소 4개로 끝난다.
여기에 타입이 있으면 텍스트 앞에 타입 아이콘(✔/✖/⚠/ⓘ)이 하나 더 붙는다.
마크업으로 치면 div 하나 안에 (아이콘) + 텍스트 + (링크) + (X) 넣는 정도다.
토스트 박스를 화면에 미리 하나 만들어 둔다. 평소엔 CSS로 숨겨두고, 필요할 때 보였다 숨겼다 한다.
<div id="snackbar" class="snackbar">
<span class="snackbar__text"></span>
</div>
내용(아이콘·텍스트·액션·X)은 보여줄 때 JS가 채워 넣는다.
핵심은 opacity:0(평소 숨김) + .show일 때 opacity:1(보임). 클래스 하나로 켰다 껐다 한다.
타입은 modifier 클래스(snackbar--success 등)로 색만 바꿔준다.
/* (1) 토스트 박스: 평소엔 안 보이는 상태로 대기 */
.snackbar {
position: fixed; /* 스크롤해도 화면에 고정 */
left: 50%; /* 가로 중앙 기준점 */
bottom: 30px; /* 화면 아래쪽 */
transform: translateX(-50%); /* 자기 폭의 절반만큼 왼쪽으로 = 진짜 가운데 */
width: 90%; max-width: 420px; box-sizing: border-box;
display: flex; align-items: center; gap: 10px;
min-height: 44px; padding: 14px 16px; border-radius: 10px;
background: #222; color: #fff; /* 다크 배경 + 흰 글씨 */
font-size: 14px;
opacity: 0; /* ★ 평소엔 투명 = 안 보임 */
pointer-events: none; /* 안 보일 땐 클릭도 막기 */
transition: opacity .3s, transform .3s; /* 부드럽게 나타나고 사라지게 */
}
/* (2) 'show' 클래스가 붙으면 → 보인다 */
.snackbar.show {
opacity: 1;
pointer-events: auto;
transform: translateX(-50%) translateY(0);
}
.snackbar__icon { flex-shrink: 0; font-size: 16px; } /* 타입 아이콘 */
.snackbar__text { flex: 1; } /* 텍스트는 남는 공간 다 차지 */
.snackbar__action { /* 액션(바로가기) = 파란 링크 */
color: #5b9dff; font-weight: bold; text-decoration: none; white-space: nowrap;
}
/* ===== 표준 타입: 왼쪽 accent 보더 + 같은 색 아이콘 ===== */
.snackbar--success { border-left: 4px solid #2ecc71; }
.snackbar--success .snackbar__icon { color: #2ecc71; }
.snackbar--error { border-left: 4px solid #e74c3c; }
.snackbar--error .snackbar__icon { color: #e74c3c; }
.snackbar--warning { border-left: 4px solid #f39c12; }
.snackbar--warning .snackbar__icon { color: #f39c12; }
.snackbar--info { border-left: 4px solid #3498db; }
.snackbar--info .snackbar__icon { color: #3498db; }
새로 배우는 건 사실상 setTimeout 한 줄뿐이다.
setTimeout(할 일, 3000) = "3000밀리초(=3초) 뒤에 이거 해줘" 라고 알람을 예약하는 함수.
var hideTimer = null; // 자동 숨김 타이머 기억용 (새 토스트 뜨면 이전 거 취소)
// 타입별 아이콘 (HTML 엔티티)
var ICONS = { success:'✔', error:'✖', warning:'⚠', info:'ⓘ' };
function showSnackbar(message, options) {
options = options || {};
var bar = document.getElementById('snackbar');
// 클래스 초기화 후, 타입 modifier 다시 붙이기
bar.className = 'snackbar';
if (options.type) bar.classList.add('snackbar--' + options.type); // success/error/warning/info
// 내용 그리기: (타입 아이콘) + 텍스트 + (액션) + X
var html = '';
if (options.type && ICONS[options.type]) {
html += '<span class="snackbar__icon">' + ICONS[options.type] + '</span>';
}
html += '<span class="snackbar__text">' + message + '</span>';
if (options.action) { // 액션 있을 때만 링크 추가
html += '<a class="snackbar__action" href="' + (options.href || '#') + '">'
+ options.action + '</a>';
}
html += '<button class="snackbar__close">×</button>';
bar.innerHTML = html;
// 보여주고 → 3초 뒤 자동 숨김
bar.classList.add('show');
clearTimeout(hideTimer);
hideTimer = setTimeout(hideSnackbar, 3000);
}
// 숨기기 = show 클래스 떼기 (X 누를 때도 이거 호출)
function hideSnackbar() {
clearTimeout(hideTimer);
document.getElementById('snackbar').classList.remove('show');
}
동작 정리:
1. 클래스 초기화 → 타입 modifier 붙이기 (색 결정)
2. 내용 그리고 classList.add('show') → 보여줘
3. setTimeout(..., 3000) → 3초 뒤 숨겨 (= 자동 소멸의 정체)
4. X를 누르면 → 기다리지 말고 바로 hideSnackbar()
setTimeout의3000은 밀리초다.1000= 1초. 그래서 3000이 3초.
옵션만 바꾸면 같은 함수로 토스트·스낵바·타입별 알림이 다 나온다.
showSnackbar('저장되었습니다.', { type:'success' }); // 초록 성공
showSnackbar('저장에 실패했습니다.', { type:'error' }); // 빨강 오류
showSnackbar('입력값을 다시 확인해 주세요.', { type:'warning' }); // 주황 경고
showSnackbar('새 버전이 있습니다.', { type:'info' }); // 파랑 정보
showSnackbar('항목을 삭제했습니다.', { action:'실행취소', href:'#' }); // 스낵바(액션, 타입 없음)
showSnackbar('링크가 복사되었습니다.'); // 중립 토스트
type 안 주면 → 중립(다크) 토스트action 주면 → 파란 바로가기 링크 (= 스낵바){ type:'success', action:'보기', href:'...' })긴 메시지는 따로 모드가 없어도 자동으로 줄바꿈된다. 비결은 높이를 고정하지 않는 것.
.snackbar {
/* height: 44px; ← 고정하면 안 됨 */
min-height: 44px; /* 최소만 잡고, 높이는 내용 따라 자동으로 늘어남 */
}
align-items: center 덕분에 2~3줄이 돼도 아이콘·액션·X가 세로 가운데에 예쁘게 놓인다.<br>을 넣는다. (단, innerHTML로 넣을 때만 먹는다)showSnackbar('파일을 업로드하지 못했습니다.<br>잠시 후 다시 시도해 주세요.', { type:'error' });
주의:
<br>이나 HTML을innerHTML로 넣을 땐, 사용자 입력·서버 값을 그대로 넣으면 깨지거나 보안문제(XSS)가 날 수 있다. 고정 안내문구엔 괜찮다.
스낵바는 보통 하단이지만, 위치는 결국 CSS 값일 뿐이라 어디든 갈 수 있다.
기본 클래스 + 위치별 수식 클래스(modifier) 를 같이 붙이는 방식(멀티클래스)이 정석이다.
.snackbar { bottom: 30px; } /* 기본 = 하단 */
.snackbar--top { top: 30px; bottom: auto; } /* 위 */
.snackbar--center { top: 50%; bottom: auto; transform: translate(-50%, -50%); } /* 가운데 */
<div class="snackbar">...</div> <!-- 기본 하단 -->
<div class="snackbar snackbar--top">...</div> <!-- 위 -->
<div class="snackbar snackbar--center">...</div> <!-- 가운데 -->
bottom 값을 bottom: auto로 풀어줘야 충돌이 없다.2~3줄일 때 X를 위로 붙이고 싶을 때가 있다. 이것도 모디파이어 한 줄.
.snackbar--xtop .snackbar__close { align-self: flex-start; }
align-items = 컨테이너가 자식들 전체의 세로 정렬을 정함align-self = 개별 자식이 자기 혼자만 다르게 정렬JS에서 옵션 따라 그 클래스를 붙였다 뗀다. 여기에 자동 규칙까지 넣을 수 있다:
// xtop 안 정해주면 → "액션 없을 때만 위로" 자동 적용
var xtop = (options.xtop !== undefined) ? options.xtop : !options.action;
bar.classList.toggle('snackbar--xtop', !!xtop);
classList.toggle('클래스', 조건) = 조건이 참이면 붙이고, 거짓이면 뗀다. 부를 때마다 다시 판단하므로 각 토스트가 독립적이다.
| 호출 | X 위치 | 누가 정했나 |
|---|---|---|
액션O, xtop 안 줌 | 가운데 | 자동 |
액션O, xtop:true | 위 | 내가 직접 (자동 무시) |
액션X, xtop 안 줌 | 위 | 자동 |
액션X, xtop:false | 가운데 | 내가 직접 (자동 무시) |
→ 평소엔 자동, 예외만 손으로 덮어쓰기.
X 같은 누르는 요소는 터치영역 최소 44×44px(애플 HIG) / 48px(머티리얼)로 잡는 게 표준이다.
보이는 × 글자는 작아도, 클릭되는 영역은 넓게.
.snackbar__close {
flex-shrink: 0;
width: 44px; height: 44px; /* ★ 터치영역 44px */
display: flex; /* × 글자를… */
align-items: center; /* …세로 가운데 */
justify-content: center; /* …가로 가운데 */
margin: -10px -8px; /* 커진 만큼 음수마진으로 당겨 레이아웃 보정 (안 밀리게) */
background: none; border: none; color: #aaa; font-size: 18px;
line-height: 1; padding: 0; cursor: pointer;
}
포인트 — width/height로 영역을 키우고, margin 음수로 그만큼 당겨서 다른 요소가 안 밀리게 한다.
"자동으로 사라지니까 X 작아도 되지" 가 아니라, 지금 당장 닫고 싶은 사용자를 위해 넉넉하게 잡아야 한다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>토스트/스낵바 연습</title>
<style>
body { font-family: -apple-system, "Apple SD Gothic Neo", sans-serif;
max-width: 480px; margin: 0 auto; padding: 30px 20px; line-height: 1.6; background: #f5f5f5; }
.btn { display: block; width: 100%; box-sizing: border-box; margin: 8px 0; padding: 14px;
font-size: 15px; border: 1px solid #ccc; border-radius: 8px; background: #fff; cursor: pointer; }
/* ===== 토스트(스낵바) 본체 ===== */
.snackbar {
position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%);
width: 90%; max-width: 420px; box-sizing: border-box;
display: flex; align-items: center; gap: 10px;
min-height: 44px; padding: 14px 16px; border-radius: 10px;
background: #222; color: #fff; font-size: 14px;
opacity: 0; pointer-events: none;
transition: opacity .3s, transform .3s;
}
.snackbar.show { opacity: 1; pointer-events: auto; transform: translateX(-50%) translateY(0); }
.snackbar__icon { flex-shrink: 0; font-size: 16px; }
.snackbar__text { flex: 1; }
.snackbar__action { color: #5b9dff; font-weight: bold; text-decoration: none; white-space: nowrap; }
.snackbar__close {
flex-shrink: 0; width: 44px; height: 44px;
display: flex; align-items: center; justify-content: center; margin: -10px -8px;
background: none; border: none; color: #aaa; font-size: 18px; line-height: 1; padding: 0; cursor: pointer;
}
/* 표준 타입 */
.snackbar--success { border-left: 4px solid #2ecc71; } .snackbar--success .snackbar__icon { color: #2ecc71; }
.snackbar--error { border-left: 4px solid #e74c3c; } .snackbar--error .snackbar__icon { color: #e74c3c; }
.snackbar--warning { border-left: 4px solid #f39c12; } .snackbar--warning .snackbar__icon { color: #f39c12; }
.snackbar--info { border-left: 4px solid #3498db; } .snackbar--info .snackbar__icon { color: #3498db; }
/* 위치 / X정렬 모디파이어 */
.snackbar--top { top: 30px; bottom: auto; }
.snackbar--center { top: 50%; bottom: auto; transform: translate(-50%, -50%); }
.snackbar--xtop .snackbar__close { align-self: flex-start; }
</style>
</head>
<body>
<button class="btn" onclick="showSnackbar('저장되었습니다.', { type:'success' })">success</button>
<button class="btn" onclick="showSnackbar('저장에 실패했습니다.', { type:'error' })">error</button>
<button class="btn" onclick="showSnackbar('입력값을 다시 확인해 주세요.', { type:'warning' })">warning</button>
<button class="btn" onclick="showSnackbar('새 버전이 있습니다.', { type:'info' })">info</button>
<button class="btn" onclick="showSnackbar('항목을 삭제했습니다.', { action:'실행취소', href:'#' })">스낵바 (액션)</button>
<button class="btn" onclick="showSnackbar('파일을 업로드하지 못했습니다. 잠시 후 다시 시도해 주세요.', { type:'error' })">긴 메시지 + X 자동 위</button>
<div id="snackbar" class="snackbar">
<span class="snackbar__text"></span>
</div>
<script>
var hideTimer = null;
var ICONS = { success:'✔', error:'✖', warning:'⚠', info:'ⓘ' };
function showSnackbar(message, options) {
options = options || {};
var bar = document.getElementById('snackbar');
// 클래스 초기화 → 타입 modifier
bar.className = 'snackbar';
if (options.type) bar.classList.add('snackbar--' + options.type);
// X 정렬: 안 정해주면 "액션 없을 때만 위로" 자동
var xtop = (options.xtop !== undefined) ? options.xtop : !options.action;
if (xtop) bar.classList.add('snackbar--xtop');
// 내용 그리기
var html = '';
if (options.type && ICONS[options.type]) html += '<span class="snackbar__icon">' + ICONS[options.type] + '</span>';
html += '<span class="snackbar__text">' + message + '</span>';
if (options.action) html += '<a class="snackbar__action" href="' + (options.href || '#') + '">' + options.action + '</a>';
html += '<button class="snackbar__close">×</button>';
bar.innerHTML = html;
// 보여주고 → 3초 뒤 자동 숨김
bar.classList.add('show');
clearTimeout(hideTimer);
hideTimer = setTimeout(hideSnackbar, 3000);
}
function hideSnackbar() {
clearTimeout(hideTimer);
document.getElementById('snackbar').classList.remove('show');
}
</script>
</body>
</html>
setTimeout 한 줄. 라이브러리든 직접 만들든 속은 다 이 패턴(요소 보여주고 → 타이머로 숨기기).classList로 조합. 컴포넌트 하나로 다 커버.