07뛰어오르는 성공
캐릭터가 한 번 웅크렸다 뛰어오르고 별 세 개가 뒤늦게 따라 올라갑니다. 그림자는 뜨는 동안 작아집니다.
쓰임결제 완료·과제 제출처럼 한 번만 크게 축하해 주면 되는 완료 화면
translateYanimation-delayanimation-fill-modescale

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-smiley" viewBox="0 0 256 256" fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88M80 108a12 12 0 1 1 12 12a12 12 0 0 1-12-12m96 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-1.08 48c-10.29 17.79-27.39 28-46.92 28s-36.63-10.2-46.92-28a8 8 0 1 1 13.84-8c7.47 12.91 19.21 20 33.08 20s25.61-7.1 33.08-20a8 8 0 1 1 13.84 8"/></symbol>
<symbol id="ico-star" viewBox="0 0 256 256" fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></symbol>
</defs>
</svg>
<main class="stage">
<div class="mb is-demo" id="mb">
<p class="mb__hint">
<svg class="mb__ico" aria-hidden="true"><use href="#ico-smiley"/></svg>
<span>완료 버튼을 누르면 한 번 뛰어오릅니다</span>
</p>
<div class="mb__floor">
<!-- 캐릭터는 01 과 같은 몸(타원 하나에 이목구비). 별은 Phosphor Duotone star 를 인라인으로 박았다.
별은 제 갈 길(translateY + scale)이 있어 캐릭터와 함께 묶지 않고 따로 둔다. -->
<div class="mb__char" aria-label="뛰어오르는 마스코트" role="img">
<span class="mb__star mb__star--l" aria-hidden="true"><svg class="mb__starIco"><use href="#ico-star"/></svg></span>
<span class="mb__star mb__star--c" aria-hidden="true"><svg class="mb__starIco"><use href="#ico-star"/></svg></span>
<span class="mb__star mb__star--r" id="mbStar3" aria-hidden="true"><svg class="mb__starIco"><use href="#ico-star"/></svg></span>
<span class="mb__body">
<i class="mb__eye mb__eye--l"></i>
<i class="mb__eye mb__eye--r"></i>
<i class="mb__cheek mb__cheek--l"></i>
<i class="mb__cheek mb__cheek--r"></i>
<i class="mb__mouth"></i>
</span>
<span class="mb__shadow"></span>
</div>
</div>
<button class="mb__go" id="mbGo" type="button">완료</button>
<!-- 점프와 별은 순수한 그림이라 화면 낭독기에는 닿지 않는다 — 완료를 누르면 글자로도 알린다 -->
<p class="mb__sr" aria-live="polite" id="mbSay"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 뛰어오르는 성공 — 한 번 웅크렸다 뛰어오르고, 별 세 개가 뒤늦게 따라 올라간다.
// 그림자는 캐릭터가 공중에 있는 동안 작아진다(높이의 착시).
// 점프 높이·별 상승 폭은 CSS 변수(--j · --r)로 두어 좁은 폭에서 한 번에 줄인다 —
// transform 으로 커진 상자는 scrollHeight 에 그대로 잡히므로(BD9) 무대 안에서만 뛴다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink; // 몸색 — 노란 무대 위 먹 몸 (01 과 같은 캐릭터, 색만 반전)
@include stage($stage-yellow);
.mb {
--j: -46px; // 점프 정점
--r: -40px; // 별 상승 폭
display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; max-width: 320px;
}
.mb__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.4; color: $subject-ink;
}
.mb__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.mb__floor { position: relative; width: 100%; height: 160px; display: grid; place-items: end center; }
.mb__char { position: relative; width: 112px; height: 152px; display: grid; place-items: end center; }
// 별 — 스스로 완결한 요소라 scale 의 원점은 중심(기본값) (규칙 8).
// 기본 상태는 scale(0): visibility 가 아니라 기하로 숨긴다 — 애니메이션이 그 자리를 대신 채운다.
// 가운데 별이 가장 크고 양 옆이 작다. 다 오른 자리는 정점의 얼굴 위(가운데)·어깨 옆(양쪽) —
// 별 바닥이 얼굴(이마)에 겹치지 않게 상승 폭(--r)을 점프(--j)와 맞춰 계산해 뒀다.
.mb__star { position: absolute; }
.mb__star--l { left: -24px; top: 30px; }
.mb__star--c { left: 50%; top: -8px; margin-left: -14px; }
.mb__star--r { right: -24px; top: 30px; }
.mb__starIco { display: block; width: 22px; height: 22px; color: $subject-blue; }
.mb__star--c .mb__starIco { width: 28px; height: 28px; }
.mb__body {
position: relative; display: block; width: 92px; height: 104px;
border-radius: 50% 50% 46% 46%;
background: $color;
transform-origin: 50% 100%; // 발바닥이 바닥에 붙어 웅크림·늘어남이 모두 발에서 시작한다 — 붙는 지점이라 중심 아님 (규칙 8 예외)
box-shadow: $shadow-press;
}
.mb__eye {
position: absolute; top: 40px; width: 10px; height: 12px;
border-radius: 50%; background: $subject-cream;
}
.mb__eye--l { left: 22px; }
.mb__eye--r { right: 22px; }
.mb__cheek {
position: absolute; top: 58px; width: 14px; height: 6px;
border-radius: $r-pill; background: $subject-pink; opacity: .9;
}
.mb__cheek--l { left: 13px; }
.mb__cheek--r { right: 13px; }
.mb__mouth {
position: absolute; top: 62px; left: 50%; margin-left: -8px; width: 16px; height: 9px;
border-bottom: 3px solid $subject-cream;
border-radius: 0 0 $r-pill $r-pill;
}
// 그림자 — 공중에 뜬 동안 눌리는 면. 세로로는 자라지 않게 scaleX 만
.mb__shadow {
position: absolute; left: 50%; bottom: -4px; width: 72px; height: 10px; margin-left: -36px;
border-radius: 50%;
background: rgba(23, 20, 26, .2);
}
.mb__go {
margin: 0; padding: $sp-2 $sp-4; border: 0; border-radius: $r-control;
background: $subject-ink; color: $subject-cream; cursor: pointer;
font-weight: 700; font-size: clamp(12px, 3vw, 14px); line-height: 1.4; font-family: inherit;
}
.mb__go:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// 화면 낭독기용 축하 문구 — 눈에는 보이지 않지만 완료를 글자로도 전한다
.mb__sr {
position: absolute; width: 1px; height: 1px; padding: 0; border: 0; margin: 0;
overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
// ── 미리보기 루프 (2초 안에: 완료 누름 → 웅크림 → 점프 → 별 세 개가 뒤늦게) ──
// 시차는 낱개 속성으로만 — animation: 단축은 delay 를 0으로 리셋한다 (240 실측).
// 루프에서는 시차를 키프레임에 굽는다(seStarA·B·C): animation-delay 로 미루면
// 무한 반복에서 별의 주기가 점프와 어긋나 다음 바퀴부터 엉켜 보인다.
@keyframes sePress { // 완료 버튼 누름 신호 — 이 장면의 방아쇠
0% { transform: scale(1); }
2% { transform: scale(.94); }
5%, 100% { transform: scale(1); }
}
@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; }
}
// 별 — 캐릭터가 땅을 떠난 뒤(12%) 바로 뒤따라 올라와(1~2% 시차) 머리 위에 뜬다.
// 뜬 뒤에는 세 번 물결(scale + rotate)로 반짝이고 89% 안에 진정한다. 사라지는 구간은 루프에 없다(255 실측)
@keyframes seStarA {
0%, 12% { transform: translateY(0) scale(0) rotate(0deg); }
17% { transform: translateY(var(--r)) scale(1) rotate(0deg); }
33% { transform: translateY(var(--r)) scale(1.12) rotate(6deg); }
42% { transform: translateY(var(--r)) scale(1) rotate(0deg); }
55% { transform: translateY(var(--r)) scale(1.12) rotate(-6deg); }
64% { transform: translateY(var(--r)) scale(1) rotate(0deg); }
77% { transform: translateY(var(--r)) scale(1.12) rotate(6deg); }
86%, 100% { transform: translateY(var(--r)) scale(1) rotate(0deg); }
}
@keyframes seStarB {
0%, 13% { transform: translateY(0) scale(0) rotate(0deg); }
18% { transform: translateY(var(--r)) scale(1) rotate(0deg); }
35% { transform: translateY(var(--r)) scale(1.12) rotate(-6deg); }
44% { transform: translateY(var(--r)) scale(1) rotate(0deg); }
57% { transform: translateY(var(--r)) scale(1.12) rotate(6deg); }
66% { transform: translateY(var(--r)) scale(1) rotate(0deg); }
79% { transform: translateY(var(--r)) scale(1.12) rotate(-6deg); }
88%, 100% { transform: translateY(var(--r)) scale(1) rotate(0deg); }
}
@keyframes seStarC {
0%, 14% { transform: translateY(0) scale(0) rotate(0deg); }
19% { transform: translateY(var(--r)) scale(1) rotate(0deg); }
36% { transform: translateY(var(--r)) scale(1.12) rotate(6deg); }
45% { transform: translateY(var(--r)) scale(1) rotate(0deg); }
58% { transform: translateY(var(--r)) scale(1.12) rotate(-6deg); }
67% { transform: translateY(var(--r)) scale(1) rotate(0deg); }
80% { transform: translateY(var(--r)) scale(1.12) rotate(6deg); }
89%, 100% { transform: translateY(var(--r)) scale(1) rotate(0deg); }
}
// 눈 깜빡임 — 서 있는 뒤반의 프레임을 채운다. 반쯤 감긴 유령이 없게 steps(1, end) 컷 (01 과 같은 버릇)
@keyframes seBlink {
0%, 50% { transform: scaleY(1); }
52%, 56% { transform: scaleY(.08); }
58%, 100% { transform: scaleY(1); }
}
.mb.is-demo .mb__go {
animation-name: sePress;
animation-duration: $duration;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
.mb.is-demo .mb__body {
animation-name: seJump;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.mb.is-demo .mb__shadow {
animation-name: seShadow;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.mb.is-demo .mb__star--l { animation-name: seStarA; animation-duration: $duration; animation-timing-function: $ease-pop; animation-iteration-count: infinite; }
.mb.is-demo .mb__star--c { animation-name: seStarB; animation-duration: $duration; animation-timing-function: $ease-pop; animation-iteration-count: infinite; }
.mb.is-demo .mb__star--r { animation-name: seStarC; animation-duration: $duration; animation-timing-function: $ease-pop; animation-iteration-count: infinite; }
.mb.is-demo .mb__eye {
transform-origin: 50% 50%;
animation-name: seBlink;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// ── 실제 조작 — 완료를 누르면 한 번 재생. 여기서는 animation-delay 로 시차 ──
// 지연이 걸린 애니메이션에는 animation-fill-mode: backwards 가 필수 —
// 없으면 지연 구간에 요소의 기본 상태(만든 모습)가 0프레임에 그대로 번쩍였다 사라진다 (252-08 실측).
@keyframes seStarQ {
0% { transform: translateY(0) scale(0) rotate(0deg); }
14% { transform: translateY(var(--r)) scale(1) rotate(0deg); }
30% { transform: translateY(var(--r)) scale(1) rotate(10deg); }
46% { transform: translateY(var(--r)) scale(1) rotate(-8deg); }
60%, 100% { transform: translateY(var(--r)) scale(1) rotate(0deg); }
}
@keyframes sePressQ {
0% { transform: scale(1); }
40% { transform: scale(.94); }
100% { transform: scale(1); }
}
.mb.is-jump .mb__go {
animation-name: sePressQ; animation-duration: $dur-quick; animation-timing-function: $ease-pop; animation-iteration-count: 1;
}
.mb.is-jump .mb__body {
animation-name: seJump; animation-duration: 1.04s; animation-timing-function: $easing; animation-iteration-count: 1;
}
.mb.is-jump .mb__shadow {
animation-name: seShadow; animation-duration: 1.04s; animation-timing-function: $easing; animation-iteration-count: 1;
}
.mb.is-jump .mb__star {
animation-name: seStarQ; animation-duration: 0.8s; animation-timing-function: $ease-pop; animation-iteration-count: 1;
animation-fill-mode: backwards; // 지연 구간에는 0% 상태(scale(0)) 를 미리 입혀 둔다
}
.mb.is-jump .mb__star--l { animation-delay: 0.42s; }
.mb.is-jump .mb__star--c { animation-delay: 0.5s; }
.mb.is-jump .mb__star--r { animation-delay: 0.58s; }
@media (max-width: 340px) {
.mb { --j: -34px; --r: -26px; gap: $sp-2; }
.mb__floor { height: 108px; }
.mb__char { width: 88px; height: 100px; }
.mb__body { width: 70px; height: 78px; }
.mb__eye { top: 30px; width: 8px; height: 10px; }
.mb__eye--l { left: 16px; }
.mb__eye--r { right: 16px; }
.mb__cheek { top: 44px; width: 12px; height: 5px; }
.mb__cheek--l { left: 9px; }
.mb__cheek--r { right: 9px; }
.mb__mouth { top: 48px; }
.mb__shadow { width: 56px; margin-left: -28px; }
.mb__star--l { left: -14px; top: 22px; }
.mb__star--c { top: -6px; margin-left: -9px; }
.mb__star--r { right: -14px; top: 22px; }
.mb__starIco { width: 16px; height: 16px; }
.mb__star--c .mb__starIco { width: 18px; height: 18px; }
.mb__go { padding: $sp-1 $sp-3; }
}
// reduce — 운동을 끊고 '성공 직후' 한 장으로 보여 준다. 별은 떠 있는 상태로 (퇴장을 재현하지 않는다)
@media (prefers-reduced-motion: reduce) {
.mb.is-demo .mb__go,
.mb.is-demo .mb__body,
.mb.is-demo .mb__shadow,
.mb.is-demo .mb__eye,
.mb.is-demo .mb__star--l,
.mb.is-demo .mb__star--c,
.mb.is-demo .mb__star--r { animation: none !important; }
.mb.is-demo .mb__star--l,
.mb.is-demo .mb__star--c,
.mb.is-demo .mb__star--r { transform: translateY(var(--r)) scale(1) !important; }
.mb.is-jump .mb__go,
.mb.is-jump .mb__body,
.mb.is-jump .mb__shadow,
.mb.is-jump .mb__star { animation: none !important; }
.mb.is-jump .mb__star { transform: translateY(var(--r)) scale(1) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mb {
--j: -46px;
--r: -40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 320px;
}
.mb__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.4;
color: #17141a;
}
.mb__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.mb__floor {
position: relative;
width: 100%;
height: 160px;
display: grid;
place-items: end center;
}
.mb__char {
position: relative;
width: 112px;
height: 152px;
display: grid;
place-items: end center;
}
.mb__star {
position: absolute;
}
.mb__star--l {
left: -24px;
top: 30px;
}
.mb__star--c {
left: 50%;
top: -8px;
margin-left: -14px;
}
.mb__star--r {
right: -24px;
top: 30px;
}
.mb__starIco {
display: block;
width: 22px;
height: 22px;
color: #2f6df6;
}
.mb__star--c .mb__starIco {
width: 28px;
height: 28px;
}
.mb__body {
position: relative;
display: block;
width: 92px;
height: 104px;
border-radius: 50% 50% 46% 46%;
background: #17141a;
transform-origin: 50% 100%;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.mb__eye {
position: absolute;
top: 40px;
width: 10px;
height: 12px;
border-radius: 50%;
background: #fff7e6;
}
.mb__eye--l {
left: 22px;
}
.mb__eye--r {
right: 22px;
}
.mb__cheek {
position: absolute;
top: 58px;
width: 14px;
height: 6px;
border-radius: 999px;
background: #ff9ebb;
opacity: 0.9;
}
.mb__cheek--l {
left: 13px;
}
.mb__cheek--r {
right: 13px;
}
.mb__mouth {
position: absolute;
top: 62px;
left: 50%;
margin-left: -8px;
width: 16px;
height: 9px;
border-bottom: 3px solid #fff7e6;
border-radius: 0 0 999px 999px;
}
.mb__shadow {
position: absolute;
left: 50%;
bottom: -4px;
width: 72px;
height: 10px;
margin-left: -36px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.2);
}
.mb__go {
margin: 0;
padding: 8px 16px;
border: 0;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
cursor: pointer;
font-weight: 700;
font-size: clamp(12px, 3vw, 14px);
line-height: 1.4;
font-family: inherit;
}
.mb__go:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mb__sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
border: 0;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@keyframes sePress {
0% {
transform: scale(1);
}
2% {
transform: scale(0.94);
}
5%, 100% {
transform: scale(1);
}
}
@keyframes seJump {
0%, 4% {
transform: translate(0, 0) scale(1, 1);
}
9%, 12% {
transform: translate(0, 8px) scale(1.1, 0.8);
}
16% {
transform: translate(0, var(--j)) scale(0.92, 1.16);
}
19% {
transform: translate(0, var(--j)) scale(0.98, 1.04);
}
26% {
transform: translate(0, 0) scale(1.02, 0.96);
}
29% {
transform: translate(0, 0) scale(1.12, 0.84);
}
35%, 100% {
transform: translate(0, 0) scale(1, 1);
}
}
@keyframes seShadow {
0%, 4% {
transform: scaleX(1);
opacity: 0.2;
}
9%, 12% {
transform: scaleX(1.12);
opacity: 0.24;
}
16%, 19% {
transform: scaleX(0.54);
opacity: 0.1;
}
26% {
transform: scaleX(0.92);
opacity: 0.18;
}
29% {
transform: scaleX(1.14);
opacity: 0.24;
}
35%, 100% {
transform: scaleX(1);
opacity: 0.2;
}
}
@keyframes seStarA {
0%, 12% {
transform: translateY(0) scale(0) rotate(0deg);
}
17% {
transform: translateY(var(--r)) scale(1) rotate(0deg);
}
33% {
transform: translateY(var(--r)) scale(1.12) rotate(6deg);
}
42% {
transform: translateY(var(--r)) scale(1) rotate(0deg);
}
55% {
transform: translateY(var(--r)) scale(1.12) rotate(-6deg);
}
64% {
transform: translateY(var(--r)) scale(1) rotate(0deg);
}
77% {
transform: translateY(var(--r)) scale(1.12) rotate(6deg);
}
86%, 100% {
transform: translateY(var(--r)) scale(1) rotate(0deg);
}
}
@keyframes seStarB {
0%, 13% {
transform: translateY(0) scale(0) rotate(0deg);
}
18% {
transform: translateY(var(--r)) scale(1) rotate(0deg);
}
35% {
transform: translateY(var(--r)) scale(1.12) rotate(-6deg);
}
44% {
transform: translateY(var(--r)) scale(1) rotate(0deg);
}
57% {
transform: translateY(var(--r)) scale(1.12) rotate(6deg);
}
66% {
transform: translateY(var(--r)) scale(1) rotate(0deg);
}
79% {
transform: translateY(var(--r)) scale(1.12) rotate(-6deg);
}
88%, 100% {
transform: translateY(var(--r)) scale(1) rotate(0deg);
}
}
@keyframes seStarC {
0%, 14% {
transform: translateY(0) scale(0) rotate(0deg);
}
19% {
transform: translateY(var(--r)) scale(1) rotate(0deg);
}
36% {
transform: translateY(var(--r)) scale(1.12) rotate(6deg);
}
45% {
transform: translateY(var(--r)) scale(1) rotate(0deg);
}
58% {
transform: translateY(var(--r)) scale(1.12) rotate(-6deg);
}
67% {
transform: translateY(var(--r)) scale(1) rotate(0deg);
}
80% {
transform: translateY(var(--r)) scale(1.12) rotate(6deg);
}
89%, 100% {
transform: translateY(var(--r)) scale(1) rotate(0deg);
}
}
@keyframes seBlink {
0%, 50% {
transform: scaleY(1);
}
52%, 56% {
transform: scaleY(0.08);
}
58%, 100% {
transform: scaleY(1);
}
}
.mb.is-demo .mb__go {
animation-name: sePress;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.mb.is-demo .mb__body {
animation-name: seJump;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mb.is-demo .mb__shadow {
animation-name: seShadow;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mb.is-demo .mb__star--l {
animation-name: seStarA;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.mb.is-demo .mb__star--c {
animation-name: seStarB;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.mb.is-demo .mb__star--r {
animation-name: seStarC;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.mb.is-demo .mb__eye {
transform-origin: 50% 50%;
animation-name: seBlink;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes seStarQ {
0% {
transform: translateY(0) scale(0) rotate(0deg);
}
14% {
transform: translateY(var(--r)) scale(1) rotate(0deg);
}
30% {
transform: translateY(var(--r)) scale(1) rotate(10deg);
}
46% {
transform: translateY(var(--r)) scale(1) rotate(-8deg);
}
60%, 100% {
transform: translateY(var(--r)) scale(1) rotate(0deg);
}
}
@keyframes sePressQ {
0% {
transform: scale(1);
}
40% {
transform: scale(0.94);
}
100% {
transform: scale(1);
}
}
.mb.is-jump .mb__go {
animation-name: sePressQ;
animation-duration: 220ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
.mb.is-jump .mb__body {
animation-name: seJump;
animation-duration: 1.04s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
}
.mb.is-jump .mb__shadow {
animation-name: seShadow;
animation-duration: 1.04s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
}
.mb.is-jump .mb__star {
animation-name: seStarQ;
animation-duration: 0.8s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
animation-fill-mode: backwards;
}
.mb.is-jump .mb__star--l {
animation-delay: 0.42s;
}
.mb.is-jump .mb__star--c {
animation-delay: 0.5s;
}
.mb.is-jump .mb__star--r {
animation-delay: 0.58s;
}
@media (max-width: 340px) {
.mb {
--j: -34px;
--r: -26px;
gap: 8px;
}
.mb__floor {
height: 108px;
}
.mb__char {
width: 88px;
height: 100px;
}
.mb__body {
width: 70px;
height: 78px;
}
.mb__eye {
top: 30px;
width: 8px;
height: 10px;
}
.mb__eye--l {
left: 16px;
}
.mb__eye--r {
right: 16px;
}
.mb__cheek {
top: 44px;
width: 12px;
height: 5px;
}
.mb__cheek--l {
left: 9px;
}
.mb__cheek--r {
right: 9px;
}
.mb__mouth {
top: 48px;
}
.mb__shadow {
width: 56px;
margin-left: -28px;
}
.mb__star--l {
left: -14px;
top: 22px;
}
.mb__star--c {
top: -6px;
margin-left: -9px;
}
.mb__star--r {
right: -14px;
top: 22px;
}
.mb__starIco {
width: 16px;
height: 16px;
}
.mb__star--c .mb__starIco {
width: 18px;
height: 18px;
}
.mb__go {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.mb.is-demo .mb__go,
.mb.is-demo .mb__body,
.mb.is-demo .mb__shadow,
.mb.is-demo .mb__eye,
.mb.is-demo .mb__star--l,
.mb.is-demo .mb__star--c,
.mb.is-demo .mb__star--r {
animation: none !important;
}
.mb.is-demo .mb__star--l,
.mb.is-demo .mb__star--c,
.mb.is-demo .mb__star--r {
transform: translateY(var(--r)) scale(1) !important;
}
.mb.is-jump .mb__go,
.mb.is-jump .mb__body,
.mb.is-jump .mb__shadow,
.mb.is-jump .mb__star {
animation: none !important;
}
.mb.is-jump .mb__star {
transform: translateY(var(--r)) scale(1) !important;
}
}// 완료를 누르면 점프가 한 번 재생된다. 마지막 별의 animationend 에서 상태를 원위치로 돌린다 —
// 다시 누르면 또 뛴다.
(function () {
var root = document.getElementById('mb');
var go = document.getElementById('mbGo');
var say = document.getElementById('mbSay');
var starLast = document.getElementById('mbStar3');
var CHEER = '완료를 축하합니다';
function leap() {
root.classList.remove('is-demo');
root.classList.remove('is-jump');
void root.offsetWidth; // 클래스를 뗐다 붙여 점프를 처음 프레임부터 다시 튀긴다
root.classList.add('is-jump');
say.textContent = CHEER;
go.focus({ preventScroll: true });
}
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
go.addEventListener('click', leap);
// 세 별 중 마지막 것이 다 오르고 나면(is-jump 해제) 자리를 원위치로 — 점프만 먼저 끝나도 별은 끝까지 떠 있게
root.addEventListener('animationend', function (e) {
if (e.target === starLast) root.classList.remove('is-jump');
});
}());// JumpSuccess — 뛰어오르는 성공. vanilla 판과 같은 마크업·타이밍.
// 완료를 누르면 점프가 한 번 재생되고, 마지막 별의 animationend 로 상태를 원위치로 돌린다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./JumpSuccess.module.scss";
export type JumpSuccessProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HINT_TEXT = "완료 버튼을 누르면 한 번 뛰어오릅니다";
const CHAR_LABEL = "뛰어오르는 마스코트";
const GO_TEXT = "완료";
const CHEER_TEXT = "완료를 축하합니다";
export default function JumpSuccess({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: JumpSuccessProps) {
const [demo, setDemo] = useState(true);
const [jump, setJump] = useState(false);
const [say, setSay] = useState("");
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
function leap() {
setDemo(false);
setJump(false);
// 두 프레임을 건너 뛰어 점프를 처음부터 다시 튀긴다 (vanilla 의 void offsetWidth 와 같은 역할)
requestAnimationFrame(() => { requestAnimationFrame(() => setJump(true)); });
setSay(CHEER_TEXT);
}
const cls = [styles.root, demo ? styles.isDemo : "", jump ? styles.jump : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-smiley" viewBox="0 0 256 256" fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88M80 108a12 12 0 1 1 12 12a12 12 0 0 1-12-12m96 0a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-1.08 48c-10.29 17.79-27.39 28-46.92 28s-36.63-10.2-46.92-28a8 8 0 1 1 13.84-8c7.47 12.91 19.21 20 33.08 20s25.61-7.1 33.08-20a8 8 0 1 1 13.84 8"/></symbol>
<symbol id="ico-star" viewBox="0 0 256 256" fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-smiley" /></svg>
<span>{HINT_TEXT}</span>
</p>
<div className={styles.floor}>
<div className={styles.char} aria-label={CHAR_LABEL} role="img">
<span className={`${styles.star} ${styles.starL}`} aria-hidden="true">
<svg className={styles.starIco}><use href="#ico-star" /></svg>
</span>
<span className={`${styles.star} ${styles.starC}`} aria-hidden="true">
<svg className={styles.starIco}><use href="#ico-star" /></svg>
</span>
<span
className={`${styles.star} ${styles.starR}`}
aria-hidden="true"
onAnimationEnd={() => setJump(false)}
>
<svg className={styles.starIco}><use href="#ico-star" /></svg>
</span>
<span className={styles.body}>
<i className={`${styles.eye} ${styles.eyeL}`}></i>
<i className={`${styles.eye} ${styles.eyeR}`}></i>
<i className={`${styles.cheek} ${styles.cheekL}`}></i>
<i className={`${styles.cheek} ${styles.cheekR}`}></i>
<i className={styles.mouth}></i>
</span>
<span className={styles.shadow}></span>
</div>
</div>
<button className={styles.go} type="button" onClick={leap}>{GO_TEXT}</button>
<p className={styles.sr} aria-live="polite">{say}</p>
</div>
</main>
);
}07 뛰어오르는 성공 — 측정 누적면적 11.441% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 마스코트 애니메이션 9종 — 인사부터 잠들기까지
이 데모가 속한 글
마스코트 애니메이션 9종 — 인사부터 잠들기까지마스코트 애니메이션은 서비스 화면에 사는 캐릭터가 상황마다 다른 몸짓과 표정으로 사람에게 말을 거는 움직임입니다. 여기 실은 아홉 개는 그림 파일도 라이브러리도 없이 타원 하나에 눈·볼·입만 얹어 CSS 로 그렸고, 이 글의 데모 전부 보기


