GODRICH

토스트 알림 UI 9종 — 쌓이고 밀리고 사라지는 스택

토스트 알림 UI는 화면 구석에 잠깐 떴다 사라지는 알림을 어떻게 쌓고 어떻게 치울지 정하는 설계입니다. 아홉 개 전부 라이브러리 없이 바닐라 JS와 SCSS로만 돌아가고, 첫 번째는 새 알림이 앞선 카드를 위로 밀어 올리는 그 동작 하나만 담았습니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

순서는 인기순이 아니라 알림 하나가 화면에서 겪는 순서입니다. 먼저 들어와 자리를 잡고(01), 무슨 일인지 색과 아이콘으로 밝히고(02), 남은 시간을 보여 주며 기다립니다(03). 그다음은 치우는 쪽입니다. 손으로 밀어 날리고(04), 잘못 지운 것을 되돌리고(05), 결과가 늦게 오는 일은 같은 자리에서 답으로 갈립니다(06). 마지막 셋은 여러 개가 한꺼번에 왔을 때입니다. 넘치면 묶어 접고(07), 여섯 자리 중에서 뜰 곳을 골라 옮기고(08), 화면을 보지 못하는 사람에게 읽히는 순서를 나눕니다(09). 카드가 쌓이는 항목은 골격이 하나로 같습니다. 스택은 높이를 못 박은 상자 하나 안에 들어가고 카드는 그 안에서 transform으로 움직입니다. 카드 자체를 top이나 margin으로 밀면 스택이 길어질 때마다 브라우저가 레이아웃을 다시 재기 때문입니다.

01밀려 올라가는 스택

새 카드는 목록 맨 아래에 들어가고, 그 위에 있던 카드들은 한 칸씩 위로 올라갑니다. 올라가는 모습이 뚝뚝 끊기지 않게 하려면 넣기 전 위치를 먼저 재 두었다가, 새 카드를 넣은 뒤 옛 자리로 되돌려 붙였다 한 프레임 뒤에 놓아 줍니다. 이 네 걸음이 FLIP이고, 브라우저는 마지막 걸음만 애니메이션으로 봅니다.

getBoundingClientRecttranslateYrequestAnimationFrame
var before = [].map.call(list.children, function (el) { return el.getBoundingClientRect().top; });
list.insertBefore(el, list.firstChild);
[].forEach.call(list.children, function (node, i) {
  if (!i) return;
  var d = before[i - 1] - node.getBoundingClientRect().top; // 옛 위치 − 새 위치 = 되돌릴 거리
  node.style.transition = 'none';
  node.style.transform = 'translateY(' + d + 'px)';
});
requestAnimationFrame(function () { // 한 프레임 뒤에 풀어야 브라우저가 되돌린 위치를 시작점으로 본다
  [].forEach.call(list.children, function (node, i) {
    if (!i) return;
    node.style.transition = '';
    node.style.transform = '';
  });
});

02종류별 아이콘과 색

성공·경고·오류·안내 네 종류가 카드 한 장 안에 겹쳐 있고, 카드의 data-kind 값 하나가 어느 얼굴을 보일지 정합니다. 자동 루프에서는 네 얼굴이 0.5초마다 돌아가며 켜지는데, 갈리는 순간을 steps(1, end)로 끊어 두 얼굴이 반투명하게 겹치는 프레임을 없앴습니다. 음수 animation-delay만 다르게 주면 같은 키프레임 하나로 네 개를 돌릴 수 있습니다.

data-kindsteps(1, end)currentColor
.tk.is-demo .tk__face {
  animation-name: tk-face;
  animation-duration: $dur-loop;
  animation-timing-function: steps(1, end);
  animation-iteration-count: infinite;
}
.tk.is-demo .tk__face[data-kind="ok"]   { animation-delay: 0s; }
.tk.is-demo .tk__face[data-kind="warn"] { animation-delay: -1.5s; }
.tk.is-demo .tk__face[data-kind="bad"]  { animation-delay: -1s; }
.tk.is-demo .tk__face[data-kind="info"] { animation-delay: -0.5s; }
@keyframes tk-face {
  0%   { opacity: 1; }
  25%  { opacity: 0; }
  100% { opacity: 0; }
}

03남은 시간 바와 일시정지

바닥의 바는 scaleX로 줄어들고, 축을 왼쪽에 두었기 때문에 오른쪽 끝부터 닳습니다. 마우스를 올리거나 키보드 초점이 들어오면 바에는 animation-play-state: paused를 걸고 닫힘 타이머는 아예 지우며, 벗어날 때 흘러간 시간을 뺀 나머지로 타이머를 다시 겁니다. 둘 중 하나만 멈추면 바는 서 있는데 알림이 닫히는 일이 생깁니다.

scaleXanimation-play-statetransform-origin
.pb__fill {
  display: block;
  height: 100%;
  background: $color;
  transform-origin: left center;
  transform: scaleX(1);
}
.pb__toast.is-on .pb__fill {
  animation-name: pb-drain;
  animation-duration: 4200ms;
  animation-timing-function: linear;
  animation-iteration-count: 1;
  animation-fill-mode: forwards;
}
.pb__toast.is-hold .pb__fill { animation-play-state: paused; }
@keyframes pb-drain {
  0%   { transform: scaleX(1); }
  100% { transform: scaleX(0); }
}

04밀어서 날려 보내기

누른 채 옆으로 끌면 카드가 손가락을 그대로 따라오고, 놓았을 때 움직인 거리가 72px을 넘으면 그 방향으로 420px 날아가며 닫힙니다. 모자라면 이동값을 0으로 되돌려 제자리로 튕겨 옵니다. 누르는 순간 setPointerCapture를 걸어 두어야 손가락이 카드 밖으로 벗어나도 이동 이벤트가 끊기지 않습니다.

setPointerCapturepointermovetranslateX(var(--dx))
card.addEventListener('pointerdown', function (e) {
  root.classList.remove('is-demo');
  down = true; x0 = e.clientX; dx = 0;
  card.setPointerCapture(e.pointerId); // 손가락이 카드 밖으로 나가도 move 가 계속 온다
  card.classList.add('is-drag');
});
card.addEventListener('pointermove', function (e) {
  if (!down) return;
  dx = e.clientX - x0;
  set(dx);
});
card.addEventListener('pointerup', function () {
  if (!down) return;
  down = false;
  card.classList.remove('is-drag');
  if (Math.abs(dx) > OUT) fling(dx < 0 ? -1 : 1); else set(0);
});

05되돌리기 버튼이 진짜로 되돌린다

지운 줄은 곧바로 없애지 않고 grid-template-rows1fr에서 0fr로 접어 숨겨만 둡니다. 되돌리기를 누르면 예약해 둔 삭제를 clearTimeout으로 취소하고 접힘만 풀면 되니, 지운 내용을 어디에 따로 보관할 필요가 없습니다. 토스트가 뜨는 자리는 높이를 미리 잡아 두어 위 목록이 밀려 내려가지 않게 했습니다.

clearTimeoutgrid-template-rowsaria-live
function drop(row) {
  root.classList.remove('is-demo');
  if (pending) commit();
  pending = row;
  row.classList.add('is-gone');
  toast.classList.add('is-on');
  clearTimeout(timer);
  timer = setTimeout(commit, 4000);
}
function undo() {
  clearTimeout(timer);
  if (pending) pending.classList.remove('is-gone');
  pending = null;
  toast.classList.remove('is-on');
}

06로딩에서 결과로 바뀌는 토스트

같은 카드 안에서 도는 고리가 끝나는 순간 성공 또는 실패 표시로 갈립니다. 고리는 1초짜리 rotate 한 바퀴를 무한히 돌고, 갈리는 지점은 프레임 사이에 놓은 steps(1, end) 컷이라 두 아이콘이 겹쳐 보이는 구간이 없습니다. 기다리는 동안에는 카드에 aria-busy="true"가 붙고 결과가 나오면 aria-busy="false"로 바뀝니다.

rotatesteps(1, end)aria-busy
.pm__spin {
  animation-name: pm-spin;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}
@keyframes pm-spin {
  0%   { rotate: 0deg; }
  100% { rotate: 360deg; }
}
@keyframes pm-load {
  0%   { opacity: 1; }
  35%  { opacity: 0; }
  56%  { opacity: 1; }
  77%  { opacity: 0; }
  89%  { opacity: 1; }
  100% { opacity: 1; }
}

07세 개가 넘으면 접히는 묶음

카드마다 자기 순번을 --i에 담고 있고, 접힘과 펼침은 그 숫자에 곱하는 간격과 크기만 다릅니다. 접혔을 때는 12px씩 어긋나며 scale이 조금씩 줄어 덱처럼 보이고, 펼치면 36px 계단이 되며 모두 같은 크기로 돌아옵니다. 쌓이는 순서는 z-index: calc(9 - var(--i)) 한 줄이 맡습니다.

aria-expanded--iscale
.cg__item {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  margin: 0;
  padding: 0;
  z-index: calc(9 - var(--i));
  transform: translateY(calc(var(--i) * var(--fold) * -1)) scale(calc(1 - var(--i) * .04));
  transition: transform $duration $easing;
  cursor: pointer;
}
.cg__list.is-open .cg__item { transform: translateY(calc(var(--i) * var(--step) * -1)) scale(1); }

08여섯 자리 위치 전환

창의 data-pos 값 하나가 알림 묶음의 insettransform을 함께 바꿔, 위아래 세 자리씩 여섯 곳 중 하나로 붙입니다. 가운데 자리는 inset의 왼쪽 값을 50%로 두고 translate(-50%)를, 오른쪽 자리는 calc(100% - 8px)translate(-100%)를 짝지어 묶음 폭을 몰라도 맞아떨어지게 했습니다. 두 값을 같은 시간으로 전환하니 자리가 바뀔 때 끊기지 않고 미끄러집니다.

data-posinsetaria-pressed
.ps__toasts {
  position: absolute;
  inset: calc(100% - 8px) auto auto calc(100% - 8px);
  display: flex;
  flex-direction: column;
  gap: $sp-1;
  width: 148px;
  transform: translate(-100%, -100%);
  transition: inset $duration $easing, transform $duration $easing;
}
.ps__screen[data-pos="tl"] .ps__toasts { inset: 30px auto auto 8px;               transform: translate(0, 0); }
.ps__screen[data-pos="tc"] .ps__toasts { inset: 30px auto auto 50%;               transform: translate(-50%, 0); }
.ps__screen[data-pos="tr"] .ps__toasts { inset: 30px auto auto calc(100% - 8px);  transform: translate(-100%, 0); }
.ps__screen[data-pos="br"] .ps__toasts { inset: calc(100% - 8px) auto auto calc(100% - 8px); transform: translate(-100%, -100%); }

09읽히는 순서를 나누는 라이브 리전

왼쪽 줄은 읽고 있던 문장을 끝까지 두고 차례를 기다리고, 오른쪽 줄은 읽던 것을 끊고 끼어듭니다. 둘의 차이는 역할 이름과 aria-live 값 한 쌍뿐이라, 오류만 오른쪽으로 보내면 나머지 알림이 사용자의 읽기를 방해하지 않습니다. aria-atomic="true"는 글자가 바뀔 때 바뀐 조각만이 아니라 문장 전체를 다시 읽게 합니다.

role="alert"aria-live="assertive"aria-atomic
<p class="lr__card" role="status" aria-live="polite" aria-atomic="true" aria-label="정중한 알림 줄">
  <span class="lr__dot" aria-hidden="true"></span>
  <span class="lr__msg">메일을 보냈습니다</span>
</p>
<p class="lr__card" role="alert" aria-live="assertive" aria-atomic="true" aria-label="급한 알림 줄">
  <span class="lr__dot" aria-hidden="true"></span>
  <span class="lr__msg">결제가 거절됐습니다</span>
</p>

어디서 깨지는가 — 함정

토스트는 사라지는 게 일인 부품입니다. 그래서 아홉 개를 2초 루프로 돌려 보니 다른 편에서는 없던 문제가 나왔습니다. 대표 이미지는 데모마다 프레임 24장 가운데 앞 장과 가장 크게 달라진 한 장으로 뽑는데(편의 표지는 그중 01번 것을 씁니다), 토스트 데모에서 가장 크게 달라지는 순간은 언제나 카드가 없어지는 순간입니다. 03번 첫 판이 딱 그랬습니다. 대표 이미지가 주황 바탕에 버튼 하나만 덩그러니 남은 빈 화면으로 나왔고, 알림이 떠 있는 장면은 한 장도 뽑히지 않았습니다. 고친 방법은 단순합니다. 루프에서는 카드를 지우지 않고, 바가 다 닳으면 카드가 한 번 내려앉았다 돌아오고 다음 알림이 들어온 것처럼 바가 다시 차게 했습니다. 닫히는 동작 자체는 버튼과 자동 타이머로 여전히 돌아갑니다.

두 번째는 페이드가 놓이는 자리입니다. 2초를 24장으로 나누면 샘플 간격이 4.1667%인데, 사라지는 구간을 그 간격에 걸치게 두면 반투명한 유령 카드가 찍힙니다. 그래서 02·06·09번의 상태 교체는 전부 steps(1, end) 컷으로 끊었고, 06·09번은 그 컷을 샘플과 샘플 사이(35%·45%·56%·77%·89%)에 놓았습니다. 01번은 카드가 세 장이라 사정이 다릅니다. 셋을 한꺼번에 없애면 그 빈 화면이 또 대표가 되므로 78%·82%·86%에 한 장씩 끊어 뺐습니다. 세 번째는 크기입니다. 절대 배치한 토스트는 부모 높이 계산에 끼지 않아서, 무대는 멀쩡한데 실제 문서 높이만 늘어납니다. 그래서 카드가 상자 밖으로 나가는 01·02·03·04·05·08번은 스택을 높이를 못 박은 상자에 넣고 overflow: hidden으로 잘랐습니다. 320×200 무대에서 여백을 빼면 세로로 쓸 수 있는 건 174px뿐이라, 좁은 화면에서는 카드 간격을 44px에서 34px로 줄이고 상자 높이도 함께 낮춥니다. 아홉 벌 소스가 든 압축 파일의 비밀번호는 qh4e8nxm 이고, 지금 보고 있는 아홉 개의 SCSS와 React 판이 여기서 고친 값 그대로 들어 있습니다.

접근성

움직임을 줄여 달라는 설정(prefers-reduced-motion: reduce)이 켜져 있으면 아홉 개 모두 자동 재생만 멈추고 지금 상태는 화면에 그대로 둡니다. 01번 카드는 미끄러지는 과정 없이 세 칸에 그대로 서고, 03번 바는 줄어들기를 멈추며, 09번의 읽는 막대는 한 높이로 선 채 어느 줄이 켜져 있는지는 색으로 계속 보여 줍니다. 누르고 닫는 길도 마우스 하나로만 만들어 두지 않았습니다.

항목 마우스 키보드 상태 알림
01 스택 버튼 클릭 Enter · Space aria-live="polite"로 새 줄만
03 진행 바 호버로 멈춤 Tab 초점으로 멈춤 닫기 버튼에 aria-label
04 스와이프 끌어서 놓기 좌우 화살표로 그 방향 닫기 뒤 카드가 남아 흐름이 끊기지 않음
05 되돌리기 휴지통 클릭 Tab · Enter role="status"로 지움 안내
06 프로미스 버튼 클릭 Enter · Space aria-busy로 진행 여부
07 묶음 카드·버튼 클릭 Enter · Space aria-expanded
08 자리 전환 자리 버튼 클릭 Tab · Enter aria-pressed · 자리마다 aria-label
09 두 줄기 버튼 클릭 Enter · Space role="status"role="alert"로 분리

라이브 리전 두 종류의 차이, 특히 role="status"가 왜 기본값이어야 하는지는 MDN의 status 역할 문서에 정리되어 있습니다. 04번이 쓰는 포인터 잡기도 MDN의 setPointerCapture 문서에 조건이 적혀 있는데, 잡아 두지 않으면 카드 밖으로 나간 순간 이동 이벤트가 끊겨 카드가 중간에 멈춥니다. 같은 계열로 알려 주는 부품은 알림 카테고리에, 사람이 누르지 않아도 스스로 도는 부품은 자동 실행 카테고리에 모여 있습니다.

FAQ

알림이 동시에 다섯 개씩 오면 어떻게 처리하나요?

화면에 두는 개수를 정해 두고 넘치면 오래된 것부터 빼거나(01번은 세 장으로 잘라 냅니다) 07번처럼 접어 묶습니다. 둘 다 "몇 개까지"라는 숫자 하나만 바꾸면 되도록 짜 두었습니다. 개수를 제한하지 않으면 세로로 길어져 화면 아래쪽 버튼을 가려 버립니다.

자동으로 닫히는 시간은 몇 초가 적당한가요?

03번은 4.2초로 잡았습니다. 읽는 데 걸리는 시간과 한 줄 길이에 따라 달라지지만, 액션 버튼이 붙은 알림은 누를 시간을 줘야 하므로 05번처럼 넉넉히(4초 이상) 두고, 마우스를 올리면 멈추도록 하는 편이 안전합니다. 되돌릴 수 없는 오류는 아예 자동으로 닫지 않는 쪽을 권합니다.

이 아홉 개를 토스트 라이브러리 대신 써도 되나요?

한 화면에서 알림을 띄우는 자리가 한 곳이고 종류가 네다섯 가지라면 그대로 써도 무리가 없습니다. 스택 관리·자동 닫힘·되돌리기·라이브 리전까지 여기 다 들어 있고 의존성이 없기 때문입니다. 여러 화면에서 아무 데서나 호출해야 하거나 서버 이벤트와 묶어야 한다면, 이 코드를 라이브러리의 렌더 함수 자리에 끼워 모양만 가져가는 쪽이 낫습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.