05Carrying loader
The character walks a box left and right while the bar underneath fills from the left. The body squashes once each time a foot lands.
Best forA waiting screen that replaces a blank page during an upload or export that takes more than a few seconds
translateXscaleXanimation-directionrole="progressbar"

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-package" viewBox="0 0 256 256" fill="currentColor"><path d="M128 129.09V232a8 8 0 0 1-3.84-1l-88-48.18a8 8 0 0 1-4.16-7V80.18a8 8 0 0 1 .7-3.25Z" opacity=".2"/><path d="m223.68 66.15l-88-48.15a15.88 15.88 0 0 0-15.36 0l-88 48.17a16 16 0 0 0-8.32 14v95.64a16 16 0 0 0 8.32 14l88 48.17a15.88 15.88 0 0 0 15.36 0l88-48.17a16 16 0 0 0 8.32-14V80.18a16 16 0 0 0-8.32-14.03M128 32l80.34 44l-29.77 16.3l-80.35-44Zm0 88L47.66 76l33.9-18.56l80.34 44ZM40 90l80 43.78v85.79l-80-43.75Zm176 85.78l-80 43.79v-85.75l32-17.51V152a8 8 0 0 0 16 0v-44.45L216 90v85.77Z"/></symbol>
</defs>
</svg>
<main class="stage">
<div class="ml">
<p class="ml__hint">
<svg class="ml__ico" aria-hidden="true"><use href="#ico-package"/></svg>
<span>The bar fills as boxes move</span>
</p>
<div class="ml__scene">
<i class="ml__ground"></i>
<!-- 캐릭터·상자는 장식 — 진행 상태는 아래 막대의 role="progressbar" 가 알린다.
캐릭터는 01 마스코트와 같은 몸(타원 한 개 + 같은 이목구비), 색만 잉크 무대용 크림으로 뒤집는다. -->
<div class="ml__lane" aria-hidden="true">
<div class="ml__step">
<i class="ml__arm ml__arm--l"></i>
<i class="ml__arm ml__arm--r"></i>
<span class="ml__body">
<i class="ml__eye ml__eye--l"></i>
<i class="ml__eye ml__eye--r"></i>
<i class="ml__cheek ml__cheek--l"></i>
<i class="ml__cheek ml__cheek--r"></i>
<i class="ml__mouth"></i>
</span>
<span class="ml__box"></span>
</div>
</div>
</div>
<p class="ml__meta">
<!-- 숫자는 막대 밖 잉크 무대 위에 둔다 — 중간 채움색 위에서는 어느 글자색도 기준을 못 넘긴다.
카운터는 00 부터 99 까지 2초에 한 바퀴 — 막대 채움과 같은 속도다. -->
<span class="ml__odo" aria-hidden="true">
<i class="ml__odoWin"><i class="ml__odoCol ml__odoCol--t"><b>0</b><b>1</b><b>2</b><b>3</b><b>4</b><b>5</b><b>6</b><b>7</b><b>8</b><b>9</b></i></i>
<i class="ml__odoWin"><i class="ml__odoCol ml__odoCol--u"><b>0</b><b>1</b><b>2</b><b>3</b><b>4</b><b>5</b><b>6</b><b>7</b><b>8</b><b>9</b></i></i>
<b class="ml__pct">%</b>
</span>
<!-- 칩 role="progressbar" — CSS 데모판에서 값 표시는 정적이고, 실제 서비스에서는 JS 가 aria-valuenow 를 갱신한다 -->
<span class="ml__track" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="Carrying progress">
<i class="ml__fill"></i>
</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 짐을 나르는 로딩 — 캐릭터가 상자를 들고 좌우로 오가는 동안 아래 막대가 왼쪽부터 채워진다.
// 왕복 한 바퀴 = $duration(2s). 걷기는 1s 를 animation-direction: alternate 로 돌려 편도 1s,
// 발 딛임(눌림)은 0.25s — 편도에 네 걸음. 카운터 단위 0.2s. 전부 2s 의 약수라 프레임이 한 바퀴를 다 담는다.
// 진행 막대는 시작 변에 붙어 자라는 요소라 transform-origin 을 왼쪽 가운데(0 50%)에 둔다
// (규칙 8 의 예외 — 붙는 지점이 있는 요소). 걷기 속도는 일정해야 자연스러우니 linear.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
$accent: $stage-yellow;
@include stage($stage-ink);
.ml { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; max-width: 340px; }
.ml__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-cream;
}
.ml__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $accent; }
.ml__hint span { flex: 1 1 auto; min-width: 0; }
// ── 무대면: 걷는 길 ──
.ml__scene { position: relative; width: 100%; height: 150px; }
.ml__ground {
position: absolute; left: 0; right: 0; bottom: 0; display: block;
height: 2px; border-radius: $r-pill;
background: rgba($subject-cream, .18);
}
// 차도(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); }
}
// 발이 땅에 닿는 순간 몸이 한 번 눌린다(squash) — 눌림은 발바닥에서 일어나므로
// transform-origin 을 아래 중심(50% 100%)에 둔다. 0.25s 한 걸음, 한 주기 안에 뜀·착지·눌림이 다 들어 있다.
.ml__step {
position: absolute; right: 0; bottom: 0;
width: var(--char-w); height: 140px;
transform-origin: 50% 100%;
animation-name: mlStep;
animation-duration: 250ms;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes mlStep {
0%, 100% { transform: translateY(0) scaleY(.9); }
40% { transform: translateY(-6px) scaleY(1.05); }
80% { transform: translateY(0) scaleY(1); }
}
// ── 캐릭터 (01 과 같은 몸, 크림 뒤집기) ──
.ml__body {
position: absolute; left: 8px; bottom: 0;
width: 88px; height: 96px;
border-radius: 50% 50% 46% 46%;
background: $color;
}
.ml__eye {
position: absolute; top: 40px; width: 10px; height: 12px;
border-radius: 50%; background: $subject-ink;
}
.ml__eye--l { left: 22px; }
.ml__eye--r { right: 22px; }
.ml__cheek {
position: absolute; top: 56px; width: 12px; height: 6px;
border-radius: $r-pill; background: $subject-pink; opacity: .9;
}
.ml__cheek--l { left: 14px; }
.ml__cheek--r { right: 14px; }
.ml__mouth {
position: absolute; top: 60px; left: 50%; width: 16px; height: 8px;
margin-left: -8px;
border-bottom: 3px solid $subject-ink;
border-radius: 0 0 $r-pill $r-pill;
}
// 팔 — 몸 뒤에서 상자 아래 귀퉁이로 뻗는다. 어깨 관절(팔 윗점)을 축으로 돌려 세운 각도.
.ml__arm {
position: absolute; top: 56px; width: 10px; height: 30px;
border-radius: $r-pill;
background: $color;
transform-origin: 5px 5px;
}
.ml__arm--l { left: 11px; transform: rotate(-148deg); }
.ml__arm--r { right: 11px; transform: rotate(148deg); }
// 상자는 캐릭터가 드는 피사체 소품이라 손으로 그린다(UI 아이콘이 아니다).
// 테이프 줄 한 가운데 — 잉크색이라 크림 팔·노랑 면 둘 다에서 떨어진다.
.ml__box {
position: absolute; top: -4px; left: calc(50% - 22px);
width: 44px; height: 34px;
border-radius: $r-xs;
background: $accent;
}
.ml__box::after {
content: ""; position: absolute; top: 0; bottom: 0;
left: calc(50% - 4px); width: 8px;
background: $subject-ink; opacity: .85;
}
// ── 진행 막대 + 카운터 ──
.ml__meta { display: flex; align-items: center; gap: $sp-2; margin: 0; width: 100%; }
.ml__odo { display: flex; align-items: center; flex: 0 0 auto; }
.ml__odoWin {
display: block; width: 10px; height: 14px; overflow: hidden;
font-weight: 700; font-size: 14px; line-height: 14px; color: $subject-cream;
}
.ml__odoCol { display: block; }
.ml__odoCol b { display: block; font-weight: 700; }
// 한 글자 창 — 0 부터 9 까지 10장을 세로로 쌓고 translateY 를 steps(10, end) 로 끊어
// 보이는 글자를 바꾼다. -100% 는 띠 전체(10 글자) 기준이라 한 글자가 10% 씩이다.
.ml__odoCol--t {
animation-name: mlOdo;
animation-duration: $duration;
animation-timing-function: steps(10, end);
animation-iteration-count: infinite;
}
.ml__odoCol--u {
animation-name: mlOdo;
animation-duration: 200ms;
animation-timing-function: steps(10, end);
animation-iteration-count: infinite;
}
@keyframes mlOdo {
0% { transform: translateY(0); }
100% { transform: translateY(-100%); }
}
.ml__pct {
display: block; margin-left: $sp-1;
font-weight: 700; font-size: 14px; line-height: 14px; color: $subject-cream;
}
.ml__track {
position: relative; display: block; flex: 1 1 auto;
height: 8px; border-radius: $r-pill;
background: rgba($subject-cream, .16);
overflow: hidden;
}
.ml__fill {
position: absolute; inset: 0; display: block;
border-radius: $r-pill;
background: $accent;
transform-origin: 0 50%;
transform: scaleX(0);
animation-name: mlFill;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes mlFill {
0% { transform: scaleX(0); }
100% { transform: scaleX(1); }
}
// ── 좁은 폭 (320px 무대) ── 세로 예산이 200px 라 전체적으로 4px 눈금 안에서 줄인다
@media (max-width: 340px) {
.ml { gap: $sp-2; }
.ml__scene { height: 116px; }
.ml__lane { height: 112px; --char-w: 92px; }
.ml__step { width: var(--char-w); height: 112px; }
.ml__body { left: 8px; width: 76px; height: 82px; }
.ml__eye { top: 34px; width: 9px; height: 11px; }
.ml__eye--l { left: 18px; }
.ml__eye--r { right: 18px; }
.ml__cheek { top: 48px; }
.ml__cheek--l { left: 10px; }
.ml__cheek--r { right: 10px; }
.ml__mouth { top: 52px; }
.ml__arm { top: 48px; height: 28px; }
.ml__arm--l { left: 8px; }
.ml__arm--r { right: 8px; }
.ml__box { top: 0; left: calc(50% - 20px); width: 40px; height: 30px; }
}
// reduce — 상자를 들고 한가운데 선 장면 한 장 + 막대는 60% 찬 상태로 멈춘다(카운터도 60)
@media (prefers-reduced-motion: reduce) {
.ml__lane,
.ml__step,
.ml__fill,
.ml__odoCol--t,
.ml__odoCol--u { animation: none !important; }
.ml__lane { transform: translateX(-38%) !important; }
.ml__step { transform: none !important; }
.ml__fill { transform: scaleX(.6) !important; }
.ml__odoCol--t { transform: translateY(-60%) !important; }
.ml__odoCol--u { transform: translateY(0) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ml {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 340px;
}
.ml__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #fff7e6;
}
.ml__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.ml__hint span {
flex: 1 1 auto;
min-width: 0;
}
.ml__scene {
position: relative;
width: 100%;
height: 150px;
}
.ml__ground {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: block;
height: 2px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.18);
}
.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);
}
}
.ml__step {
position: absolute;
right: 0;
bottom: 0;
width: var(--char-w);
height: 140px;
transform-origin: 50% 100%;
animation-name: mlStep;
animation-duration: 250ms;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes mlStep {
0%, 100% {
transform: translateY(0) scaleY(0.9);
}
40% {
transform: translateY(-6px) scaleY(1.05);
}
80% {
transform: translateY(0) scaleY(1);
}
}
.ml__body {
position: absolute;
left: 8px;
bottom: 0;
width: 88px;
height: 96px;
border-radius: 50% 50% 46% 46%;
background: #fff7e6;
}
.ml__eye {
position: absolute;
top: 40px;
width: 10px;
height: 12px;
border-radius: 50%;
background: #17141a;
}
.ml__eye--l {
left: 22px;
}
.ml__eye--r {
right: 22px;
}
.ml__cheek {
position: absolute;
top: 56px;
width: 12px;
height: 6px;
border-radius: 999px;
background: #ff9ebb;
opacity: 0.9;
}
.ml__cheek--l {
left: 14px;
}
.ml__cheek--r {
right: 14px;
}
.ml__mouth {
position: absolute;
top: 60px;
left: 50%;
width: 16px;
height: 8px;
margin-left: -8px;
border-bottom: 3px solid #17141a;
border-radius: 0 0 999px 999px;
}
.ml__arm {
position: absolute;
top: 56px;
width: 10px;
height: 30px;
border-radius: 999px;
background: #fff7e6;
transform-origin: 5px 5px;
}
.ml__arm--l {
left: 11px;
transform: rotate(-148deg);
}
.ml__arm--r {
right: 11px;
transform: rotate(148deg);
}
.ml__box {
position: absolute;
top: -4px;
left: calc(50% - 22px);
width: 44px;
height: 34px;
border-radius: 4px;
background: #ffd23f;
}
.ml__box::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: calc(50% - 4px);
width: 8px;
background: #17141a;
opacity: 0.85;
}
.ml__meta {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
width: 100%;
}
.ml__odo {
display: flex;
align-items: center;
flex: 0 0 auto;
}
.ml__odoWin {
display: block;
width: 10px;
height: 14px;
overflow: hidden;
font-weight: 700;
font-size: 14px;
line-height: 14px;
color: #fff7e6;
}
.ml__odoCol {
display: block;
}
.ml__odoCol b {
display: block;
font-weight: 700;
}
.ml__odoCol--t {
animation-name: mlOdo;
animation-duration: 2s;
animation-timing-function: steps(10, end);
animation-iteration-count: infinite;
}
.ml__odoCol--u {
animation-name: mlOdo;
animation-duration: 200ms;
animation-timing-function: steps(10, end);
animation-iteration-count: infinite;
}
@keyframes mlOdo {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-100%);
}
}
.ml__pct {
display: block;
margin-left: 4px;
font-weight: 700;
font-size: 14px;
line-height: 14px;
color: #fff7e6;
}
.ml__track {
position: relative;
display: block;
flex: 1 1 auto;
height: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
overflow: hidden;
}
.ml__fill {
position: absolute;
inset: 0;
display: block;
border-radius: 999px;
background: #ffd23f;
transform-origin: 0 50%;
transform: scaleX(0);
animation-name: mlFill;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes mlFill {
0% {
transform: scaleX(0);
}
100% {
transform: scaleX(1);
}
}
@media (max-width: 340px) {
.ml {
gap: 8px;
}
.ml__scene {
height: 116px;
}
.ml__lane {
height: 112px;
--char-w: 92px;
}
.ml__step {
width: var(--char-w);
height: 112px;
}
.ml__body {
left: 8px;
width: 76px;
height: 82px;
}
.ml__eye {
top: 34px;
width: 9px;
height: 11px;
}
.ml__eye--l {
left: 18px;
}
.ml__eye--r {
right: 18px;
}
.ml__cheek {
top: 48px;
}
.ml__cheek--l {
left: 10px;
}
.ml__cheek--r {
right: 10px;
}
.ml__mouth {
top: 52px;
}
.ml__arm {
top: 48px;
height: 28px;
}
.ml__arm--l {
left: 8px;
}
.ml__arm--r {
right: 8px;
}
.ml__box {
top: 0;
left: calc(50% - 20px);
width: 40px;
height: 30px;
}
}
@media (prefers-reduced-motion: reduce) {
.ml__lane,
.ml__step,
.ml__fill,
.ml__odoCol--t,
.ml__odoCol--u {
animation: none !important;
}
.ml__lane {
transform: translateX(-38%) !important;
}
.ml__step {
transform: none !important;
}
.ml__fill {
transform: scaleX(0.6) !important;
}
.ml__odoCol--t {
transform: translateY(-60%) !important;
}
.ml__odoCol--u {
transform: translateY(0) !important;
}
}// CarryLoading — vanilla 판(run/300/demo/05-mascot-carry-loading/index.html) 의 React 포트.
// 캐릭터가 상자를 들고 왕복하는 동안 막대가 왼쪽부터 채워진다. CSS 만으로 성립 — JS 상태가 없다.
// duration·easing·color 는 props → CSS 변수. 걷기(편도)·발 딛임·카운터 단위는 duration 의 약수로 파생
// (2s 기준 1s · 250ms · 200ms — 왕복 한 바퀴가 duration 안에 들어온다).
import type { CSSProperties } from "react";
import styles from "./CarryLoading.module.scss";
export type CarryLoadingProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--walk"?: string;
"--step"?: string;
"--odo-u"?: string;
};
const HINT_TEXT = "짐을 나르는 동안 막대가 채워집니다";
const PROGRESS_LABEL = "짐 나르기 진행률";
const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
export default function CarryLoading({
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
}: CarryLoadingProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--walk": `${duration / 2}ms`,
"--step": `${duration / 8}ms`,
"--odo-u": `${duration / 10}ms`,
};
return (
<main className={styles.stage}>
<div className={styles.ml} style={vars as CSSProperties}>
<p className={styles.ml__hint}>
<svg className={styles.ml__ico} aria-hidden="true">
<use href="#ico-package" />
</svg>
<span>{HINT_TEXT}</span>
</p>
<div className={styles.ml__scene}>
<i className={styles.ml__ground}></i>
{/* 캐릭터·상자는 장식 — 진행 상태는 아래 막대의 role="progressbar" 가 알린다 */}
<div className={styles.ml__lane} aria-hidden={true}>
<div className={styles.ml__step}>
<i className={`${styles.ml__arm} ${styles["ml__arm--l"]}`}></i>
<i className={`${styles.ml__arm} ${styles["ml__arm--r"]}`}></i>
<span className={styles.ml__body}>
<i className={`${styles.ml__eye} ${styles["ml__eye--l"]}`}></i>
<i className={`${styles.ml__eye} ${styles["ml__eye--r"]}`}></i>
<i className={`${styles.ml__cheek} ${styles["ml__cheek--l"]}`}></i>
<i className={`${styles.ml__cheek} ${styles["ml__cheek--r"]}`}></i>
<i className={styles.ml__mouth}></i>
</span>
<span className={styles.ml__box}></span>
</div>
</div>
</div>
<p className={styles.ml__meta}>
{/* 숫자는 막대 밖 잉크 무대 위 — 카운터는 00 부터 99 까지 duration 에 한 바퀴 */}
<span className={styles.ml__odo} aria-hidden={true}>
<i className={styles.ml__odoWin}>
<i className={`${styles.ml__odoCol} ${styles["ml__odoCol--t"]}`}>
{DIGITS.map((d) => <b key={d}>{d}</b>)}
</i>
</i>
<i className={styles.ml__odoWin}>
<i className={`${styles.ml__odoCol} ${styles["ml__odoCol--u"]}`}>
{DIGITS.map((d) => <b key={d}>{d}</b>)}
</i>
</i>
<b className={styles.ml__pct}>%</b>
</span>
<span
className={styles.ml__track}
role="progressbar"
aria-valuenow="0"
aria-valuemin="0"
aria-valuemax="100"
aria-label={PROGRESS_LABEL}
>
<i className={styles.ml__fill}></i>
</span>
</p>
</div>
</main>
);
}05 Carrying loader — Measured area 33.276% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Mascot Animation: 9 CSS Parts from Wave to Sleep
The post this demo lives in
Mascot Animation: 9 CSS Parts from Wave to SleepA mascot animation is a small drawn character that reacts to what the screen is doing: waving at a new visitor, pointing See all demos in this post


