GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

마스코트 애니메이션 9종 — 인사부터 잠들기까지

마스코트 애니메이션은 서비스 화면에 사는 캐릭터가 상황마다 다른 몸짓과 표정으로 사람에게 말을 거는 움직임입니다. 여기 실은 아홉 개는 그림 파일도 라이브러리도 없이 타원 하나에 눈·볼·입만 얹어 CSS 로 그렸고, 받아서 열면 그 자리에서 돕니다.

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

아홉 개를 늘어놓은 기준은 인기순이 아니라 캐릭터 하나가 서비스에서 겪는 하루입니다. 앞의 넷은 사람이 아직 아무것도 하지 않은 구간이라 가만히 숨만 쉬다가(01) 인사를 하고(02) 어디를 눌러야 하는지 가리키고(03) 비밀번호 칸에서는 눈을 가려 줍니다(04). 가운데 셋은 사람이 무언가를 누른 뒤라 기다리게 하고(05) 틀렸다고 고개를 젓고(06) 됐다고 뛰어오릅니다(07). 마지막 둘은 할 일이 없는 시간으로, 만지면 눌리고(08) 오래 두면 잠듭니다(09). css 캐릭터 애니메이션을 처음 붙일 때도 이 순서로 고르면 화면 하나에 필요한 최소 세트가 먼저 잡힙니다. 몸은 아홉 개 모두 border-radius: 50% 50% 46% 46% 를 준 타원 하나이고 이목구비 좌표와 비율이 같아서, 무대 색만 갈아도 같은 캐릭터로 읽힙니다. 무대는 흰 셋(01·04·08)·노랑 셋(02·07·09)·주황 둘(03·06)·먹색 하나(05)로 나눠 격자 아홉 장이 한 덩어리로 뭉쳐 보이지 않게 했습니다. 아홉 개 모두 움직이는 경로가 둘이라, 격자 미리보기는 CSS 키프레임이 돌리고 사람이 화면에 손을 대는 순간 .is-demo 클래스가 빠지며 진짜 상태는 JS 가 맡습니다. 누를 데가 있는 03·04·06·07·08·09 는 미리보기와 별개로 실제로 누르고 입력하고 끌어서 값이 바뀌는 것까지 확인했습니다. 캐릭터 없이 말풍선과 단계 점만으로 길을 알려 주는 쪽은 웹 온보딩 UI 9종에, 04·06 이 서 있는 폼 쪽 규칙은 폼 유효성 검사 UI 9종에 있습니다. 같은 interaction 기둥에서 캐릭터 대신 손가락이 화면을 끌고 가는 키오스크 UI 디자인, 캐릭터를 세울 배경을 코드로 찍어 내는 CSS 배경 패턴이 이 편의 양옆입니다.

01숨 쉬는 대기 포즈

무엇이 보이나

몸이 2초에 한 번 천천히 부풀었다 가라앉고, 그 아래 그림자는 반대로 좁아졌다 넓어집니다. 눈은 루프의 76% 지점에서 한 프레임 만에 감겼다 82% 에 다시 떠집니다.

숨 쉬는 대기 포즈
새 창에서 열기

01 · 숨 쉬는 대기 포즈 — 측정 누적면적 2.49% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scaleYsteps(1, end)transform-originanimation-iteration-count

축을 발바닥에 두는 이유

24프레임 가운데 움직인 프레임 23/23 · 누적면적 2.49% 로 아홉 개 중에서도 얇게 움직이는 축에 드는 판이고, 세로로 늘 때 가로가 조금 줄어드는 scaleX(.975) 가 부피를 일정하게 보이게 하며, 축이 발바닥(50% 100%)이라 발이 바닥에 붙은 채로 부풀어 오르며, 그림자는 바닥에 눌린 면이라 세로로 자라지 않고 scaleX 만 반대 위상으로 움직여 몸이 늘어날 때 오히려 좁아집니다.

// 몸 — 타원 하나. 숨은 이 요소의 scaleY 로만 준다
.mb__body {
  position: relative; display: block; width: 88px; height: 96px;
  border-radius: 50% 50% 46% 46%;
  background: $color;
  transform-origin: 50% 100%;
  box-shadow: $shadow-press;
}
@keyframes mbBreath {
  0%, 100% { transform: scaleY(1) scaleX(1); }
  50%      { transform: scaleY(1.085) scaleX(.975); }
}
// 눈꺼풀 — 76%·80% 두 지점에서만 감긴다. 루프 복귀(90%)보다 앞이라 24프레임에 잡힌다
@keyframes mbBlink {
  0%, 74%   { transform: scaleY(1); }
  76%, 80%  { transform: scaleY(.08); }
  82%, 100% { transform: scaleY(1); }
}

02손 흔드는 인사

무엇이 보이나

캐릭터가 바닥 아래에서 위로 올라와 팔을 좌우로 흔들고, 팔이 바깥으로 갈 때 몸은 반대쪽으로 기웁니다. 흔들기는 등장이 끝난 직후인 560ms 지점부터 시작합니다.

손 흔드는 인사
새 창에서 열기

02 · 손 흔드는 인사 — 측정 누적면적 5.653% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

rotatetransform-originanimation-fill-modetranslateY

지연에 fill-mode 를 같이 주는 자리

등장이 0~26% 구간에서 끝나고 88% 부터는 정지 자세로 서 있어 움직인 프레임이 16/23 으로 아홉 개 중 가장 낮은데, animation-delay 만 주고 animation-fill-mode: backwards 를 빼면 지연 구간에 팔이 기본 자세로 한 프레임 번쩍였다 사라지므로 둘은 늘 붙어 다닙니다.

.wg__arm {
  position: absolute; left: 107px; top: 32px; width: 12px; height: 44px;
  border-radius: $r-pill; background: $color;
  transform-origin: 50% 95%;
  transform: rotate(24deg);
}
@keyframes wgWave {
  0%, 28%   { transform: rotate(58deg); }
  32%       { transform: rotate(24deg); }
  40%       { transform: rotate(52deg); }
  48%       { transform: rotate(24deg); }
  56%       { transform: rotate(52deg); }
  64%       { transform: rotate(24deg); }
  72%       { transform: rotate(52deg); }
  80%       { transform: rotate(24deg); }
  88%, 100% { transform: rotate(24deg); }
}
.wg.is-demo .wg__arm {
  animation-name: wgWave;
  animation-duration: $duration;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-delay: 560ms;
  animation-fill-mode: backwards;
}

03다음 버튼을 가리키는 안내

무엇이 보이나

팔이 버튼 쪽으로 뻗고 눈동자도 3px 만 같은 방향으로 옮겨 갑니다. 버튼에는 링이 퍼졌다 사라지고 안내 문구는 세 단계가 한 프레임에 갈립니다.

다음 버튼을 가리키는 안내
새 창에서 열기

03 · 다음 버튼을 가리키는 안내 — 측정 누적면적 3.006% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

rotatetranslateXaria-livebox-shadow

컷과 보간을 섞는 자리

문구 교체를 컷으로만 두면 움직인 프레임이 두세 장으로 떨어지므로 팔을 80도에서 92도로 살짝 내리는 보간을 옆에 붙여 19/23 · 누적면적 3.006% 가 나왔고, 눈알 전체가 아니라 눈동자만 3px 옮긴 것도 같은 이유인데, 그래야 얼굴이 일그러지지 않으면서 시선 방향만 바뀝니다.

@keyframes pgPoint {
  0%, 31%   { transform: rotate(80deg); }
  34%       { transform: rotate(92deg); }
  37%, 62%  { transform: rotate(80deg); }
  65%       { transform: rotate(92deg); }
  68%, 86%  { transform: rotate(80deg); }
  88%       { transform: rotate(92deg); }
  90%, 100% { transform: rotate(80deg); }
}
@keyframes pgLook {
  0%        { transform: translateX(0); }
  8%, 100%  { transform: translateX(3px); }
}
@keyframes pgMsg1 {
  0%, 31%   { opacity: 0; visibility: hidden; }
  32%, 61%  { opacity: 1; visibility: visible; }
  62%, 100% { opacity: 0; visibility: hidden; }
}
.is-demo .pg__msg--0,
.is-demo .pg__msg--1,
.is-demo .pg__msg--2 {
  animation-duration: $duration;
  animation-timing-function: steps(1, end);
  animation-iteration-count: infinite;
}

04비밀번호 칸에서 눈 가리기

무엇이 보이나

비밀번호 칸에 글자가 들어가기 시작하면 두 팔이 올라와 눈을 덮고, 칸을 비우면 다시 내려갑니다. 칸 안의 보기 단추를 켜면 팔이 내려가면서 눈이 도로 나옵니다.

비밀번호 칸에서 눈 가리기
새 창에서 열기

04 · 비밀번호 칸에서 눈 가리기 — 측정 누적면적 2.806% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

rotatetransform-originaria-hiddentype="password"

팔이 어깨에서 도는 이유

팔은 위로 미끄러지는 게 아니라 윗점(transform-origin: 5px 5px)을 축으로 28도에서 95도까지 좌우 대칭으로 도는 캡슐 하나씩이고, 축을 가운데 두면 팔이 제자리에서 도는 대신 궤도를 그리며 밀려 보이며, 미리보기 루프에서는 21/23 프레임에 변화가 잡혔습니다.

// 팔 — 어깨에서 돈다. transform-origin 을 팔 윗점(어깨 관절)에 두는 이유:
// 중심 원점이면 팔이 제자리에서 회전하는 게 아니라 궤도를 그리며 밀려 보인다.
.mc__arm {
  position: absolute; top: 42px; width: 10px; height: 40px;
  border-radius: $r-pill;
  background: $subject-blue-deep;
  transform-origin: 5px 5px;
  transition-property: transform;
  transition-duration: $dur-base;
  transition-timing-function: $easing;
}
.mc__arm--l { left: 2px; transform: rotate(28deg); }
.mc__arm--r { right: 2px; transform: rotate(-28deg); }

// 입력이 차면 팔이 얼굴 쪽으로 돌아 올라가 눈을 덮는다
.mc.is-cover .mc__arm--l { transform: rotate(-95deg); }
.mc.is-cover .mc__arm--r { transform: rotate(95deg); }
// 눈은 트윈 없이 바로 감긴다 — 중간 반쯤 감긴 프레임을 두지 않는다
.mc.is-cover .mc__eye { transform: scaleY(.1); }

05짐을 나르는 로딩

무엇이 보이나

상자를 인 캐릭터가 좌우로 왕복하는 동안 아래 막대가 왼쪽부터 차오르고, 발이 땅에 닿을 때마다 몸이 한 번 눌립니다. 퍼센트 숫자는 0부터 9까지 쌓은 기둥을 위로 올려 돌리는 오도미터입니다.

짐을 나르는 로딩
새 창에서 열기

05 · 짐을 나르는 로딩 — 측정 누적면적 33.276% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateXscaleXanimation-directionrole="progressbar"

걷는 차도를 오른쪽에 붙이는 이유

누적면적 33.276% · 강도 192.4 로 아홉 개 중 가장 크게 움직이는 판인데, 차도를 왼쪽에 고정하고 양수로 밀면 좁은 화면에서 움직이는 동안에만 가로로 넘치기 때문에 오른쪽에 붙이고 음수 translateX 로 당겼고, 왕복 2초·걸음 0.25초·카운터 0.2초가 전부 2초의 약수라 24프레임이 한 바퀴를 빠짐없이 담습니다.

// 차도(lane)는 면 전체 폭, 캐릭터는 차도의 오른쪽 끝에 붙인다 — translateX 을 음수 방향으로만 주면
// 차도 상자가 왼쪽으로만 삐져나가 scrollWidth 를 늘리지 않는다(320px 넘침 실측 300-05).
// calc(100% - 캐릭터 폭) 만큼 왼쪽으로 가면 정확히 왼쪽 끝에 닿고, 0 이면 오른쪽 끝이다.
.ml__lane {
  position: absolute; left: 0; right: 0; bottom: 0; height: 140px;
  --char-w: 104px;
  animation-name: mlWalk;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-direction: alternate;
  animation-iteration-count: infinite;
}
@keyframes mlWalk {
  0%   { transform: translateX(calc(-1 * (100% - var(--char-w)))); }
  100% { transform: translateX(0); }
}
@keyframes mlStep {
  0%, 100% { transform: translateY(0) scaleY(.9); }
  40%      { transform: translateY(-6px) scaleY(1.05); }
  80%      { transform: translateY(0) scaleY(1); }
}

06고개 젓는 실패

무엇이 보이나

확인 버튼을 누르면 얼굴이 좌우로 여덟 번 흔들리고 입력칸이 같은 박자로 덜컹입니다. 웃는 얼굴과 슬픈 얼굴은 11% 에서 12% 로 넘어가는 한 프레임에 통째로 갈립니다.

고개 젓는 실패
새 창에서 열기

06 · 고개 젓는 실패 — 측정 누적면적 9.047% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateXsteps(1, end)aria-liveanimation-name

흔들림 클래스를 떼는 자리

표정과 오류 문구, 테두리 색이 전부 같은 지점에서 컷되므로 반쯤 섞인 중간 얼굴이 남지 않고, 고개와 입력칸이 18·26·34·42·48·54·60% 같은 박자를 쓰기 때문에 둘이 한 덩어리로 흔들리며, JS 가 animationend 에서 흔들림 클래스를 떼어 같은 버튼을 다시 눌러도 또 흔들립니다.

@keyframes seHead { // 고개 젓기 — 상태 컷(12%)과 동시에 시작해 72% 에 진정
  0%, 11% { transform: translateX(0) rotate(0deg); }
  18% { transform: translateX(-10px) rotate(-7deg); }
  26% { transform: translateX(10px) rotate(7deg); }
  34% { transform: translateX(-9px) rotate(-6deg); }
  42% { transform: translateX(9px) rotate(6deg); }
  48% { transform: translateX(-7px) rotate(-5deg); }
  54% { transform: translateX(7px) rotate(5deg); }
  60% { transform: translateX(-4px) rotate(-3deg); }
  66% { transform: translateX(4px) rotate(3deg); }
  72%, 100% { transform: translateX(0) rotate(0deg); }
}
// 상태 교체는 전부 같은 지점(11% → 12%)에서 한 프레임에 컷 — steps(1, end) 가 지켜 준다
@keyframes seFaceOk { 0%, 11% { visibility: visible; } 12%, 100% { visibility: hidden; } }
@keyframes seFaceNo { 0%, 11% { visibility: hidden; } 12%, 100% { visibility: visible; } }
@keyframes seErrIn  { 0%, 11% { visibility: hidden; } 12%, 100% { visibility: visible; } }
@keyframes seEdge   { 0%, 11% { border-color: rgba($subject-ink, .25); } 12%, 100% { border-color: $error; } }

07뛰어오르는 성공

무엇이 보이나

캐릭터가 한 번 웅크렸다 뛰어오르고 별 세 개가 한두 프레임 늦게 따라 올라갑니다. 떠 있는 동안 그림자는 scaleX(.54) 까지 좁아집니다.

뛰어오르는 성공
새 창에서 열기

07 · 뛰어오르는 성공 — 측정 누적면적 11.441% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateYanimation-delayanimation-fill-modescale

별의 시차를 키프레임에 굽는 이유

무한 반복에서 지연 속성으로 시차를 주면 다음 바퀴부터 별의 주기가 점프와 어긋나므로 세 별에 12·13·14% 로 시작이 다른 키프레임을 따로 만들었고, 그래서 22/23 프레임에 변화가 잡혔습니다.

@keyframes seJump {
  0%, 4% { transform: translate(0, 0) scale(1, 1); }
  9%, 12% { transform: translate(0, 8px) scale(1.1, .8); }  // 웅크림 — 한 박자 베긴다
  16% { transform: translate(0, var(--j)) scale(.92, 1.16); } // 발사 — 프레임 사이(12~16%)에 통째로 떠오른다
  19% { transform: translate(0, var(--j)) scale(.98, 1.04); } // 정점 — 몸이 풀린다
  26% { transform: translate(0, 0) scale(1.02, .96); }    // 착지 직전
  29% { transform: translate(0, 0) scale(1.12, .84); }    // 착지 눌림
  35%, 100% { transform: translate(0, 0) scale(1, 1); }   // 회복 — 이후는 조용히 선다
}
@keyframes seShadow { // 그림자는 뜨는 동안 작아지고 착지 눌림에 잠깐 넓어진다
  0%, 4% { transform: scaleX(1); opacity: .2; }
  9%, 12% { transform: scaleX(1.12); opacity: .24; }
  16%, 19% { transform: scaleX(.54); opacity: .1; }
  26% { transform: scaleX(.92); opacity: .18; }
  29% { transform: scaleX(1.14); opacity: .24; }
  35%, 100% { transform: scaleX(1); opacity: .2; }
}

08쓰다듬으면 눌리는 반응

무엇이 보이나

머리를 문지르면 몸이 scale(1.22, .84) 로 가로로 퍼지며 눌리고, 손을 떼면 한 번 넘쳤다가 제자리로 돌아옵니다. 문지른 횟수만큼 하트가 위로 떠오릅니다.

쓰다듬으면 눌리는 반응
새 창에서 열기

08 · 쓰다듬으면 눌리는 반응 — 측정 누적면적 4.586% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scalepointerdowntransform-origintouch-action

포인터 이동을 횟수로 바꾸는 자리

가로 이동 거리를 더해 36px 을 넘을 때마다 한 번으로 세고, 포인터가 없는 사람을 위해 Enter 와 Space 에도 같은 함수를 걸어 두었으며, 폰에서 문지르는 동안 페이지가 따라 스크롤되지 않도록 캐릭터 버튼에 touch-action: none 을 줬습니다.

  btn.addEventListener('pointermove', function (e) {
    if (!down) return;
    acc = acc + Math.abs(e.clientX - lastX);
    lastX = e.clientX;
    if (acc >= RUB_PX) { acc = 0; rub(); }
  });
  // 키보드 대체 — Enter·Space 로도 한 번 쓰다듬는다. 기본 click 과 겹치지 않게 preventDefault.
  btn.addEventListener('keydown', function (e) {
    if (e.key !== 'Enter' && e.key !== ' ') return;
    e.preventDefault();
    touch();
    rub();
    press(true);
    setTimeout(function () { press(false); }, 380);
  });

09오래 두면 잠드는 유휴

무엇이 보이나

3.6초 동안 아무 입력이 없으면 눈이 감기고 Z 가 하나씩 떠오릅니다. 화면을 누르거나 키를 누르면 캐릭터가 한 번 튀며 깨고 타이머가 처음으로 돌아갑니다.

오래 두면 잠드는 유휴
새 창에서 열기

09 · 오래 두면 잠드는 유휴 — 측정 누적면적 2.336% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateYanimation-delaysetTimeoutprefers-reduced-motion

미리보기와 진짜 타이머를 겹치지 않게 두는 자리

격자 미리보기가 도는 동안에는 진짜 타이머를 걸지 않고 사람의 첫 입력이 들어온 뒤부터 재우며, 잠든 숨은 3.6초 주기로 깨어 있을 때보다 느리고 얕게 바뀌고, Z 세 개는 0초·0.46초·0.92초로 시차를 둬 하나씩 차례로 올라갑니다.

  function sleep() { root.classList.add('is-asleep'); state.textContent = ASLEEP; }
  function arm() { clearTimeout(timer); timer = setTimeout(sleep, IDLE_MS); }
  function wake() {
    var was = root.classList.contains('is-asleep');
    root.classList.remove('is-demo');
    if (was) {
      root.classList.remove('is-asleep');
      root.classList.add('is-wake');
      setTimeout(function () { root.classList.remove('is-wake'); }, 460);
      state.textContent = AWAKE;
    }
    arm();
  }
  document.addEventListener('pointerdown', wake);
  window.addEventListener('keydown', wake);

어디서 깨지는가 — 함정

캐릭터 애니메이션 만들기에서 제일 자주 무너지는 자리는 표정이 아니라 폭입니다. 아홉 개 중 일곱은 좁은 화면에서 여백과 몸 크기만 내리면 끝났는데, 폼을 낀 06 과 무대를 가로지르는 05 는 서로 다른 이유로 가로 스크롤을 만들었습니다.

320px 에서만 넘친 06

아홉 개를 480×300 과 320×200 두 폭에서 렌더했는데, 06 만 좁은 쪽에서 body.scrollWidth 가 328 로 나왔습니다. 넓은 폭에서는 멀쩡했고 고개 젓는 움직임도 정상이라 화면만 봐서는 티가 나지 않았습니다. 06 은 움직이는 선언이 열둘로 아홉 개 중 가장 많아서 처음에는 좌우로 ±10px 흔드는 폭을 의심했지만, 흔들림을 0 으로 두고 다시 재도 숫자는 그대로였습니다.

무대가 grid 라서 생긴 최소폭

무대가 display: grid 라 뿌리 .mb 가 grid 항목의 자동 최소크기, 그러니까 min-content 로 붙잡혀 있었습니다. grid 항목은 min-width 기본값이 auto 라 내용보다 작아지지 않는 성질이 있고, 그래서 320px 무대의 가용폭 294.4px 을 무시하고 309px 로 버텼습니다. 여기에 <input> 의 기본 size 에서 오는 내재 최소폭 약 177px 이 겹쳐, 폼을 아무리 줄여도 줄이 접히지 않았습니다.

정지 화면 검사가 놓치는 넘침

같은 검사를 아홉 개에 돌렸을 때 05 는 멈춰 있는 상태에서는 아무 문제가 없었습니다. 걷는 차도 상자가 폭 전체를 차지한 채 오른쪽으로 밀려 나가는 구조라, 움직이는 동안에만 가로 스크롤이 생겼기 때문입니다. 차도를 오른쪽 끝에 붙이고 음수 방향으로만 당기도록 뒤집자 삐져나가는 쪽이 화면 밖 왼쪽이 되어, 같은 궤적을 그리면서도 넘침이 사라졌습니다. 둘 다 눈으로는 보이지 않고 숫자로만 드러났는데, 하나는 멈춘 화면에서 재야 잡혔고 다른 하나는 프레임을 넘겨 가며 재야 잡혔습니다.

푼 자리와 다시 잰 값

좁은폭 미디어쿼리에서 뿌리와 폼, input 에 min-width: 0 을 주고, 긴 오류 문구와 라벨에는 overflow-wrap: anywhere 를 줘서 줄이 접히게 했습니다. 같은 미디어쿼리에서 몸을 88px 에서 78px 로 내리고 눈·볼·입 좌표도 함께 당겨, 폭이 풀린 뒤에도 세로 200px 예산을 넘지 않게 했습니다. 다시 렌더한 06 은 body.scrollWidth 320 으로 돌아왔고 움직인 프레임 19/23 도 그대로 지켰습니다. 07 의 뛰어오름처럼 되튀는 움직임을 손으로 이징에 맡기지 않고 계산으로 내려면 물리 시뮬레이션 만들기의 스프링 쪽이 답을 들고 있습니다. 아홉 개 파일이 든 압축 파일의 비밀번호는 wyvxy9q2 이고, 나머지 여덟 개도 같은 두 폭 검사를 함께 통과했습니다.

온보딩 화면 디자인에서 마스코트는 어디에 서는가

온보딩 화면 디자인에서 캐릭터가 설 자리는 사람이 다음에 할 일 바로 옆입니다. 03 은 「다음 단계」 버튼 왼쪽에 서서 팔로 버튼을 가리키고, 04 는 비밀번호 칸 왼쪽에 붙어 입력이 시작되는 순간에만 반응합니다. 캐릭터를 화면 한가운데 크게 세우고 조작을 아래로 밀면 시선이 캐릭터에서 끝나므로, 02 처럼 환영 한 장면만 캐릭터가 주인공이고 나머지 단계에서는 조작 옆 보조 자리로 물러나는 편이 낫습니다. 데이터가 아직 없는 화면을 캐릭터 없이 채우는 방법은 빈 화면 디자인 9종에 정리해 두었습니다.

브랜드에 맞출 때 손대는 값은 파일 맨 위 변수 세 개입니다.

이름 바꾼 값 느낌
duration $dur-loop 2s → 1s 숨·손짓이 두 배로 빨라져 더 들뜬 성격이 된다
easing $ease-spring → $ease-pop 점프와 눌림이 한 번 더 튀어 장난스러워진다
color $subject-blue → $stage-orange 차분한 안내자에서 활기찬 홍보 캐릭터로 바뀐다

색을 바꿀 때는 무대 위 글자색도 같이 손봐야 합니다. 03 의 안내문은 #ff4d1f 위 5.50:1, 02 의 인사 문장은 #ffd23f 위 12.63:1 로 재서 넣었고, 주황과 노랑 무대에서는 크림색 글자가 AA 에 못 미쳐 무대 위 글자를 전부 먹색으로 뒀습니다.

접근성

캐릭터 아홉 개는 전부 aria-hidden="true" 라 화면을 못 보는 사람에게는 읽히지 않고, 뜻은 옆 글자가 전합니다. 05 의 진행률은 막대에 건 role="progressbar" 가, 06 의 실패는 role="alert" 영역의 문장과 아이콘이, 03 의 단계 변경은 aria-live="polite" 가 알립니다. 04 는 캐릭터가 아니라 보기 단추의 aria-pressed 와 라벨이 상태를 전하므로 팔이 내려갔다는 사실이 소리로도 전해집니다.

움직임을 줄이겠다고 설정한 사람에게는 아홉 개 모두 루프를 끄고 가장 잘 읽히는 정지 한 장을 남깁니다. 특히 09 는 사람이 시작하지 않은 자동 움직임이라 취향 문제가 아니라 요구입니다 — 일시정지·정지·숨기기 기준이 5초 넘게 저절로 움직이는 것에는 멈출 방법을 요구하고, 그래서 유휴 애니메이션은 prefers-reduced-motion: reduce 에서 전부 멈춘 잠든 한 장이 됩니다. reduce 블록 안 선언에 !important 를 붙인 까닭은 루프를 켜는 .is-demo 클래스가 특이도에서 이기기 때문입니다.

항목 reduce 에서 남는 한 장
01 숨·박자·깜빡임 정지 — 몸은 scaleY(1.04), 막대는 scaleX(.56), 눈은 뜬 채
02 등장·팔·기울기·그림자 정지 — 팔을 든 인사 자세로 선 채
03 팔·눈동자·버튼 링 정지 — data-step 에 맞는 안내 문구 한 장만 보임
04 팔·눈·단추·고스트 정지 — 팔이 눈을 덮은 rotate(-99deg) 자세
05 걷기·발딛임·막대·오도미터 정지 — 막대는 scaleX(.6) 까지 찬 상태
06 흔들림과 컷 정지 — 슬픈 얼굴·오류 문구가 보이고 칸 테두리는 $error
07 점프·별 정지 — 별 셋이 머리 위 translateY(var(--r)) 에 떠 있음
08 눌림·하트 정지 — 몸은 scale(1.2, .85) 로 눌린 채, 하트는 떠 있음
09 미리보기·잠든 숨·깨는 팝 전부 정지 — 눈 감긴 채 Z 가 떠 있음

FAQ

마스코트 캐릭터 만들기에 그림 파일이 꼭 필요한가요

아홉 개 모두 그림 파일 없이 div 몇 개와 border-radius 로 몸과 얼굴을 그렸습니다. 몸과 얼굴에는 SVG 를 한 장도 쓰지 않았고, 07 의 별과 08 의 하트처럼 캐릭터 밖 장식과 안내문 아이콘만 Phosphor Duotone 을 인라인으로 넣었습니다.

캐릭터가 스크린 리더에 두 번 읽히지는 않나요

캐릭터 요소에 aria-hidden="true" 가 걸려 있어 읽히지 않습니다. 상태는 항목마다 따로 마련한 글자 영역이 전하므로, 캐릭터를 지워도 뜻은 그대로 남습니다. 04 가 서 있는 입력칸 자체가 포커스에 어떻게 답하는지는 CSS 입력창 검색창 포커스 애니메이션에서 아홉 가지로 다뤘습니다.

리액트 프로젝트에도 그대로 붙일 수 있나요

압축 파일에 바닐라 판과 리액트 판이 같이 들어 있고, 리액트 판은 클래스 토글을 상태로 옮긴 것 말고는 같은 스타일을 씁니다. 클래스 이름과 키프레임 이름이 두 판에서 같으므로 스타일 파일만 먼저 가져다 써도 됩니다.

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

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