9 Loading Screen Designs — Pretty CSS Loaders
A loading screen design is the first thing your site says while the user waits. A pastel ring or a circle filling with water makes that wait feel intentional —
01 loading-screen-candy-ring
02 loading-screen-gooey-dots
03 loading-screen-blob-morph
04 loading-screen-squish-ball
05 loading-screen-orbit-dots
06 loading-screen-liquid-fill
07 loading-screen-count-up
08 loading-screen-cube-stack
09 loading-screen-curtain-reveal
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Candy ring spin
- 02 Gooey dots
- 03 Morphing blob
- 04 Squish ball
- 05 Orbit dots
- 06 Liquid fill circle
- 07 Count-up number
- 08 Cube stack
- 09 Curtain reveal
The nine loading screen design ideas follow the order you'd actually pick them in, not popularity. You start with the safe first choice, the candy ring (01), and the familiar dots (02), move into personality with the blob (03) and the ball (04), and then give the wait meaning with the orbit (05) and the liquid fill (06). The count-up (07) and cube stack (08) show progress, and the curtain (09) stages the moment loading ends. When the one waiting is a chat window rather than a person, 9 CSS AI Chat Loading Animations takes the slot, and when the screen changes pages entirely it's Page Loading Animation CSS that fits.
01Candy ring spin
What this css loading animation shows
A conic-gradient ring blending three pastel colors gets carved into a donut with mask and spins once a second. A wrapper around it breathes to 1.12× on its own slower, 2-second cycle, so the ring feels alive instead of merely rotating.

<main class="stage">
<div class="cr-wrap is-demo" aria-hidden="true">
<div class="cr-ring"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s; // 링 한 바퀴
$easing: $ease-out;
$color: $subject-pink;
@include stage($stage-ink);
// 실사용: 로딩 시작하면 JS 가 .is-loading 을 붙인다
.cr-wrap {
width: 148px;
height: 148px;
display: grid;
place-items: center;
}
.cr-ring {
width: 148px;
height: 148px;
border-radius: 50%;
background: conic-gradient(from 0deg, $color, $subject-lilac, $subject-sky, $color);
mask: radial-gradient(farthest-side, #0000 calc(100% - 22px), #000 calc(100% - 21px));
}
.cr-wrap.is-loading,
.cr-wrap.is-demo {
.cr-ring { animation: cr-spin $duration linear infinite; }
animation: cr-breathe 2s $easing infinite;
}
@keyframes cr-spin {
to { transform: rotate(360deg); }
}
@keyframes cr-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.12); }
}
@media (prefers-reduced-motion: reduce) {
.cr-wrap.is-loading,
.cr-wrap.is-demo {
animation: none !important;
.cr-ring { animation: none !important; }
}
}
// 320px 폭 스테이지에서 1.12배 breathe 가 패딩 여유를 거의 다 먹어 타이밍에 따라 세로 넘침이 났다
// (실측 2026-09-11: 148px 링 기준 여유 8.64px 뿐이라 렌더 타이밍마다 폰넘침 O/X 가 갈림) — 좁은 화면에서 링 자체를 줄여 여유를 키운다
@media (max-width: 340px), (max-height: 220px) {
.cr-wrap, .cr-ring { width: 116px; height: 116px; }
}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);
}
.cr-wrap {
width: 148px;
height: 148px;
display: grid;
place-items: center;
}
.cr-ring {
width: 148px;
height: 148px;
border-radius: 50%;
background: conic-gradient(from 0deg, #ff9ebb, #b9a0ff, #6ec8ff, #ff9ebb);
mask: radial-gradient(farthest-side, rgba(0, 0, 0, 0) calc(100% - 22px), #000 calc(100% - 21px));
}
.cr-wrap.is-loading .cr-ring,
.cr-wrap.is-demo .cr-ring {
animation: cr-spin 1s linear infinite;
}
.cr-wrap.is-loading,
.cr-wrap.is-demo {
animation: cr-breathe 2s ease-out infinite;
}
@keyframes cr-spin {
to {
transform: rotate(360deg);
}
}
@keyframes cr-breathe {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.12);
}
}
@media (prefers-reduced-motion: reduce) {
.cr-wrap.is-loading,
.cr-wrap.is-demo {
animation: none !important;
}
.cr-wrap.is-loading .cr-ring,
.cr-wrap.is-demo .cr-ring {
animation: none !important;
}
}
@media (max-width: 340px), (max-height: 220px) {
.cr-wrap, .cr-ring {
width: 116px;
height: 116px;
}
}import { CSSProperties } from "react";
import styles from "./CandyRing.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface CandyRingProps {
duration?: string;
easing?: string;
color?: string;
}
export default function CandyRing({
duration = "1s",
easing = "ease-out",
color = "#ff9ebb",
}: CandyRingProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={`${styles.wrap} ${styles.demo}`} style={style} aria-hidden="true">
<div className={styles.ring} />
</div>
);
}01 · Candy ring spin — Measured area 9.335% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
It reads like a piece of candy on a dark first screen, which makes it a good flagship loader for dark-themed apps.
.cr-ring {
width: 148px;
height: 148px;
border-radius: 50%;
background: conic-gradient(from 0deg, $color, $subject-lilac, $subject-sky, $color);
mask: radial-gradient(farthest-side, #0000 calc(100% - 22px), #000 calc(100% - 21px));
}
.cr-wrap.is-demo {
.cr-ring { animation: cr-spin $duration linear infinite; }
animation: cr-breathe 2s $easing infinite;
}
@keyframes cr-spin { to { transform: rotate(360deg); } }
@keyframes cr-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.12); }
}
02Gooey dots
What happens on screen
Only the middle dot moves — it slides left and right to visit its two stationary siblings, stretching into a blob on contact before snapping back to center. The first version used the classic blur plus contrast filter trick, but on a yellow background it left a red rim around every dot, so the demo now boosts only the alpha channel with an SVG feColorMatrix.

<main class="stage">
<!-- gooey: 색이 아니라 알파(불투명도)를 증폭해 끈적이는 이음새를 만든다 -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="goo-filter">
<feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur"/>
<feColorMatrix in="blur" mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"/>
</filter>
</defs>
</svg>
<div class="goo is-demo" aria-hidden="true">
<span class="goo-dot gd-a"></span>
<span class="goo-dot gd-b"></span>
<span class="goo-dot gd-c"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 2s;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-yellow);
// gooey 필터: SVG feGaussianBlur 로 번지게 한 뒤 feColorMatrix 로
// 알파(불투명도)만 증폭해 경계를 다시 세운다. 색 채널은 건드리지 않아
// blur+contrast 조합이 노란 배경에서 만드는 붉은 테두리가 생기지 않는다.
.goo {
position: relative;
display: flex;
align-items: center;
padding: $sp-6 $sp-8;
filter: url(#goo-filter);
}
.goo-dot {
width: 44px;
height: 44px;
border-radius: 50%;
background: $color;
margin-right: $sp-6;
}
.goo-dot:last-child { margin-right: 0; }
// 실사용: 로딩 중에만 .is-loading — 가운데 점이 좌우 끝점을 차례로 찾아간다
.goo.is-loading .gd-b,
.goo.is-demo .gd-b {
animation: goo-slide $duration $easing infinite;
}
@keyframes goo-slide {
0%, 50%, 100% { transform: translateX(0); }
25% { transform: translateX(-68px); }
75% { transform: translateX(68px); }
}
@media (prefers-reduced-motion: reduce) {
.goo.is-loading .gd-b,
.goo.is-demo .gd-b {
animation: none !important;
transform: none !important;
}
.goo { filter: none; }
}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);
}
.goo {
position: relative;
display: flex;
align-items: center;
padding: 24px 32px;
filter: url(#goo-filter);
}
.goo-dot {
width: 44px;
height: 44px;
border-radius: 50%;
background: #4cd4a6;
margin-right: 24px;
}
.goo-dot:last-child {
margin-right: 0;
}
.goo.is-loading .gd-b,
.goo.is-demo .gd-b {
animation: goo-slide 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes goo-slide {
0%, 50%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-68px);
}
75% {
transform: translateX(68px);
}
}
@media (prefers-reduced-motion: reduce) {
.goo.is-loading .gd-b,
.goo.is-demo .gd-b {
animation: none !important;
transform: none !important;
}
.goo {
filter: none;
}
}import { CSSProperties } from "react";
import styles from "./GooeyDots.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface GooeyDotsProps {
duration?: string;
easing?: string;
color?: string;
/** 페이지에 필터가 여러 개면 겹치지 않게 다른 id 를 준다 */
filterId?: string;
}
export default function GooeyDots({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#4cd4a6",
filterId = "goo-loader",
}: GooeyDotsProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<filter id={filterId}>
<feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"
/>
</filter>
</defs>
</svg>
<div
className={styles.goo}
style={{ ...style, filter: `url(#${filterId})` }}
aria-hidden="true"
>
<span className={styles.dot} />
<span className={`${styles.dot} ${styles.dotB}`} />
<span className={styles.dot} />
</div>
</>
);
}02 · Gooey dots — Measured area 3.392% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Same motion, zero color artifacts.
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="goo-filter">
<feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur"/>
<feColorMatrix in="blur" mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"/>
</filter>
</defs>
</svg>
<div class="goo">
<span class="goo-dot"></span>
<span class="goo-dot gd-b"></span>
<span class="goo-dot"></span>
</div>
.goo {
display: flex;
align-items: center;
padding: $sp-6 $sp-8;
filter: url(#goo-filter);
}
.gd-b { animation: goo-slide 2s $ease-spring infinite; }
@keyframes goo-slide {
0%, 50%, 100% { transform: translateX(0); }
25% { transform: translateX(-68px); }
75% { transform: translateX(68px); }
}
03Morphing blob
What moves
A cushion-shaped blob squirms without pause by stepping through eight border-radius values while rotating 90 degrees between each. The four radius pairs return to their starting values exactly on the 2-second loop, so the seam is invisible.

<main class="stage">
<div class="mb-blob is-demo" aria-hidden="true"></div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 2s;
$easing: linear;
$color: $subject-cream;
@include stage($stage-orange);
// 실사용: 로딩 중에만 .is-loading
.mb-blob {
width: 112px;
height: 112px;
border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%; // 정지 모양도 블롭
background: $color;
}
.mb-blob.is-loading,
.mb-blob.is-demo {
// 반경 여덟 값이 순서를 바꾸며 모양을 바꾸고, 회전이 붙어 한 바퀴에 정확히 돌아온다
animation: mb-morph $duration $easing infinite;
}
@keyframes mb-morph {
0% {
border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%;
transform: rotate(0deg) scale(1);
}
25% {
border-radius: 45% 55% 60% 40% / 52% 42% 58% 48%;
transform: rotate(90deg) scale(1.06);
}
50% {
border-radius: 57% 43% 40% 60% / 55% 47% 53% 45%;
transform: rotate(180deg) scale(0.96);
}
75% {
border-radius: 42% 58% 57% 43% / 48% 55% 45% 52%;
transform: rotate(270deg) scale(1.04);
}
100% {
border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%;
transform: rotate(360deg) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.mb-blob.is-loading,
.mb-blob.is-demo {
animation: none !important;
transform: none !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mb-blob {
width: 112px;
height: 112px;
border-radius: 58% 42% 43% 57%/45% 58% 42% 55%;
background: #fff7e6;
}
.mb-blob.is-loading,
.mb-blob.is-demo {
animation: mb-morph 2s linear infinite;
}
@keyframes mb-morph {
0% {
border-radius: 58% 42% 43% 57%/45% 58% 42% 55%;
transform: rotate(0deg) scale(1);
}
25% {
border-radius: 45% 55% 60% 40%/52% 42% 58% 48%;
transform: rotate(90deg) scale(1.06);
}
50% {
border-radius: 57% 43% 40% 60%/55% 47% 53% 45%;
transform: rotate(180deg) scale(0.96);
}
75% {
border-radius: 42% 58% 57% 43%/48% 55% 45% 52%;
transform: rotate(270deg) scale(1.04);
}
100% {
border-radius: 58% 42% 43% 57%/45% 58% 42% 55%;
transform: rotate(360deg) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.mb-blob.is-loading,
.mb-blob.is-demo {
animation: none !important;
transform: none !important;
}
}import { CSSProperties } from "react";
import styles from "./MorphingBlob.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface MorphingBlobProps {
duration?: string;
easing?: string;
color?: string;
}
export default function MorphingBlob({
duration = "2s",
easing = "linear",
color = "#fff7e6",
}: MorphingBlobProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return <div className={styles.blob} style={style} aria-hidden="true" />;
}03 · Morphing blob — Measured area 2.206% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The static base keeps a blob shape too, so it still looks right when animation is off.
.mb-blob {
width: 112px;
height: 112px;
border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%;
background: $color;
}
.mb-blob.is-demo { animation: mb-morph $duration linear infinite; }
@keyframes mb-morph {
0% { border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%; transform: rotate(0deg) scale(1); }
25% { border-radius: 45% 55% 60% 40% / 52% 42% 58% 48%; transform: rotate(90deg) scale(1.06); }
50% { border-radius: 57% 43% 40% 60% / 55% 47% 53% 45%; transform: rotate(180deg) scale(.96); }
75% { border-radius: 42% 58% 57% 43% / 48% 55% 45% 52%; transform: rotate(270deg) scale(1.04); }
100% { border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%; transform: rotate(360deg) scale(1); }
}
04Squish ball
What you see
The ball drops, splats flat to .55 height on impact, holds a beat, then rebounds on its own elasticity. transform-origin sits at the bottom center so the ball's underside stays glued to the floor during the squash, and a shadow ellipse shrinks and fades while the ball is high.

<main class="stage">
<div class="sq-scene is-demo" aria-hidden="true">
<div class="sq-ball"></div>
<div class="sq-shadow"></div>
<div class="sq-floor"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s;
$easing: cubic-bezier(.45, 0, .8, .6); // 떨어질 때: 점점 빨라지는 중력
$color: $subject-blue;
@include stage($stage-paper);
// 실사용: 로딩 중에만 .is-loading
.sq-scene {
position: relative;
width: 112px;
height: 164px;
}
.sq-ball {
position: absolute;
left: 50%;
bottom: 2px; // 바닥선(2px) 위에 정확히 얹힌다
width: 56px;
height: 56px;
margin-left: -28px;
border-radius: 50%;
background: $color;
transform-origin: 50% 100%; // 바닥에 닿은 채로 찍히게
}
.sq-shadow {
position: absolute;
left: 50%;
bottom: 0;
width: 56px;
height: $sp-1;
margin-left: -28px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .22);
}
.sq-floor {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 2px;
background: $subject-ink;
}
.sq-scene.is-loading .sq-ball,
.sq-scene.is-demo .sq-ball {
animation: sq-bounce $duration $easing infinite;
}
.sq-scene.is-loading .sq-shadow,
.sq-scene.is-demo .sq-shadow {
animation: sq-shadow-pulse $duration $easing infinite;
}
@keyframes sq-bounce {
0% { transform: translateY(-98px) scale(1, 1); }
55% { transform: translateY(0) scale(1, 1); } // 바닥 도착
68% { transform: translateY(0) scale(1.28, .55); } // 쿵 — 찍힘
80% { transform: translateY(0) scale(.92, 1.08); } // 되튕기며 복원
100% { transform: translateY(-98px) scale(1, 1); }
}
@keyframes sq-shadow-pulse {
0%, 100% { transform: scale(.5); opacity: .3; }
55%, 80% { transform: scale(1.05); opacity: .75; }
}
@media (prefers-reduced-motion: reduce) {
.sq-scene.is-loading .sq-ball,
.sq-scene.is-demo .sq-ball {
animation: none !important;
transform: translateY(-40px) !important; // 정지해도 공중에 떠 있는 모양
}
.sq-scene.is-loading .sq-shadow,
.sq-scene.is-demo .sq-shadow {
animation: none !important;
opacity: .5 !important;
transform: scale(.8) !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sq-scene {
position: relative;
width: 112px;
height: 164px;
}
.sq-ball {
position: absolute;
left: 50%;
bottom: 2px;
width: 56px;
height: 56px;
margin-left: -28px;
border-radius: 50%;
background: #2f6df6;
transform-origin: 50% 100%;
}
.sq-shadow {
position: absolute;
left: 50%;
bottom: 0;
width: 56px;
height: 4px;
margin-left: -28px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.22);
}
.sq-floor {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 2px;
background: #17141a;
}
.sq-scene.is-loading .sq-ball,
.sq-scene.is-demo .sq-ball {
animation: sq-bounce 1s cubic-bezier(0.45, 0, 0.8, 0.6) infinite;
}
.sq-scene.is-loading .sq-shadow,
.sq-scene.is-demo .sq-shadow {
animation: sq-shadow-pulse 1s cubic-bezier(0.45, 0, 0.8, 0.6) infinite;
}
@keyframes sq-bounce {
0% {
transform: translateY(-98px) scale(1, 1);
}
55% {
transform: translateY(0) scale(1, 1);
}
68% {
transform: translateY(0) scale(1.28, 0.55);
}
80% {
transform: translateY(0) scale(0.92, 1.08);
}
100% {
transform: translateY(-98px) scale(1, 1);
}
}
@keyframes sq-shadow-pulse {
0%, 100% {
transform: scale(0.5);
opacity: 0.3;
}
55%, 80% {
transform: scale(1.05);
opacity: 0.75;
}
}
@media (prefers-reduced-motion: reduce) {
.sq-scene.is-loading .sq-ball,
.sq-scene.is-demo .sq-ball {
animation: none !important;
transform: translateY(-40px) !important;
}
.sq-scene.is-loading .sq-shadow,
.sq-scene.is-demo .sq-shadow {
animation: none !important;
opacity: 0.5 !important;
transform: scale(0.8) !important;
}
}import { CSSProperties } from "react";
import styles from "./SquishBall.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface SquishBallProps {
duration?: string;
easing?: string;
color?: string;
}
export default function SquishBall({
duration = "1s",
easing = "cubic-bezier(.45, 0, .8, .6)",
color = "#2f6df6",
}: SquishBallProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.scene} style={style} aria-hidden="true">
<div className={styles.ball} />
<div className={styles.shadow} />
<div className={styles.floor} />
</div>
);
}04 · Squish ball — Measured area 5.947% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The fall uses an accelerating cubic-bezier(.45, 0, .8, .6) for a sense of gravity.
.sq-ball {
position: absolute;
left: 50%;
bottom: 2px;
width: 56px;
height: 56px;
margin-left: -28px;
border-radius: 50%;
background: $color;
transform-origin: 50% 100%;
}
.sq-ball.is-demo { animation: sq-bounce 1s cubic-bezier(.45, 0, .8, .6) infinite; }
@keyframes sq-bounce {
0% { transform: translateY(-98px) scale(1, 1); }
55% { transform: translateY(0) scale(1, 1); }
68% { transform: translateY(0) scale(1.28, .55); }
80% { transform: translateY(0) scale(.92, 1.08); }
100% { transform: translateY(-98px) scale(1, 1); }
}
05Orbit dots
What happens on screen
Three differently sized dots share one center and orbit on radii of 36, 52 and 68px. The smaller the dot, the faster the angular speed (1s versus 2s), which gives a planetary sense of depth, and the three start 120 degrees apart so they never bunch up.

<main class="stage">
<div class="ob-system is-demo" aria-hidden="true">
<span class="ob-core"></span>
<span class="ob-dot od-s"></span>
<span class="ob-dot od-m"></span>
<span class="ob-dot od-l"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 2s; // 바깥 두 점의 한 바퀴 (안쪽 작은 점은 절반)
$easing: linear;
$color: $subject-cream;
@include stage($stage-ink);
// 실사용: 로딩 중에만 .is-loading
// 각 점은 자기 transform-origin 을 계통 중심에 두고 제자리에서 공전한다.
// 회전하는 컨다란 팔 박스를 쓰면 정사각형이 45도에 경계 상자가 루트2배로
// 커져 320px 폰 화면에서 세로 넘침을 일으킨다 (219 실측).
.ob-system {
position: relative;
width: 168px;
height: 168px;
}
.ob-core {
position: absolute;
left: 50%;
top: 50%;
width: 24px;
height: 24px;
margin: -12px 0 0 -12px;
border-radius: 50%;
background: $subject-sky;
}
.ob-dot {
position: absolute;
border-radius: 50%;
}
// 작은 점: 안쪽 궤도(반지름 52), 빠르게
.od-s {
top: 26px; // 84 - 52 - 6
left: calc(50% - 6px);
width: 12px;
height: 12px;
background: $color;
transform-origin: 50% 58px; // 점 중심에서 계통 중심까지 52 + 자기 반지름 6
}
// 중간 점: 가장 안쪽 궤도(반지름 36)
.od-m {
top: 39px; // 84 - 36 - 9
left: calc(50% - 9px);
width: 18px;
height: 18px;
background: $subject-pink;
transform-origin: 50% 45px; // 규칙8: 계통 중심 공전 축
}
// 큰 점: 바깥 궤도(반지름 68), 느리게
.od-l {
top: 3px; // 84 - 68 - 13
left: calc(50% - 13px);
width: 26px;
height: 26px;
background: $subject-mint;
transform-origin: 50% 81px; // 규칙8: 계통 중심 공전 축
}
// 세 점은 같은 방향, 서로 120도씩 어긋난 위상에서 출발한다
.ob-system.is-loading .od-s,
.ob-system.is-demo .od-s { animation: ob-spin-a ($duration / 2) $easing infinite; }
.ob-system.is-loading .od-m,
.ob-system.is-demo .od-m { animation: ob-spin-b $duration $easing infinite; }
.ob-system.is-loading .od-l,
.ob-system.is-demo .od-l { animation: ob-spin-c $duration $easing infinite; }
@keyframes ob-spin-a {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes ob-spin-b {
from { transform: rotate(120deg); }
to { transform: rotate(480deg); }
}
@keyframes ob-spin-c {
from { transform: rotate(240deg); }
to { transform: rotate(600deg); }
}
@media (prefers-reduced-motion: reduce) {
.ob-system.is-loading .od-s,
.ob-system.is-loading .od-m,
.ob-system.is-loading .od-l,
.ob-system.is-demo .od-s,
.ob-system.is-demo .od-m,
.ob-system.is-demo .od-l {
animation: none !important;
transform: none !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);
}
.ob-system {
position: relative;
width: 168px;
height: 168px;
}
.ob-core {
position: absolute;
left: 50%;
top: 50%;
width: 24px;
height: 24px;
margin: -12px 0 0 -12px;
border-radius: 50%;
background: #6ec8ff;
}
.ob-dot {
position: absolute;
border-radius: 50%;
}
.od-s {
top: 26px;
left: calc(50% - 6px);
width: 12px;
height: 12px;
background: #fff7e6;
transform-origin: 50% 58px;
}
.od-m {
top: 39px;
left: calc(50% - 9px);
width: 18px;
height: 18px;
background: #ff9ebb;
transform-origin: 50% 45px;
}
.od-l {
top: 3px;
left: calc(50% - 13px);
width: 26px;
height: 26px;
background: #4cd4a6;
transform-origin: 50% 81px;
}
.ob-system.is-loading .od-s,
.ob-system.is-demo .od-s {
animation: ob-spin-a 1s linear infinite;
}
.ob-system.is-loading .od-m,
.ob-system.is-demo .od-m {
animation: ob-spin-b 2s linear infinite;
}
.ob-system.is-loading .od-l,
.ob-system.is-demo .od-l {
animation: ob-spin-c 2s linear infinite;
}
@keyframes ob-spin-a {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes ob-spin-b {
from {
transform: rotate(120deg);
}
to {
transform: rotate(480deg);
}
}
@keyframes ob-spin-c {
from {
transform: rotate(240deg);
}
to {
transform: rotate(600deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ob-system.is-loading .od-s,
.ob-system.is-loading .od-m,
.ob-system.is-loading .od-l,
.ob-system.is-demo .od-s,
.ob-system.is-demo .od-m,
.ob-system.is-demo .od-l {
animation: none !important;
transform: none !important;
}
}import { CSSProperties } from "react";
import styles from "./OrbitDots.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface OrbitDotsProps {
duration?: string;
easing?: string;
color?: string;
}
export default function OrbitDots({
duration = "2s",
easing = "linear",
color = "#fff7e6",
}: OrbitDotsProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.system} style={style} aria-hidden="true">
<span className={styles.core} />
<span className={`${styles.dot} ${styles.dotS}`} />
<span className={`${styles.dot} ${styles.dotM}`} />
<span className={`${styles.dot} ${styles.dotL}`} />
</div>
);
}05 · Orbit dots — Measured area 11.261% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
What rotates is each dot itself, not a big arm box — the reason is in the trap section below.
.od-s { // small dot: radius 52
top: 26px;
left: calc(50% - 6px);
width: 12px;
height: 12px;
transform-origin: 50% 58px; // distance from dot to system center
animation: ob-spin-a 1s linear infinite;
}
@keyframes ob-spin-a {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
06Liquid fill circle
What moves
Water rises inside a rounded ring while the surface itself comes from a big rounded square (radius 36%) rotating in place. The rise moves only the translate property so it never fights the rotation on transform, and the square's corners sweep a ripple across the surface as it turns.

<main class="stage">
<div class="lq-tube is-demo" aria-hidden="true">
<div class="lq-water"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 2s;
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-yellow);
// 실사용: 로딩 중에만 .is-loading
.lq-tube {
position: relative;
width: 150px;
height: 150px;
border: 4px solid $subject-ink;
border-radius: 50%;
overflow: hidden; // 원 밖의 물은 잘린다
}
.lq-water {
position: absolute;
top: 100%;
left: calc(50% - 55px);
width: 110px;
height: 110px;
border-radius: 36%;
background: $color;
}
.lq-tube.is-loading .lq-water,
.lq-tube.is-demo .lq-water {
// 두 동작의 조합: 사각형의 회전(물결 윗면) + translateY 상승
animation: lq-wave 2s linear infinite,
lq-rise $duration $easing infinite;
}
@keyframes lq-wave {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes lq-rise {
0% { translate: 0 0; }
50% { translate: 0 -60px; }
100% { translate: 0 0; }
}
@media (prefers-reduced-motion: reduce) {
.lq-tube.is-loading .lq-water,
.lq-tube.is-demo .lq-water {
animation: none !important;
translate: 0 -30px !important; // 절반쯤 찬 물에 수면이 기울어진 정지 모양
transform: rotate(12deg) !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);
}
.lq-tube {
position: relative;
width: 150px;
height: 150px;
border: 4px solid #17141a;
border-radius: 50%;
overflow: hidden;
}
.lq-water {
position: absolute;
top: 100%;
left: calc(50% - 55px);
width: 110px;
height: 110px;
border-radius: 36%;
background: #6ec8ff;
}
.lq-tube.is-loading .lq-water,
.lq-tube.is-demo .lq-water {
animation: lq-wave 2s linear infinite, lq-rise 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes lq-wave {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes lq-rise {
0% {
translate: 0 0;
}
50% {
translate: 0 -60px;
}
100% {
translate: 0 0;
}
}
@media (prefers-reduced-motion: reduce) {
.lq-tube.is-loading .lq-water,
.lq-tube.is-demo .lq-water {
animation: none !important;
translate: 0 -30px !important;
transform: rotate(12deg) !important;
}
}import { CSSProperties } from "react";
import styles from "./LiquidFill.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface LiquidFillProps {
duration?: string;
easing?: string;
color?: string;
/** 원 테두리 색 */
frameColor?: string;
}
export default function LiquidFill({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#6ec8ff",
frameColor = "#17141a",
}: LiquidFillProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
"--frame-color": frameColor,
} as Vars & { "--frame-color"?: string };
return (
<div className={styles.tube} style={style} aria-hidden="true">
<div className={styles.water} />
</div>
);
}06 · Liquid fill circle — Measured area 3.84% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The 2-second fill-and-drain loop keeps long waits from feeling stuck.
.lq-tube {
position: relative;
width: 150px;
height: 150px;
border: 4px solid $subject-ink;
border-radius: 50%;
overflow: hidden;
}
.lq-water {
position: absolute;
top: 100%;
left: calc(50% - 55px);
width: 110px;
height: 110px;
border-radius: 36%;
background: $color;
animation: lq-wave 2s linear infinite, lq-rise $duration $ease-spring infinite;
}
@keyframes lq-wave { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes lq-rise {
0% { translate: 0 0; }
50% { translate: 0 -60px; }
100% { translate: 0 0; }
}
07Count-up number
What you see
With zero JavaScript, an integer variable --n registered through @property climbs from 0 to 100 and prints itself via counter(). A thin conic-gradient ring reads the same variable, so the digits and the gauge can never drift apart.

<main class="stage">
<div class="cu-box is-demo" role="status">
<span class="cu-sr-only">Loading</span>
<div class="cu-ring" aria-hidden="true"></div>
<span class="cu-num" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 2s;
$easing: linear;
$color: $subject-cream;
@include stage($stage-orange);
// 숫자 변수를 등록해야 애니메이션과 counter() 로 쓸 수 있다 (JS 없음)
@property --n {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.cu-box {
position: relative;
width: 170px;
height: 170px;
display: grid;
place-items: center;
--n: 0;
}
.cu-sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cu-ring {
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient($color calc(var(--n) * 1%), rgba(23, 20, 26, .45) 0);
mask: radial-gradient(farthest-side, #0000 calc(100% - 20px), #000 calc(100% - 19px));
}
.cu-num {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 56px;
font-weight: 800;
line-height: 1;
color: $color;
counter-reset: n var(--n);
}
.cu-num::after {
content: counter(n) "%";
}
// 실사용: 로딩 중에만 .is-loading — 숫자와 링이 같은 변수를 읽어 정확히 맞물린다
.cu-box.is-loading,
.cu-box.is-demo {
animation: cu-count $duration $easing infinite;
}
@keyframes cu-count {
from { --n: 0; }
to { --n: 100; }
}
@media (prefers-reduced-motion: reduce) {
.cu-box.is-loading,
.cu-box.is-demo {
animation: none !important;
--n: 100 !important; // 100% 에 멈춘 모양
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --n {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.cu-box {
position: relative;
width: 170px;
height: 170px;
display: grid;
place-items: center;
--n: 0;
}
.cu-sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cu-ring {
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(#fff7e6 calc(var(--n) * 1%), rgba(23, 20, 26, 0.45) 0);
mask: radial-gradient(farthest-side, rgba(0, 0, 0, 0) calc(100% - 20px), #000 calc(100% - 19px));
}
.cu-num {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 56px;
font-weight: 800;
line-height: 1;
color: #fff7e6;
counter-reset: n var(--n);
}
.cu-num::after {
content: counter(n) "%";
}
.cu-box.is-loading,
.cu-box.is-demo {
animation: cu-count 2s linear infinite;
}
@keyframes cu-count {
from {
--n: 0;
}
to {
--n: 100;
}
}
@media (prefers-reduced-motion: reduce) {
.cu-box.is-loading,
.cu-box.is-demo {
animation: none !important;
--n: 100 !important;
}
}import { CSSProperties } from "react";
import styles from "./CountUpNumber.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface CountUpNumberProps {
duration?: string;
easing?: string;
color?: string;
/** 화면에 표시할 짧은 안내 문구 (스크린리더용) */
label?: string;
}
export default function CountUpNumber({
duration = "2s",
easing = "linear",
color = "#fff7e6",
label = "Loading",
}: CountUpNumberProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.box} style={style} role="status">
<span className={styles.srOnly}>{label}</span>
<div className={styles.ring} aria-hidden="true" />
<span className={styles.num} aria-hidden="true" />
</div>
);
}07 · Count-up number — Measured area 9.245% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The number itself is aria-hidden — it's decoration — while a visually hidden label carries role="status", so assistive tech announces the progress from that label instead of reading digits one by one.
@property --n {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.cu-num {
counter-reset: n var(--n);
}
.cu-num::after { content: counter(n) "%"; }
.cu-box.is-demo { animation: cu-count $duration linear infinite; }
@keyframes cu-count {
from { --n: 0; }
to { --n: 100; }
}
08Cube stack
What happens on screen
Four squares pop into their cells in grid reading order — top-left, top-right, bottom-left, bottom-right — at 120ms intervals, then a beat later all four settle together. The entrance (inner cube scale) and the landing (outer cell translateY) are split across two nested elements so one transform never overwrites the other.

<main class="stage">
<div class="cs-grid is-demo" aria-hidden="true">
<span class="cs-cell"><span class="cs-cube"></span></span>
<span class="cs-cell"><span class="cs-cube"></span></span>
<span class="cs-cell"><span class="cs-cube"></span></span>
<span class="cs-cell"><span class="cs-cube"></span></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 2s;
$easing: $ease-pop;
$color: $subject-lilac;
@include stage($stage-paper);
.cs-grid {
display: grid;
grid-template-columns: repeat(2, 56px);
grid-auto-rows: 56px;
gap: $sp-3;
}
.cs-cell {
display: grid;
place-items: center;
}
// 실사용: 로딩 중에만 .is-loading — 등장(안쪽 큐브 scale)은 하나씩,
// 착지(바깥 셀 translate)는 전부 같은 박자에
.cs-cube {
width: 48px;
height: 48px;
border-radius: $r-control;
background: $color;
box-shadow: $shadow-press;
}
.is-loading .cs-cell,
.is-demo .cs-cell {
animation: cs-settle $duration $easing infinite;
}
.is-loading .cs-cube,
.is-demo .cs-cube {
animation: cs-pop $duration $easing infinite backwards;
}
.is-loading .cs-cell:nth-child(1) .cs-cube,
.is-demo .cs-cell:nth-child(1) .cs-cube { animation-delay: 0s; }
.is-loading .cs-cell:nth-child(2) .cs-cube,
.is-demo .cs-cell:nth-child(2) .cs-cube { animation-delay: 120ms; }
.is-loading .cs-cell:nth-child(3) .cs-cube,
.is-demo .cs-cell:nth-child(3) .cs-cube { animation-delay: 240ms; }
.is-loading .cs-cell:nth-child(4) .cs-cube,
.is-demo .cs-cell:nth-child(4) .cs-cube { animation-delay: 360ms; }
@keyframes cs-pop {
0% { transform: scale(0); opacity: 0; }
16% { transform: scale(1.18); opacity: 1; }
22%, 68% { transform: scale(1); opacity: 1; }
90%, 100% { transform: scale(0); opacity: 0; }
}
@keyframes cs-settle {
0%, 60% { transform: translateY(0); }
70% { transform: translateY(8px); }
80%, 100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.is-loading .cs-cell,
.is-loading .cs-cube,
.is-demo .cs-cell,
.is-demo .cs-cube {
animation: none !important;
transform: none !important;
opacity: 1 !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cs-grid {
display: grid;
grid-template-columns: repeat(2, 56px);
grid-auto-rows: 56px;
gap: 12px;
}
.cs-cell {
display: grid;
place-items: center;
}
.cs-cube {
width: 48px;
height: 48px;
border-radius: 12px;
background: #b9a0ff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.is-loading .cs-cell,
.is-demo .cs-cell {
animation: cs-settle 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.is-loading .cs-cube,
.is-demo .cs-cube {
animation: cs-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite backwards;
}
.is-loading .cs-cell:nth-child(1) .cs-cube,
.is-demo .cs-cell:nth-child(1) .cs-cube {
animation-delay: 0s;
}
.is-loading .cs-cell:nth-child(2) .cs-cube,
.is-demo .cs-cell:nth-child(2) .cs-cube {
animation-delay: 120ms;
}
.is-loading .cs-cell:nth-child(3) .cs-cube,
.is-demo .cs-cell:nth-child(3) .cs-cube {
animation-delay: 240ms;
}
.is-loading .cs-cell:nth-child(4) .cs-cube,
.is-demo .cs-cell:nth-child(4) .cs-cube {
animation-delay: 360ms;
}
@keyframes cs-pop {
0% {
transform: scale(0);
opacity: 0;
}
16% {
transform: scale(1.18);
opacity: 1;
}
22%, 68% {
transform: scale(1);
opacity: 1;
}
90%, 100% {
transform: scale(0);
opacity: 0;
}
}
@keyframes cs-settle {
0%, 60% {
transform: translateY(0);
}
70% {
transform: translateY(8px);
}
80%, 100% {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.is-loading .cs-cell,
.is-loading .cs-cube,
.is-demo .cs-cell,
.is-demo .cs-cube {
animation: none !important;
transform: none !important;
opacity: 1 !important;
}
}import { CSSProperties } from "react";
import styles from "./CubeStack.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface CubeStackProps {
duration?: string;
easing?: string;
color?: string;
}
export default function CubeStack({
duration = "2s",
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#b9a0ff",
}: CubeStackProps) {
const style: Vars = {
"--duration": duration,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.grid} style={style} aria-hidden="true">
{[0, 1, 2, 3].map((i) => (
<span key={i} className={`${styles.cell} ${styles["d" + i]}`}>
<span className={styles.cube} />
</span>
))}
</div>
);
}08 · Cube stack — Measured area 13.051% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
It maps naturally onto a screen that loads one module at a time.
.cs-cell { animation: cs-settle $duration $ease-pop infinite; }
.cs-cube { animation: cs-pop $duration $ease-pop infinite backwards; }
.cs-cell:nth-child(2) .cs-cube { animation-delay: 120ms; }
.cs-cell:nth-child(3) .cs-cube { animation-delay: 240ms; }
.cs-cell:nth-child(4) .cs-cube { animation-delay: 360ms; }
@keyframes cs-pop {
0% { transform: scale(0); opacity: 0; }
16% { transform: scale(1.18); opacity: 1; }
22%, 68% { transform: scale(1); opacity: 1; }
90%, 100% { transform: scale(0); opacity: 0; }
}
@keyframes cs-settle {
0%, 60% { transform: translateY(0); }
70% { transform: translateY(8px); }
80%, 100% { transform: translateY(0); }
}
09Curtain reveal
What moves
This one stages the moment loading ends. Two curtain panels push outward to uncover the content placeholder behind them (title bar, text lines, thumbnail), stay open for a beat, then draw closed and repeat.

<main class="stage">
<div class="ct-scene is-demo">
<div class="ct-content" aria-hidden="true">
<span class="ct-bar ct-bar-title"></span>
<span class="ct-bar"></span>
<span class="ct-bar"></span>
<span class="ct-thumb"></span>
</div>
<div class="ct-panel ct-left" aria-hidden="true"><span class="ct-label">Loading</span></div>
<div class="ct-panel ct-right" aria-hidden="true"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 2s;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// 실사용: 로딩 중에만 .is-loading — 끝나면 클래스를 떼고 커튼을 연 채로 둔다
.ct-scene {
position: relative;
width: 252px;
height: 168px;
border-radius: $r-card;
overflow: hidden;
}
.ct-content {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: 1fr 64px;
grid-template-rows: auto auto auto 1fr;
gap: $sp-2 $sp-3;
padding: $sp-4;
}
.ct-bar {
height: 14px;
border-radius: $r-pill;
background: rgba(255, 247, 230, .4);
}
.ct-bar-title {
height: 20px;
grid-column: 1 / -1;
background: rgba(255, 247, 230, .75);
}
.ct-thumb {
grid-row: 2 / 4;
grid-column: 2;
border-radius: $r-sm;
background: rgba(255, 247, 230, .3);
}
.ct-panel {
position: absolute;
top: 0;
bottom: 0;
width: 50.5%; // 살짝 겹쳐 틈이 안 보이게
}
.ct-left { left: 0; background: $color; }
.ct-right { right: 0; background: $subject-pink; }
.ct-label {
position: absolute;
top: 50%;
left: 100%;
transform: translate(-50%, -50%);
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .55);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 13px;
line-height: 1.5;
color: $subject-cream;
white-space: nowrap;
}
.is-loading .ct-left,
.is-demo .ct-left { animation: ct-open-left $duration $easing infinite; }
.is-loading .ct-right,
.is-demo .ct-right { animation: ct-open-right $duration $easing infinite; }
@keyframes ct-open-left {
0%, 12% { transform: translateX(0); }
38%, 72% { transform: translateX(-101%); }
94%, 100% { transform: translateX(0); }
}
@keyframes ct-open-right {
0%, 12% { transform: translateX(0); }
38%, 72% { transform: translateX(101%); }
94%, 100% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.is-loading .ct-left,
.is-demo .ct-left {
animation: none !important;
transform: translateX(-101%) !important; // 열린 채로 멈춘다
}
.is-loading .ct-right,
.is-demo .ct-right {
animation: none !important;
transform: translateX(101%) !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);
}
.ct-scene {
position: relative;
width: 252px;
height: 168px;
border-radius: 16px;
overflow: hidden;
}
.ct-content {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: 1fr 64px;
grid-template-rows: auto auto auto 1fr;
gap: 8px 12px;
padding: 16px;
}
.ct-bar {
height: 14px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.4);
}
.ct-bar-title {
height: 20px;
grid-column: 1/-1;
background: rgba(255, 247, 230, 0.75);
}
.ct-thumb {
grid-row: 2/4;
grid-column: 2;
border-radius: 8px;
background: rgba(255, 247, 230, 0.3);
}
.ct-panel {
position: absolute;
top: 0;
bottom: 0;
width: 50.5%;
}
.ct-left {
left: 0;
background: #b9a0ff;
}
.ct-right {
right: 0;
background: #ff9ebb;
}
.ct-label {
position: absolute;
top: 50%;
left: 100%;
transform: translate(-50%, -50%);
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.55);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 13px;
line-height: 1.5;
color: #fff7e6;
white-space: nowrap;
}
.is-loading .ct-left,
.is-demo .ct-left {
animation: ct-open-left 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.is-loading .ct-right,
.is-demo .ct-right {
animation: ct-open-right 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes ct-open-left {
0%, 12% {
transform: translateX(0);
}
38%, 72% {
transform: translateX(-101%);
}
94%, 100% {
transform: translateX(0);
}
}
@keyframes ct-open-right {
0%, 12% {
transform: translateX(0);
}
38%, 72% {
transform: translateX(101%);
}
94%, 100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.is-loading .ct-left,
.is-demo .ct-left {
animation: none !important;
transform: translateX(-101%) !important;
}
.is-loading .ct-right,
.is-demo .ct-right {
animation: none !important;
transform: translateX(101%) !important;
}
}import { CSSProperties } from "react";
import styles from "./CurtainReveal.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface CurtainRevealProps {
duration?: string;
easing?: string;
color?: string;
/** 반대쪽 커튼 색 */
altColor?: string;
/** 커튼 위 알약 문구 */
label?: string;
}
export default function CurtainReveal({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
altColor = "#ff9ebb",
label = "Loading",
}: CurtainRevealProps) {
const style: Vars & { "--alt-color"?: string } = {
"--duration": duration,
"--easing": easing,
"--color": color,
"--alt-color": altColor,
};
return (
<div className={styles.scene} style={style}>
<div className={styles.content} aria-hidden="true">
<span className={`${styles.bar} ${styles.barTitle}`} />
<span className={styles.bar} />
<span className={styles.bar} />
<span className={styles.thumb} />
</div>
<div className={styles.left} aria-hidden="true">
<span className={styles.label}>{label}</span>
</div>
<div className={styles.right} aria-hidden="true" />
</div>
);
}09 · Curtain reveal — Measured area 29.278% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
In production you open the curtains when loading finishes and remove the class, and it doubles as a page-transition cover.
.ct-left { left: 0; background: $color; }
.ct-right { right: 0; background: $subject-pink; }
.ct-scene.is-demo .ct-left { animation: ct-open-left $duration $ease-spring infinite; }
.ct-scene.is-demo .ct-right { animation: ct-open-right $duration $ease-spring infinite; }
@keyframes ct-open-left {
0%, 12% { transform: translateX(0); }
38%, 72% { transform: translateX(-101%); }
94%, 100% { transform: translateX(0); }
}
@keyframes ct-open-right {
0%, 12% { transform: translateX(0); }
38%, 72% { transform: translateX(101%); }
94%, 100% { transform: translateX(0); }
}
Where it breaks — a rotating box overflows the phone screen
The first version of item 05 rotated a big square arm holding the dots, the way most orbit loaders are built. It looked fine on desktop, then the 320px phone-width check flagged a vertical overflow: the dots are tiny, but a 168px square rotated to 45 degrees has a bounding box the size of its diagonal (about 237px), and scroll-height math counts that inflated box. Measured directly, body reported 218px against a 200px viewport — and neither overflow: hidden nor clip stops that count. The fix is to shrink what rotates: each dot orbits in place with its own transform-origin pinned to the system center, so the bounding box stays inside the circle the dot traces (138px across). Compare item 01, whose ring is already a circle and therefore never grows when it spins. The archive asks for a password when you unpack it; The waiting that happens up in a menu bar instead is the mix 9 mega menu ui patterns deals with. Type 3eqz6dw4 and all nine folders open as they are. Item 02 got the same treatment for a different crime — the blur+contrast filter tinted every dot edge red on yellow, and switching to an alpha-only SVG matrix cleaned it up completely.
Accessibility
All nine loaders fall back to animation: none under prefers-reduced-motion: reduce, and each leaves a meaningful static shape rather than vanishing: 03 keeps the blob silhouette, 07 shows a full ring at 100%, 09 stays with curtains open, 04 rests mid-air. Loaders are decoration, so everything except 07 is aria-hidden="true" for assistive tech; 07 instead carries role="status" with a "Loading" label so progress gets announced. Because these loop forever, they meet WCAG 2.2.2 pause, stop, hide only if you remove the loader from the screen when loading actually ends. The media query follows the OS motion setting; MDN documents the details.
FAQ
Can these loading screen design examples be CSS only?
Yes — all nine here ship with zero lines of JavaScript, and even the 07 count-up runs on @property inside plain CSS. The only script you add in production is one line that toggles the .is-loading class when waiting starts and ends. For the classics, see 9 CSS loading spinners and the skeleton screens in skeleton loading UI. The wait that lives inside one button is what item 09 of 9 css button design ideas shows.
How do I recolor these to match my brand?
Change the single $color variable at the top of each demo's SCSS. Item 01 also blends two helper pastels into its gradient ($subject-lilac, $subject-sky), so adjust those two alongside it. The React port takes the same values as props.
Should I show a percentage when I don't know the real progress?
If you don't know, don't fake it — a counter stuck at 90 makes the wait feel longer, not shorter. Use an open-ended indicator like 01, 02 or 03 when timing is unknown, and reserve 07 for cases with real progress such as uploads. When all you need is the ending moment, 09 is the right tool.









