9 CSS Loading Spinners: Copy-Paste, Single Element
A css loading spinner is a small shape — a ring, three dots, a bar — that loops in place to tell someone a page or action is still working before there is any
01 loading-spinner-ring-basic
02 loading-spinner-dual-ring
03 loading-spinner-dots-bounce
04 loading-spinner-bar-indeterminate
05 loading-spinner-conic-sweep
06 loading-spinner-dash-svg-circle
07 loading-spinner-property-progress-ring
08 loading-spinner-button-inline-spinner
09 loading-spinner-ai-pulse-orb
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Basic ring spinner
- 02 Dual counter-rotating ring
- 03 3-dot bounce
- 04 Indeterminate bar
- 05 Conic-gradient sweep
- 06 SVG stroke-dashoffset circle
- 07 @property percent ring
- 08 Inline button loading state
- 09 AI-app style pulse orb
The nine below run from the plainest shape to the most produced. 01 through 04 lean on nothing more than a border, a transform, or a background — the shapes most sites already reach for first. 05 through 07 pull in newer CSS: a conic-gradient, an SVG stroke, and an @property-registered custom value that can genuinely tween through a number. 08 and 09 are the idea-form entries, moving the spinner into a real button and into a soft AI-thinking orb. Watch the grid above cycle through all nine on its own — every css loading spinner animation below runs inside a fixed, non-scrollable iframe with nothing to click, so it just loops.
01Basic ring spinner
What it does
A single circular border spins in place with only its top edge colored differently, and that color difference is the entire animation — no absolute positioning, no pseudo-elements underneath it.

<main class="stage">
<div class="spinner" role="status" aria-label="Loading"></div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1s; // 한 바퀴 도는 시간
$easing: linear; // 등속
$color: $subject-blue; // 진한 구간 색
@include stage($stage-ink);
.spinner {
width: clamp(48px, 14vw, 72px);
height: clamp(48px, 14vw, 72px);
border-radius: 50%;
border: 6px solid rgba($subject-cream, .22);
border-top-color: $color; // 위쪽 한 변만 진하게
animation: spin $duration $easing infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; border-top-color: rgba($subject-cream, .22); }
}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);
}
.spinner {
width: clamp(48px, 14vw, 72px);
height: clamp(48px, 14vw, 72px);
border-radius: 50%;
border: 6px solid rgba(255, 247, 230, 0.22);
border-top-color: #2f6df6;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
border-top-color: rgba(255, 247, 230, 0.22);
}
}import type { CSSProperties } from "react";
import styles from "./RingSpinner.module.scss";
export type RingSpinnerProps = {
/** 스피너 지름 (기본 56px) */
size?: number;
/** 한 바퀴 도는 시간 (기본 1000ms) */
duration?: number;
/** 진한 구간 색 */
color?: string;
/** 접근성 라벨 */
label?: string;
};
type Vars = CSSProperties & { "--size"?: string; "--duration"?: string; "--color"?: string };
export default function RingSpinner({
size = 56,
duration = 1000,
color = "#2f6df6",
label = "Loading",
}: RingSpinnerProps) {
const vars: Vars = {
"--size": `${size}px`,
"--duration": `${duration}ms`,
"--color": color,
};
return <div className={styles.spinner} style={vars} role="status" aria-label={label} />;
}01 · Basic ring spinner — Measured area 1.248% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
Reach for it as the default full-page loading state or the waiting screen right after a button click, anywhere a spinner just needs to say the page is still working.
.spinner {
width: clamp(48px, 14vw, 72px);
height: clamp(48px, 14vw, 72px);
border-radius: 50%;
border: 6px solid rgba($subject-cream, .22);
border-top-color: $color;
animation: spin $duration $easing infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
02Dual counter-rotating ring
What you see
An outer ring and an inner ring spin in opposite directions at two different speeds, so the shape reads as layered rather than flat even though every piece is still a plain circular border.

<main class="stage">
<div class="spinner" role="status" aria-label="Loading">
<span class="spinner__ring spinner__ring--outer" aria-hidden="true"></span>
<span class="spinner__ring spinner__ring--inner" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration-outer: 1.2s; // 바깥 링 한 바퀴
$duration-inner: .9s; // 안쪽 링 한 바퀴 (역방향)
$color: $subject-ink; // 바깥 링 색
@include stage($stage-yellow);
.spinner {
position: relative;
width: clamp(56px, 16vw, 84px);
height: clamp(56px, 16vw, 84px);
}
.spinner__ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 5px solid transparent;
}
.spinner__ring--outer {
border-top-color: $color;
border-bottom-color: $color;
animation: spin $duration-outer linear infinite;
}
.spinner__ring--inner {
inset: 13px;
border-left-color: $subject-blue;
border-right-color: $subject-blue;
animation: spin-rev $duration-inner linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes spin-rev {
to { transform: rotate(-360deg); }
}
@media (prefers-reduced-motion: reduce) {
.spinner__ring--outer, .spinner__ring--inner { animation: 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);
}
.spinner {
position: relative;
width: clamp(56px, 16vw, 84px);
height: clamp(56px, 16vw, 84px);
}
.spinner__ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 5px solid transparent;
}
.spinner__ring--outer {
border-top-color: #17141a;
border-bottom-color: #17141a;
animation: spin 1.2s linear infinite;
}
.spinner__ring--inner {
inset: 13px;
border-left-color: #2f6df6;
border-right-color: #2f6df6;
animation: spin-rev 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes spin-rev {
to {
transform: rotate(-360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.spinner__ring--outer, .spinner__ring--inner {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./DualRingSpinner.module.scss";
export type DualRingSpinnerProps = {
/** 스피너 지름 (기본 68px) */
size?: number;
/** 바깥 링 한 바퀴 시간 (기본 1200ms) */
durationOuter?: number;
/** 안쪽 링 한 바퀴 시간, 역방향 (기본 900ms) */
durationInner?: number;
/** 바깥 링 색 */
color?: string;
/** 안쪽 링 색 */
accent?: string;
label?: string;
};
type Vars = CSSProperties & {
"--size"?: string;
"--duration-outer"?: string;
"--duration-inner"?: string;
"--color"?: string;
"--accent"?: string;
};
export default function DualRingSpinner({
size = 68,
durationOuter = 1200,
durationInner = 900,
color = "#17141a",
accent = "#2f6df6",
label = "Loading",
}: DualRingSpinnerProps) {
const vars: Vars = {
"--size": `${size}px`,
"--duration-outer": `${durationOuter}ms`,
"--duration-inner": `${durationInner}ms`,
"--color": color,
"--accent": accent,
};
return (
<div className={styles.spinner} style={vars} role="status" aria-label={label}>
<span className={styles.outer} aria-hidden="true" />
<span className={styles.inner} aria-hidden="true" />
</div>
);
}02 · Dual counter-rotating ring — Measured area 1.833% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
It suits modal loading states and dashboard cards pulling in fresh data, where one flat ring feels too thin against a card that already has visual depth.
.spinner__ring--outer {
border-top-color: $color;
border-bottom-color: $color;
animation: spin $duration-outer linear infinite;
}
.spinner__ring--inner {
inset: 13px;
border-left-color: $subject-blue;
border-right-color: $subject-blue;
animation: spin-rev $duration-inner linear infinite;
}
@keyframes spin-rev {
to { transform: rotate(-360deg); }
}
033-dot bounce
The motion
Three dots bounce upward one after another on a fixed delay, so the motion reads as a small wave passing through the row rather than three dots blinking at random.

<main class="stage">
<div class="dots" role="status" aria-label="Typing">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: .9s; // 점 하나의 바운스 주기
$delay: .16s; // 점 사이 시차
$color: $subject-cream; // 점 색
@include stage($stage-orange);
.dots {
display: flex;
gap: clamp(8px, 3vw, 14px);
}
.dots span {
width: clamp(12px, 4vw, 18px);
height: clamp(12px, 4vw, 18px);
border-radius: 50%;
background: $color;
animation: bounce $duration ease-in-out infinite;
}
.dots span:nth-child(2) { animation-delay: $delay; }
.dots span:nth-child(3) { animation-delay: $delay * 2; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); opacity: .5; }
40% { transform: translateY(-14px); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.dots span { animation: none; opacity: .85; }
}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);
}
.dots {
display: flex;
gap: clamp(8px, 3vw, 14px);
}
.dots span {
width: clamp(12px, 4vw, 18px);
height: clamp(12px, 4vw, 18px);
border-radius: 50%;
background: #fff7e6;
animation: bounce 0.9s ease-in-out infinite;
}
.dots span:nth-child(2) {
animation-delay: 0.16s;
}
.dots span:nth-child(3) {
animation-delay: 0.32s;
}
@keyframes bounce {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.5;
}
40% {
transform: translateY(-14px);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.dots span {
animation: none;
opacity: 0.85;
}
}import type { CSSProperties } from "react";
import styles from "./DotsBounce.module.scss";
export type DotsBounceProps = {
/** 점 하나의 바운스 주기 (기본 900ms) */
duration?: number;
/** 점 사이 시차 (기본 160ms) */
delay?: number;
/** 점 색 */
color?: string;
label?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--delay"?: string; "--color"?: string };
export default function DotsBounce({
duration = 900,
delay = 160,
color = "#fff7e6",
label = "Typing",
}: DotsBounceProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--delay": `${delay}ms`,
"--color": color,
};
return (
<div className={styles.dots} style={vars} role="status" aria-label={label}>
<span aria-hidden="true" />
<span aria-hidden="true" />
<span aria-hidden="true" />
</div>
);
}03 · 3-dot bounce — Measured area 1.1% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It is the shape people already read as a chat window waiting on a reply, and it fits a search box's autocomplete wait just as naturally.
.dots span {
width: clamp(12px, 4vw, 18px);
height: clamp(12px, 4vw, 18px);
border-radius: 50%;
background: $color;
animation: bounce $duration ease-in-out infinite;
}
.dots span:nth-child(2) { animation-delay: $delay; }
.dots span:nth-child(3) { animation-delay: $delay * 2; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); opacity: .5; }
40% { transform: translateY(-14px); opacity: 1; }
}
04Indeterminate bar
What it does
A short bar sweeps from one side of a thin track to the other, scaling itself down at both ends of the sweep so it never looks like it slams flat into the track's edge.

<main class="stage">
<div class="bar" role="progressbar" aria-label="Processing">
<span class="bar__fill" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1.5s; // 막대가 한 번 오가는 시간
$easing: ease-in-out;
$color: $subject-blue; // 막대 색
@include stage($stage-paper);
.bar {
position: relative;
width: clamp(180px, 60vw, 280px);
height: 6px;
border-radius: 999px;
background: rgba($subject-ink, .12);
overflow: hidden;
}
.bar__fill {
position: absolute;
inset: 0;
border-radius: inherit;
background: $color;
transform-origin: left center; // width 대신 transform 으로만 움직인다
animation: sweep $duration $easing infinite;
}
@keyframes sweep {
0% { transform: translateX(-100%) scaleX(.3); }
50% { transform: translateX(20%) scaleX(.6); }
100% { transform: translateX(100%) scaleX(.3); }
}
@media (prefers-reduced-motion: reduce) {
.bar__fill { animation: none; transform: translateX(0) scaleX(.55); opacity: .7; }
}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);
}
.bar {
position: relative;
width: clamp(180px, 60vw, 280px);
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
overflow: hidden;
}
.bar__fill {
position: absolute;
inset: 0;
border-radius: inherit;
background: #2f6df6;
transform-origin: left center;
animation: sweep 1.5s ease-in-out infinite;
}
@keyframes sweep {
0% {
transform: translateX(-100%) scaleX(0.3);
}
50% {
transform: translateX(20%) scaleX(0.6);
}
100% {
transform: translateX(100%) scaleX(0.3);
}
}
@media (prefers-reduced-motion: reduce) {
.bar__fill {
animation: none;
transform: translateX(0) scaleX(0.55);
opacity: 0.7;
}
}import type { CSSProperties } from "react";
import styles from "./IndeterminateBar.module.scss";
export type IndeterminateBarProps = {
/** 막대가 한 번 오가는 시간 (기본 1500ms) */
duration?: number;
/** 막대 색 */
color?: string;
/** 트랙 너비 */
width?: number;
label?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--color"?: string; "--width"?: string };
export default function IndeterminateBar({
duration = 1500,
color = "#2f6df6",
width = 220,
label = "Processing",
}: IndeterminateBarProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--color": color,
"--width": `${width}px`,
};
return (
<div className={styles.bar} style={vars} role="progressbar" aria-label={label}>
<span className={styles.fill} aria-hidden="true" />
</div>
);
}04 · Indeterminate bar — Measured area 1.164% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
Use it before a real percentage exists yet — the moment right before a file upload starts, or a slim progress line pinned across the top of a page.
.bar__fill {
position: absolute;
inset: 0;
border-radius: inherit;
background: $color;
transform-origin: left center;
animation: sweep $duration $easing infinite;
}
@keyframes sweep {
0% { transform: translateX(-100%) scaleX(.3); }
50% { transform: translateX(20%) scaleX(.6); }
100% { transform: translateX(100%) scaleX(.3); }
}
05Conic-gradient sweep
What you see
A single conic-gradient spins on its own, with a radial-gradient mask cut into the middle so the filled circle reads as a thin ring without any separate border or pseudo-element drawing it.

<main class="stage">
<div class="spinner" role="status" aria-label="Loading"></div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1.1s; // 한 바퀴 도는 시간
$easing: linear;
$color: $subject-blue; // 그라디언트 진한 구간
@include stage($stage-ink);
.spinner {
width: clamp(56px, 16vw, 84px);
height: clamp(56px, 16vw, 84px);
border-radius: 50%;
background: conic-gradient($color 0deg 270deg, rgba($subject-cream, .18) 270deg 360deg);
// 가운데를 뚫어 링으로 보이게 한다 — 그라디언트 자체는 그대로 두고 통째로 돌린다
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 8px), #000 calc(100% - 8px));
mask: radial-gradient(farthest-side, transparent calc(100% - 8px), #000 calc(100% - 8px));
animation: spin $duration $easing infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; }
}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);
}
.spinner {
width: clamp(56px, 16vw, 84px);
height: clamp(56px, 16vw, 84px);
border-radius: 50%;
background: conic-gradient(#2f6df6 0deg 270deg, rgba(255, 247, 230, 0.18) 270deg 360deg);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 8px), #000 calc(100% - 8px));
mask: radial-gradient(farthest-side, transparent calc(100% - 8px), #000 calc(100% - 8px));
animation: spin 1.1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./ConicSweepSpinner.module.scss";
export type ConicSweepSpinnerProps = {
/** 스피너 지름 (기본 68px) */
size?: number;
/** 한 바퀴 도는 시간 (기본 1100ms) */
duration?: number;
/** 그라디언트 진한 구간 색 */
color?: string;
label?: string;
};
type Vars = CSSProperties & { "--size"?: string; "--duration"?: string; "--color"?: string };
export default function ConicSweepSpinner({
size = 68,
duration = 1100,
color = "#2f6df6",
label = "Loading",
}: ConicSweepSpinnerProps) {
const vars: Vars = {
"--size": `${size}px`,
"--duration": `${duration}ms`,
"--color": color,
};
return <div className={styles.spinner} style={vars} role="status" aria-label={label} />;
}05 · Conic-gradient sweep — Measured area 1.473% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
Conic gradients now render across every modern browser, which makes this the shape to reach for on a loading screen that has to carry an exact brand color, like a checkout wait.
.spinner {
width: clamp(56px, 16vw, 84px);
height: clamp(56px, 16vw, 84px);
border-radius: 50%;
background: conic-gradient($color 0deg 270deg, rgba($subject-cream, .18) 270deg 360deg);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 8px), #000 calc(100% - 8px));
mask: radial-gradient(farthest-side, transparent calc(100% - 8px), #000 calc(100% - 8px));
animation: spin $duration $easing infinite;
}
06SVG stroke-dashoffset circle
The motion
An SVG circle animates only its stroke-dashoffset, so the colored arc appears to chase itself around the track while the whole element also spins slowly underneath it.

<main class="stage">
<svg class="spinner" viewBox="0 0 44 44" width="56" height="56" role="status" aria-label="Loading">
<circle class="spinner__track" cx="22" cy="22" r="18" fill="none" stroke-width="4"></circle>
<circle class="spinner__arc" cx="22" cy="22" r="18" fill="none" stroke-width="4"></circle>
</svg>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration-spin: 1.4s; // svg 전체가 도는 시간
$duration-dash: 1.4s; // 궤적이 늘었다 줄었다 하는 시간
$color: $subject-ink; // 궤적 색
@include stage($stage-yellow);
.spinner {
animation: spin $duration-spin linear infinite;
transform-origin: center;
}
.spinner__track {
stroke: rgba($subject-ink, .15);
}
.spinner__arc {
stroke: $color;
stroke-linecap: round;
stroke-dasharray: 113; // 2 * π * 18 ≈ 113
stroke-dashoffset: 90;
animation: dash $duration-dash ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes dash {
0% { stroke-dashoffset: 100; }
50% { stroke-dashoffset: 20; }
100% { stroke-dashoffset: 100; }
}
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; }
.spinner__arc { animation: none; stroke-dashoffset: 60; }
}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);
}
.spinner {
animation: spin 1.4s linear infinite;
transform-origin: center;
}
.spinner__track {
stroke: rgba(23, 20, 26, 0.15);
}
.spinner__arc {
stroke: #17141a;
stroke-linecap: round;
stroke-dasharray: 113;
stroke-dashoffset: 90;
animation: dash 1.4s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes dash {
0% {
stroke-dashoffset: 100;
}
50% {
stroke-dashoffset: 20;
}
100% {
stroke-dashoffset: 100;
}
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
}
.spinner__arc {
animation: none;
stroke-dashoffset: 60;
}
}import type { CSSProperties } from "react";
import styles from "./DashCircleSpinner.module.scss";
export type DashCircleSpinnerProps = {
/** svg 전체가 도는 시간 (기본 1400ms) */
durationSpin?: number;
/** 궤적이 늘었다 줄었다 하는 시간 (기본 1400ms) */
durationDash?: number;
/** 궤적 색 */
color?: string;
label?: string;
};
type Vars = CSSProperties & { "--duration-spin"?: string; "--duration-dash"?: string; "--color"?: string };
export default function DashCircleSpinner({
durationSpin = 1400,
durationDash = 1400,
color = "#17141a",
label = "Loading",
}: DashCircleSpinnerProps) {
const vars: Vars = {
"--duration-spin": `${durationSpin}ms`,
"--duration-dash": `${durationDash}ms`,
"--color": color,
};
return (
<svg
className={styles.spinner}
style={vars}
viewBox="0 0 44 44"
width="56"
height="56"
role="status"
aria-label={label}
>
<circle className={styles.track} cx="22" cy="22" r="18" fill="none" strokeWidth="4" />
<circle className={styles.arc} cx="22" cy="22" r="18" fill="none" strokeWidth="4" />
</svg>
);
}06 · SVG stroke-dashoffset circle — Measured area 0.706% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It suits a wait icon that has no real percentage behind it yet, such as an app's splash screen before the first network call resolves.
.spinner__arc {
stroke: $color;
stroke-linecap: round;
stroke-dasharray: 113;
stroke-dashoffset: 90;
animation: dash $duration-dash ease-in-out infinite;
}
@keyframes dash {
0% { stroke-dashoffset: 100; }
50% { stroke-dashoffset: 20; }
100% { stroke-dashoffset: 100; }
}
07@property percent ring
What it does
A custom property named --percent is registered with @property as a plain number, so animating it from 0 to 100 tweens smoothly through every step instead of snapping straight from empty to full.

<main class="stage">
<div class="ring" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-label="Progress"></div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1.8s; // 0 → 100 → 0 한 사이클
$easing: ease-in-out;
$color: $subject-cream; // 채워지는 색
@include stage($stage-orange);
@property --percent {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
.ring {
--percent: 0;
position: relative;
width: clamp(64px, 18vw, 96px);
height: clamp(64px, 18vw, 96px);
border-radius: 50%;
background: conic-gradient($color calc(var(--percent) * 1%), rgba($subject-ink, .18) 0);
animation: fill $duration $easing infinite;
}
.ring::before {
content: "";
position: absolute;
inset: 8px;
border-radius: 50%;
background: $stage-orange; // 안쪽을 뚫어 링으로 보이게
}
@keyframes fill {
0% { --percent: 0; }
50% { --percent: 100; }
100% { --percent: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ring { animation: none; --percent: 62; }
}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 --percent {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
.ring {
--percent: 0;
position: relative;
width: clamp(64px, 18vw, 96px);
height: clamp(64px, 18vw, 96px);
border-radius: 50%;
background: conic-gradient(#fff7e6 calc(var(--percent) * 1%), rgba(23, 20, 26, 0.18) 0);
animation: fill 1.8s ease-in-out infinite;
}
.ring::before {
content: "";
position: absolute;
inset: 8px;
border-radius: 50%;
background: #ff4d1f;
}
@keyframes fill {
0% {
--percent: 0;
}
50% {
--percent: 100;
}
100% {
--percent: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ring {
animation: none;
--percent: 62;
}
}import type { CSSProperties } from "react";
import styles from "./PropertyProgressRing.module.scss";
export type PropertyProgressRingProps = {
/** 스피너 지름 (기본 80px) */
size?: number;
/** 0 → 100 → 0 한 사이클 시간 (기본 1800ms) */
duration?: number;
/** 채워지는 색 */
color?: string;
label?: string;
};
type Vars = CSSProperties & { "--size"?: string; "--duration"?: string; "--color"?: string };
export default function PropertyProgressRing({
size = 80,
duration = 1800,
color = "#fff7e6",
label = "Progress",
}: PropertyProgressRingProps) {
const vars: Vars = {
"--size": `${size}px`,
"--duration": `${duration}ms`,
"--color": color,
};
return (
<div
className={styles.ring}
style={vars}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
/>
);
}07 · @property percent ring — Measured area 1.508% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
Support for @property only reached Chrome 85, Safari 16.4, and Firefox 128, so ship a static fallback ring for anything older, and reach for this version wherever the number behind the ring is real, like upload progress or an onboarding completion meter.
@property --percent {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
.ring {
--percent: 0;
background: conic-gradient($color calc(var(--percent) * 1%), rgba($subject-ink, .18) 0);
animation: fill $duration $easing infinite;
}
@keyframes fill {
0% { --percent: 0; }
50% { --percent: 100; }
100% { --percent: 0; }
}
08Inline button loading state
What you see
Clicking the button does not swap it for a new element — the label fades and slides down while a small ring fades in on the same grid cell, and the button's own width stays fixed the whole time so nothing shifts around it.

<main class="stage">
<button class="btn is-demo" type="button">
<span class="btn__label">Save</span>
<span class="btn__spin" aria-hidden="true"></span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 글자 ↔ 스피너 교체
$spin: .8s; // 스피너 한 바퀴
$color: $subject-blue; // 버튼 색
@include stage($stage-paper);
.btn {
position: relative;
display: grid;
place-items: center;
min-width: clamp(150px, 40vw, 200px); // 폭을 고정해 글자가 사라져도 안 줄어든다
border: 0;
border-radius: $r-control;
padding: clamp(14px, 4vw, 22px) clamp(24px, 6vw, 40px);
font-weight: 700;
font-size: clamp(15px, 4vw, 20px);
line-height: 1;
font-family: inherit;
color: $subject-cream;
background: $color;
cursor: pointer;
&__label, &__spin { grid-area: 1 / 1; transition: opacity $duration, transform $duration; }
&__spin {
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid rgba($subject-cream, .35);
border-top-color: $subject-cream;
opacity: 0;
transform: scale(.6);
animation: spin $spin linear infinite;
}
&.is-loading &__label { opacity: 0; transform: translateY(6px); }
&.is-loading &__spin { opacity: 1; transform: scale(1); }
// 데모: 2초 주기로 스스로 눌린 것처럼 반복한다
&.is-demo &__label { animation: label-loop $dur-loop ease infinite; }
&.is-demo &__spin { animation: spin $spin linear infinite, spin-loop $dur-loop ease infinite; }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes label-loop {
0%, 30% { opacity: 1; transform: none; }
45%, 80% { opacity: 0; transform: translateY(6px); }
95%, 100% { opacity: 1; transform: none; }
}
@keyframes spin-loop {
0%, 30% { opacity: 0; }
45%, 80% { opacity: 1; }
95%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.btn__label, .btn__spin { transition: none; animation: none !important; }
.btn.is-loading .btn__spin { opacity: 1; transform: none; border-top-color: rgba($subject-cream, .35); }
}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);
}
.btn {
position: relative;
display: grid;
place-items: center;
min-width: clamp(150px, 40vw, 200px);
border: 0;
border-radius: 12px;
padding: clamp(14px, 4vw, 22px) clamp(24px, 6vw, 40px);
font-weight: 700;
font-size: clamp(15px, 4vw, 20px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: #2f6df6;
cursor: pointer;
}
.btn__label, .btn__spin {
grid-area: 1/1;
transition: opacity 220ms, transform 220ms;
}
.btn__spin {
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid rgba(255, 247, 230, 0.35);
border-top-color: #fff7e6;
opacity: 0;
transform: scale(0.6);
animation: spin 0.8s linear infinite;
}
.btn.is-loading .btn__label {
opacity: 0;
transform: translateY(6px);
}
.btn.is-loading .btn__spin {
opacity: 1;
transform: scale(1);
}
.btn.is-demo .btn__label {
animation: label-loop 2s ease infinite;
}
.btn.is-demo .btn__spin {
animation: spin 0.8s linear infinite, spin-loop 2s ease infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes label-loop {
0%, 30% {
opacity: 1;
transform: none;
}
45%, 80% {
opacity: 0;
transform: translateY(6px);
}
95%, 100% {
opacity: 1;
transform: none;
}
}
@keyframes spin-loop {
0%, 30% {
opacity: 0;
}
45%, 80% {
opacity: 1;
}
95%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.btn__label, .btn__spin {
transition: none;
animation: none !important;
}
.btn.is-loading .btn__spin {
opacity: 1;
transform: none;
border-top-color: rgba(255, 247, 230, 0.35);
}
}// 실제로는 fetch 시작 시 is-loading 을 붙이고 응답이 오면 뗀다. 데모는 2초 주기로 스스로 반복한다.
var b = document.querySelector('.btn');
b.addEventListener('pointerdown', function () {
b.classList.remove('is-demo');
b.classList.add('is-loading');
b.disabled = true;
setTimeout(function () {
b.classList.remove('is-loading');
b.disabled = false;
}, 900);
});import { useCallback, useState } from "react";
import type { CSSProperties, MouseEvent, ReactNode } from "react";
import styles from "./InlineSpinnerButton.module.scss";
export type InlineSpinnerButtonProps = {
children: ReactNode;
/** 글자 ↔ 스피너 교체 시간 (기본 220ms) */
duration?: number;
/** 스피너 한 바퀴 시간 (기본 800ms) */
spin?: number;
/** 버튼 색 */
color?: string;
/** 로딩을 유지할 시간 (기본 900ms, 실제로는 fetch 응답 시점) */
loadingMs?: number;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
type?: "button" | "submit";
};
type Vars = CSSProperties & { "--duration"?: string; "--spin"?: string; "--color"?: string };
export default function InlineSpinnerButton({
children,
duration = 220,
spin = 800,
color = "#2f6df6",
loadingMs = 900,
onClick,
type = "button",
}: InlineSpinnerButtonProps) {
const [loading, setLoading] = useState(false);
const handle = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
setLoading(true);
onClick?.(e);
window.setTimeout(() => setLoading(false), loadingMs);
},
[onClick, loadingMs]
);
const vars: Vars = {
"--duration": `${duration}ms`,
"--spin": `${spin}ms`,
"--color": color,
};
return (
<button
type={type}
className={styles.btn}
style={vars}
disabled={loading}
onClick={handle}
>
<span className={styles.label} data-hidden={loading}>{children}</span>
<span className={styles.spinner} data-visible={loading} aria-hidden="true" />
</button>
);
}08 · Inline button loading state — Measured area 0.899% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
Wire this into save buttons and checkout buttons, anywhere someone needs to see their own click acknowledged without the page jumping under the cursor.
.btn {
&__label, &__spin { grid-area: 1 / 1; transition: opacity $duration, transform $duration; }
&__spin {
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid rgba($subject-cream, .35);
border-top-color: $subject-cream;
opacity: 0;
transform: scale(.6);
animation: spin $spin linear infinite;
}
&.is-loading &__label { opacity: 0; transform: translateY(6px); }
&.is-loading &__spin { opacity: 1; transform: scale(1); }
}
09AI-app style pulse orb
The motion
A blurred glow sits behind a soft gradient core, and both grow and shrink on a slow two-second pulse, timed just out of phase so the whole shape reads as breathing rather than mechanically ticking.

<main class="stage">
<div class="orb" role="status" aria-label="Preparing a response"></div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 펄스 한 사이클 (2초)
$easing: ease-in-out;
$color: $subject-blue; // 구체 안쪽 색
@include stage($stage-ink);
.orb {
position: relative;
width: clamp(56px, 16vw, 84px);
height: clamp(56px, 16vw, 84px);
}
.orb::before {
content: "";
position: absolute;
inset: -30%;
border-radius: 50%;
background: radial-gradient(circle, rgba($color, .55), transparent 70%);
filter: blur(6px);
animation: glow $duration $easing infinite;
}
.orb::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, $subject-cream, $color 75%);
animation: core $duration $easing infinite;
}
@keyframes core {
0%, 100% { transform: scale(.88); opacity: .88; }
50% { transform: scale(1.08); opacity: 1; }
}
@keyframes glow {
0%, 100% { transform: scale(.8); opacity: .35; }
50% { transform: scale(1.25); opacity: .65; }
}
@media (prefers-reduced-motion: reduce) {
.orb::before, .orb::after { animation: none; transform: scale(1); opacity: .75; }
}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);
}
.orb {
position: relative;
width: clamp(56px, 16vw, 84px);
height: clamp(56px, 16vw, 84px);
}
.orb::before {
content: "";
position: absolute;
inset: -30%;
border-radius: 50%;
background: radial-gradient(circle, rgba(47, 109, 246, 0.55), transparent 70%);
filter: blur(6px);
animation: glow 2s ease-in-out infinite;
}
.orb::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #fff7e6, #2f6df6 75%);
animation: core 2s ease-in-out infinite;
}
@keyframes core {
0%, 100% {
transform: scale(0.88);
opacity: 0.88;
}
50% {
transform: scale(1.08);
opacity: 1;
}
}
@keyframes glow {
0%, 100% {
transform: scale(0.8);
opacity: 0.35;
}
50% {
transform: scale(1.25);
opacity: 0.65;
}
}
@media (prefers-reduced-motion: reduce) {
.orb::before, .orb::after {
animation: none;
transform: scale(1);
opacity: 0.75;
}
}import type { CSSProperties } from "react";
import styles from "./PulseOrb.module.scss";
export type PulseOrbProps = {
/** 오브 지름 (기본 68px) */
size?: number;
/** 펄스 한 사이클 시간 (기본 2000ms) */
duration?: number;
/** 구체 안쪽 색 */
color?: string;
label?: string;
};
type Vars = CSSProperties & { "--size"?: string; "--duration"?: string; "--color"?: string };
export default function PulseOrb({
size = 68,
duration = 2000,
color = "#2f6df6",
label = "Preparing a response",
}: PulseOrbProps) {
const vars: Vars = {
"--size": `${size}px`,
"--duration": `${duration}ms`,
"--color": color,
};
return <div className={styles.orb} style={vars} role="status" aria-label={label} />;
}09 · AI-app style pulse orb — Measured area 8.89% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
Reach for it on an AI chatbot's response wait or a voice assistant's listening indicator, anywhere a plain ring would look too literal for what is really a black box thinking.
.orb::before {
background: radial-gradient(circle, rgba($color, .55), transparent 70%);
filter: blur(6px);
animation: glow $duration $easing infinite;
}
.orb::after {
background: radial-gradient(circle at 35% 35%, $subject-cream, $color 75%);
animation: core $duration $easing infinite;
}
@keyframes core {
0%, 100% { transform: scale(.88); opacity: .88; }
50% { transform: scale(1.08); opacity: 1; }
}
Where it breaks — the traps
Two silent support failures
Two of these nine lean on browser support in a way the rest don't, and both fail silently rather than throwing an error. Skip the @property block above item 07 and the browser treats --percent as an opaque string instead of a registered number, so the ring stops tweening through the fill entirely and just jumps from empty to full partway through the loop instead of counting up. Item 05 has a quieter version of the same problem: drop the -webkit-mask line and keep only the unprefixed mask, and Safari older than 15.4 paints the full conic-gradient disc with no hole cut into the middle, so what should read as a ring shows up as a solid wedge of color instead (MDN's mask compatibility table lists 15.4 as the first unprefixed version). Both fixes already sit inside the demos above, and the whole set — every fallback included — is worth grabbing from the archive rather than retyping nine keyframe blocks by hand; unzip it with qda5pgmf typed exactly as it appears on this line, no spaces added. When the wait is long enough to sketch the shape of what's coming, the CSS skeleton loading screens take over, and when the number itself has to show, the circle gauge progress bars pick it up.
Accessibility
Reduced motion
Every one of the nine respects prefers-reduced-motion, but each drops a different amount of motion rather than all going fully still. 01 through 04, 06, and 09 stop spinning, bouncing, or pulsing outright and settle on a static dimmed shape, since a loading indicator that no longer moves still communicates "wait" through its color and position alone. 05 simply freezes its rotation in place. 07 holds --percent at a fixed 62 rather than looping through zero, so the ring shows a plausible snapshot instead of an empty or full circle that might read as done or broken. 08's button keeps its ring visible but static the instant a real click starts loading, so the disabled state still reads clearly without any spin at all. MDN documents prefers-reduced-motion and the operating-system settings that flip it on.
@media (prefers-reduced-motion: reduce) {
.spinner,
.spinner__ring--outer,
.spinner__ring--inner,
.dots span,
.bar__fill,
.spinner__arc,
.ring,
.btn__spin,
.orb::before,
.orb::after {
animation: none !important;
}
}
More css loading spinner animation variants and the rest of the CSS collection live under the CSS category hub, and what this site actually sells — and doesn't — is explained on the about page.
FAQ
Will these still work as a css loading spinner full screen overlay?
Yes — every shape here is a single element sized with clamp(), so wrapping it in a fixed, full-viewport container with a dim backdrop turns any of the nine into a full-screen overlay without changing the spinner's own markup or animation at all. Item 01's basic ring and item 05's conic sweep are the two that read cleanest at a larger size, since both are drawn from pure shape rather than several small parts.
Can I put a css loading spinner with text next to it?
Yes, and it is usually better for people using screen readers than the spinner alone. Add a sibling span with the wait message, keep the spinner itself marked aria-hidden the way each demo above already does, and use the parent's role="status" or aria-label attribute to carry the real label instead of hiding it inside decorative markup.
Do these need a build tool, a framework, or a css loading spinner generator?
No — every pattern compiles from a handful of SCSS variables into plain CSS, and nothing here depends on a specific framework or a generator to produce it. Paste the vanilla SCSS into any SCSS playground to preview one pattern on its own, and the zip's react/ folder wraps each of the nine in its own component for projects that would rather import than copy classes by hand. Once a spinner like 08's finishes and the news is good, the one-line announcement it hands off to is the CSS toast notifications; when the wait covers a whole screen instead of one button, the page loading animations carry that weight.









