GODRICH

빈 화면 디자인 9종 — CSS로 그리는 빈 상태 카드

빈 화면 디자인은 목록에 표시할 데이터가 하나도 없을 때 그 빈 자리를 대신 채우는 화면입니다. 여기 아홉 개 중 하나는 데이터가 없어서가 아니라 볼 권한이 없어서 비어 있고, 그 차이를 자물쇠 하나로 구분해 말합니다.

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

아홉 개는 예쁜 순서가 아니라 화면이 비는 이유 순서로 놓았습니다. 앞의 셋은 사용자가 잘못한 게 없는 경우입니다 — 아직 아무것도 없거나(01), 검색어와 맞는 게 없거나(02), 넣을 차례인데 아직 안 넣었거나(03). 가운데 셋은 사용자가 되돌릴 수 있는 경우입니다 — 연결이 끊겼고(04), 담은 게 없고(05), 필터를 너무 많이 걸었습니다(06). 마지막 셋은 시스템이 답해야 하는 경우입니다 — 주소가 틀렸고(07), 권한이 없고(08), 09는 비어 있는 것이 오히려 목표였던 화면입니다. 아홉 개 전부 버튼이나 입력칸이 실제로 동작하고, 상태가 바뀌는 줄에는 aria-live="polite"가 붙어 있어 화면을 보지 못하는 사람에게도 바뀐 문장이 읽힙니다. 아이콘은 직접 그리지 않고 Phosphor Duotone SVG를 파일 안에 그대로 넣어 외부 요청이 0입니다.

01다 읽은 받은함 부유 카드

쟁반 아이콘이 14px만큼 떠올랐다 내려앉고, 바닥의 타원 그림자가 같은 박자로 좁아졌다 넓어집니다. 그림자를 함께 줄이는 이유는, 아이콘만 움직이면 떠 있는 것처럼이 아니라 위아래로 흔들리는 것처럼 보이기 때문입니다.

translateYscaleXaria-live
.ez.is-demo .ez__icon { animation: ez-float $duration $easing infinite; }
.ez.is-demo .ez__shadow { animation: ez-cast $duration $easing infinite; }
@keyframes ez-float {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-14px); }
}
@keyframes ez-cast {
  0%, 100% { transform: scaleX(1); opacity: .18; }
  50%      { transform: scaleX(.68); opacity: .1; }
}

02훑고 지나가는 검색 0건

입력칸에 글자를 입력하면 아래 세 줄이 실제로 걸러지고, 남는 줄이 하나도 없을 때만 돋보기 카드가 그 자리를 덮습니다. 돋보기는 좌우로 훑으며 각도까지 함께 기울어져 "아직 찾는 중"처럼 보입니다.

translateXrotatehidden
function render() {
  var v = q.value.trim();
  var hit = 0;
  rows.forEach(function (r) {
    var on = !v || r.dataset.name.indexOf(v) >= 0;
    r.hidden = !on;
    if (on) hit += 1;
  });
  empty.hidden = hit > 0;
  msg.textContent = v ? msg.dataset.hit.replace('{q}', v) : NONE;
}

03파일이 떨어지는 점선 드롭존

점선 상자 안으로 파일이 내려와 아래쪽 구름 아이콘에 닿으면 구름이 1.1배로 부풀고, 파일은 70%와 70.1% 사이에서 한 프레임 만에 사라집니다. 두 구간을 서서히 겹쳐 지우면 반투명하게 겹친 유령 프레임이 남기 때문에, 사라짐은 부드럽게가 아니라 컷으로 끊었습니다.

translateY@keyframes dz-falldragover
@keyframes dz-fall {
  0%   { transform: translateY(-28px) scale(.82); opacity: 0; }
  16%  { transform: translateY(-28px) scale(.82); opacity: 1; }
  62%  { transform: translateY(30px) scale(1); opacity: 1; }
  70%  { transform: translateY(26px) scale(1); opacity: 1; }
  70.1%, 100% { transform: translateY(-28px) scale(.82); opacity: 0; }
}

04다시 시도 화살표가 도는 오프라인

0도에서 360도까지 한 방향으로만 도는 화살표라서 마지막 프레임이 첫 프레임과 같은 그림이고, 되감기는 순간이 없습니다. 버튼을 실제로 누르면 0.9초 동안 잠기고 시도 횟수가 안내 줄에 누적됩니다.

rotatelineararia-live
.of.is-demo .of__arrow,
.of.is-trying .of__arrow { animation: of-turn $duration $easing infinite; }
@keyframes of-turn {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

05굴러 들어오는 빈 장바구니

카트가 왼쪽 74px 밖에서 굴러 들어와 38% 지점에서 멈추고, 한 번 기울었다 다시 바로 서고, 왔던 길로 나갑니다. transform-origin을 바퀴 높이인 50% 78%에 두어서 기울 때 카트 몸통이 아니라 바퀴를 축으로 돕니다.

translateXrotatetransform-origin
.ct__cart { transform-origin: 50% 78%; }
.ct.is-demo .ct__cart { animation: ct-roll $duration $easing infinite; }
@keyframes ct-roll {
  0%, 100% { transform: translateX(-74px) rotate(-14deg); }
  38%      { transform: translateX(0) rotate(0deg); }
  52%      { transform: translateX(0) rotate(-9deg); }
  66%      { transform: translateX(0) rotate(0deg); }
}

06필터 칩이 하나씩 비는 0건

칩 세 개가 180밀리초씩 어긋난 시각에 꺼지고, 꺼지는 순간은 steps(1, end)라서 중간 크기가 한 장도 안 남습니다. 칩의 ×를 실제로 누르면 그 조건만 빠지고 남은 조건 수가 다시 계산됩니다.

scaleXsteps(1, end)animation-delay
.fc.is-demo .fc__chip { animation: fc-off $duration steps(1, end) infinite; }
.fc.is-demo li:nth-child(2) .fc__chip { animation-delay: 180ms; }
.fc.is-demo li:nth-child(3) .fc__chip { animation-delay: 360ms; }
@keyframes fc-off {
  0%, 32%   { opacity: 1; transform: scaleX(1); }
  33%, 74%  { opacity: 0; transform: scaleX(.2); }
  75%, 100% { opacity: 1; transform: scaleX(1); }
}

07바늘이 헤매는 404 카드

나침반이 -26도에서 22도, -14도, 10도로 각도를 좁혀 가며 흔들립니다. 한 바퀴를 돌면 "찾았다"로 읽히고 같은 각도로 왕복하면 기계처럼 보여서, 점점 줄어드는 폭이 길을 못 찾는 느낌에 가장 가까웠습니다.

rotateborder: dashedease-out
.nf.is-demo .nf__icon { animation: nf-wander $duration $easing infinite; }
@keyframes nf-wander {
  0%, 100% { transform: rotate(-26deg); }
  28%      { transform: rotate(22deg); }
  54%      { transform: rotate(-14deg); }
  78%      { transform: rotate(10deg); }
}

08자물쇠가 한 번 튀는 권한 잠금

뒤의 목록 줄은 지워지지 않고 흐림만 3px에서 6px로 깊어졌다 돌아옵니다. 데이터가 없는 게 아니라 가려져 있다는 뜻을 그림 하나로 말하는 방법이고, 줄의 길이를 78%와 56%로 다르게 둬야 실제 글이 가려진 것처럼 보입니다.

scalefilter: bluraria-live
.pm__rows { filter: blur(3px); }
.pm.is-demo .pm__rows { animation: pm-veil $duration ease-in-out infinite; }
@keyframes pm-veil {
  0%, 100% { filter: blur(3px); opacity: 1; }
  50%      { filter: blur(6px); opacity: .6; }
}

09색종이가 오르는 할 일 완료

조각 여섯 개가 240밀리초씩 어긋나 올라가서, 앞 조각이 흐려질 때 다음 조각이 이미 중간쯤 올라와 있습니다. 가로 위치와 회전각을 --x·--r 두 변수로 조각마다 다르게 주면 키프레임은 하나만 쓰고도 여섯 갈래로 흩어집니다.

translateanimation-delaycustom property
.ad.is-demo .ad__bit:nth-child(1) { animation-delay: 0ms;   --x: -46px; --r: 180deg; }
.ad.is-demo .ad__bit:nth-child(2) { animation-delay: 240ms; --x: -26px; --r: -150deg; }
@keyframes ad-pop {
  0%   { transform: translate(var(--x, 0), 0) rotate(0deg) scale(.6); opacity: 0; }
  16%  { opacity: 1; }
  70%  { opacity: 1; }
  100% { transform: translate(var(--x, 0), -58px) rotate(var(--r, 180deg)) scale(1); opacity: 0; }
}

어디서 깨지는가 — 함정

빈 상태 카드는 거의 언제나 "목록이 있던 자리"에 들어가기 때문에, 목록을 감추는 코드와 카드를 띄우는 코드가 같은 순간에 돕니다. 02번에서 걸린 곳이 정확히 그 지점이었습니다. 자바스크립트는 r.hidden = !on으로 조건에 안 맞는 줄을 제대로 껐는데, 스타일시트에는 .sw__row { display: flex; }가 있었습니다. 브라우저가 [hidden]에 넣어 주는 display: none은 UA 스타일시트의 값이라, 작성자가 쓴 display: flex 한 줄에 그대로 밀립니다. 결과는 화면에 세 줄이 그대로 남고 그 위로 "찾은 문서가 없습니다" 카드가 반투명하게 겹치는 그림이었습니다. 렌더 측정값은 통과였습니다 — 움직임 자체는 멀쩡했으니까요. 첫 포스터를 눈으로 열어 보고서야 잡혔습니다. 고침은 .sw__row[hidden] { display: none; } 한 줄이고, display를 바꾸는 부품마다 같은 줄이 필요합니다.

둘째는 자동 재생 루프를 제 손으로 끄는 실수입니다. 02번의 render() 안에 card.classList.remove('is-demo')가 들어 있었고, 이 함수를 초기화 때도 한 번 호출했습니다. 사용자가 아무것도 안 했는데 격자용 루프가 꺼진 것이고, 측정값은 누적면적 0.00% · 강도 0.0 · 애니메이션 0개로 나왔습니다. 루프를 끄는 줄은 render() 안이 아니라 input 이벤트 핸들러 쪽으로 옮겨야 합니다. 아홉 벌의 완성 소스와 React 판은 압축 비밀번호 ydp2zjcw를 넣으면 열리는 zip 안에 들어 있고, 위 두 줄도 고쳐진 상태로 들어 있습니다.

무엇 잘못 고침
걸러진 줄이 안 사라진다 .row { display: flex } 가 UA 의 [hidden] 을 이긴다 .row[hidden] { display: none } 을 함께 쓴다
격자에서 안 움직인다 초기화 함수가 .is-demo 를 뗀다 루프 해제는 이벤트 핸들러에서만
상태가 바뀐 것을 모른다 문장만 갈아 끼웠다 그 줄에 aria-live="polite"

접근성

prefers-reduced-motion: reduce가 켜진 화면에서는 아홉 개의 루프가 전부 멈추지만 카드가 말하려던 내용은 하나도 사라지지 않습니다. 05번은 카트를 무대 밖에 세워 두지 않고 transform: translateX(0)으로 가운데에 고정하고, 08번은 흐림을 3px로 고정해 "가려져 있다"는 사실만 남깁니다. 상태가 바뀌는 줄에는 전부 aria-live="polite"를 달아 두었기 때문에, 다시 시도 버튼을 누른 뒤 "연결을 확인하는 중"으로 바뀐 문장은 화면을 보지 못하는 사람에게도 읽힙니다. aria-live의 세 값이 각각 언제 읽히는지는 MDN의 aria-live 문서에 정리돼 있습니다. 03번의 드롭존은 마우스로 끌어다 놓는 것 외에 Enter와 Space로도 파일 선택창이 열리도록 role="button"tabindex="0"을 함께 붙였습니다.

같은 자리에서 함께 쓰는 로딩 표시는 로딩 카테고리에, 관리 화면 부품은 대시보드 카테고리에 모아 두었습니다.

FAQ

빈 화면에 일러스트를 꼭 넣어야 하나요?

그림 자체가 목적이라면 안 넣는 편이 낫습니다. 여기 아홉 개가 아이콘을 쓰는 이유는 장식이 아니라 비어 있는 이유를 한눈에 구분하기 위해서입니다. 자물쇠와 끊긴 와이파이와 빈 카트는 글을 읽기 전에 이미 서로 다른 상황이라고 말합니다. 이유가 한 가지뿐인 화면이라면 문장 한 줄과 버튼 하나로 충분합니다.

이미지 파일 없이 아이콘만으로 충분한가요?

아홉 개 전부 이미지 파일을 하나도 쓰지 않습니다. Phosphor Duotone의 SVG를 HTML 안에 그대로 붙여 넣었기 때문에 네트워크 요청이 늘지 않고, color 한 줄로 무대 색에 맞춰 색이 바뀝니다. SVG는 크기를 1em으로 두고 감싸는 요소의 font-sizeclamp()로 조절하면 모바일 화면에서도 같은 비율로 줄어듭니다.

빈 상태와 로딩 상태를 같은 카드로 써도 되나요?

쓰면 안 됩니다. 로딩은 "곧 무언가 나온다"이고 빈 상태는 "나올 것이 없다"인데, 같은 카드를 쓰면 읽는 사람이 기다려야 하는지 무언가 해야 하는지 알 수 없습니다. 04번이 그 경계를 보여 주는 예로, 목록이 비었지만 원인이 데이터 없음이 아니라 요청 실패라서 안내 문장도 버튼도 다릅니다.

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

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