【コピペOK】CSS スケルトン ローディング 9選
css スケルトンとは、本物のコンテンツが届く前に画面の形だけを灰色の板で先に見せておくローディング表現のことです。この記事ではシマーライン・カード・表・コンテナクエリまで9種類を選び、JavaScript を使わず SCSS だけで実装しました。
01 shimmer-lines
02 card-skeleton
04 table-skeleton
05 image-blur-up
06 responsive-skeleton
07 skeleton-to-content
08 wave-stagger-skeleton
09 auto-theme-skeleton
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 シマーライン
- 02 カードスケルトン
- 03 アバターリスト
- 04 テーブルスケルトン
- 05 画像ブラーアップ
- 06 レスポンシブスケルトン
- 07 スケルトン→本文
- 08 行ごとに時差
- 09 ダークモード自動配色
並び順は効果の派手さではなく、実際に画面を組み立てていく順番です。最初の4つ(01〜04)は行・カード・アバター・表という基本の形で、次の2つ(05〜06)は写真とレイアウト幅という「揺れやすい要素」への対処です。最後の3つ(07〜09)は、スケルトンが役目を終えて本物に切り替わる場面・長い一覧での見え方・配色の自動化という、仕上げの挙動を扱います。「css ローディングアニメーション」で探している人が知りたい細部を、項目ごとのコードに残しました。 待ち時間が短く、形をあらかじめ描く必要がない場面ではCSS ローディング スピナー9選のほうが簡単で、07のようにスケルトンが退いた後に画面そのものが入れ替わる瞬間はCSS 画面切り替え アニメーション9選が扱っています。
01シマーライン
何が起きるか
文字行の位置を灰色の棒が先取りし、::after の光の帯だけが translateX で通り過ぎます。

<main class="stage">
<div class="lines">
<div class="line"></div>
<div class="line"></div>
<div class="line"></div>
<div class="line"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-loop; // 빛이 줄 하나를 다 훑는 시간
$easing: $ease-out; // 시작은 빠르고 끝은 느리게
$color: $subject-cream; // 막대·빛의 바탕 색 (어두운 무대라 크림)
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.lines {
width: clamp(180px, 70%, 360px);
display: grid;
gap: clamp(10px, 3vw, 16px);
}
// ── 줄 막대 ──────────────────────────────────────────
.line {
position: relative;
overflow: hidden; // 빛이 막대 밖으로 안 나가게
height: clamp(12px, 4vw, 16px);
border-radius: $r-control;
background: rgba($color, .14);
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($color, .45), transparent);
transform: translateX(-120%); // 위치가 아니라 transform 으로만 이동
animation: shimmer $duration $easing infinite;
}
&:nth-child(1) { width: 92%; }
&:nth-child(2) { width: 100%; }
&:nth-child(3) { width: 78%; }
&:nth-child(4) { width: 55%; }
&:nth-child(2)::after { animation-delay: -140ms; } // 줄마다 살짝 어긋나게 시작 (음수 지연 = 이미 진행 중인 상태로 시작)
&:nth-child(3)::after { animation-delay: -280ms; }
&:nth-child(4)::after { animation-delay: -420ms; }
}
@keyframes shimmer {
to { transform: translateX(120%); }
}
// ── 접근성: 움직임 줄이기 설정이면 스침 대신 옅은 밝기만 ────────
@media (prefers-reduced-motion: reduce) {
.line::after { animation: none !important; opacity: .45; transform: translateX(0); }
}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);
}
.lines {
width: clamp(180px, 70%, 360px);
display: grid;
gap: clamp(10px, 3vw, 16px);
}
.line {
position: relative;
overflow: hidden;
height: clamp(12px, 4vw, 16px);
border-radius: 12px;
background: rgba(255, 247, 230, 0.14);
}
.line::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 247, 230, 0.45), transparent);
transform: translateX(-120%);
animation: shimmer 2s ease-out infinite;
}
.line:nth-child(1) {
width: 92%;
}
.line:nth-child(2) {
width: 100%;
}
.line:nth-child(3) {
width: 78%;
}
.line:nth-child(4) {
width: 55%;
}
.line:nth-child(2)::after {
animation-delay: -140ms;
}
.line:nth-child(3)::after {
animation-delay: -280ms;
}
.line:nth-child(4)::after {
animation-delay: -420ms;
}
@keyframes shimmer {
to {
transform: translateX(120%);
}
}
@media (prefers-reduced-motion: reduce) {
.line::after {
animation: none !important;
opacity: 0.45;
transform: translateX(0);
}
}import type { CSSProperties } from "react";
import styles from "./ShimmerLines.module.scss";
export type ShimmerLinesProps = {
/** 빛이 줄 하나를 훑는 시간 (기본 2000ms) */
duration?: number;
/** CSS easing (기본 ease-out) */
easing?: string;
/** 막대·빛의 바탕 색 */
color?: string;
/** 줄 수 (기본 4) */
lines?: number;
className?: string;
};
const WIDTHS = ["92%", "100%", "78%", "55%"];
export default function ShimmerLines({
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
lines = 4,
className = "",
}: ShimmerLinesProps) {
const vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={`${styles.lines} ${className}`} style={vars}>
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={styles.line}
style={{ width: WIDTHS[i % WIDTHS.length], animationDelay: `${-i * 140}ms` } as CSSProperties}
/>
))}
</div>
);
}01 · シマーライン — 計測 累積面積 11.078% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
css ローディング デザインの使いどころ
位置ではなく transform だけを動かすのでレイアウトは揺れず、行ごとに開始をわずかにずらすと1本の光が順番に流れて見えます。記事一覧やコメント欄の読み込みに向いています。
.line {
position: relative;
overflow: hidden;
height: 14px;
border-radius: 6px;
background: rgba($color, .14);
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($color, .45), transparent);
transform: translateX(-120%);
animation: shimmer $dur-loop $ease-out infinite;
}
&:nth-child(2)::after { animation-delay: -140ms; }
}
@keyframes shimmer {
to { transform: translateX(120%); }
}
02カードスケルトン
動きの内容
画像・見出し・説明の3つの枠を持つカード1枚です。

<main class="stage">
<div class="card">
<div class="thumb"></div>
<div class="title"></div>
<div class="desc"></div>
<div class="desc short"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 빛이 카드 한 장을 훑는 시간
$easing: $ease-out;
$color: $subject-ink; // 막대 색 (노란 무대라 잉크 톤)
@include stage($stage-yellow);
.card {
width: clamp(160px, 60%, 260px);
padding: clamp(10px, 2.6vw, 14px);
border-radius: $r-card;
background: rgba($stage-paper, .55);
box-shadow: $shadow-raised;
display: grid;
gap: clamp(8px, 2vw, 10px);
}
// ── 막대 하나의 공통 모양 ──────────────────────────────
%bar {
position: relative;
overflow: hidden;
border-radius: $r-control;
background: rgba($color, .16);
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($stage-paper, .85), transparent);
transform: translateX(-120%);
animation: shimmer $duration $easing infinite;
}
}
.thumb { @extend %bar; aspect-ratio: 2 / 1; }
.title { @extend %bar; height: clamp(12px, 3.6vw, 16px); width: 70%; }
.desc { @extend %bar; height: clamp(9px, 3vw, 12px); }
.desc.short { width: 55%; }
.title::after { animation-delay: -120ms; }
.desc::after { animation-delay: -240ms; }
.desc.short::after { animation-delay: -360ms; }
@keyframes shimmer {
to { transform: translateX(120%); }
}
@media (prefers-reduced-motion: reduce) {
.thumb::after, .title::after, .desc::after { animation: none !important; opacity: .5; transform: translateX(0); }
}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);
}
.card {
width: clamp(160px, 60%, 260px);
padding: clamp(10px, 2.6vw, 14px);
border-radius: 16px;
background: rgba(255, 255, 255, 0.55);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
gap: clamp(8px, 2vw, 10px);
}
.desc, .title, .thumb {
position: relative;
overflow: hidden;
border-radius: 12px;
background: rgba(23, 20, 26, 0.16);
}
.desc::after, .title::after, .thumb::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.85), transparent);
transform: translateX(-120%);
animation: shimmer 2s ease-out infinite;
}
.thumb {
aspect-ratio: 2/1;
}
.title {
height: clamp(12px, 3.6vw, 16px);
width: 70%;
}
.desc {
height: clamp(9px, 3vw, 12px);
}
.desc.short {
width: 55%;
}
.title::after {
animation-delay: -120ms;
}
.desc::after {
animation-delay: -240ms;
}
.desc.short::after {
animation-delay: -360ms;
}
@keyframes shimmer {
to {
transform: translateX(120%);
}
}
@media (prefers-reduced-motion: reduce) {
.thumb::after, .title::after, .desc::after {
animation: none !important;
opacity: 0.5;
transform: translateX(0);
}
}import type { CSSProperties } from "react";
import styles from "./CardSkeleton.module.scss";
export type CardSkeletonProps = {
/** 빛이 카드 한 장을 훑는 시간 (기본 2000ms) */
duration?: number;
/** CSS easing (기본 ease-out) */
easing?: string;
/** 막대 색 */
color?: string;
className?: string;
};
export default function CardSkeleton({
duration = 2000,
easing = "ease-out",
color = "#17141a",
className = "",
}: CardSkeletonProps) {
const vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={`${styles.card} ${className}`} style={vars}>
<div className={styles.thumb} />
<div className={styles.title} />
<div className={styles.desc} />
<div className={`${styles.desc} ${styles.short}`} />
</div>
);
}02 · カードスケルトン — 計測 累積面積 28.717% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使う場面
共通の光る帯を %bar というプレースホルダーセレクターにまとめ、.thumb・.title・.desc がそれぞれ @extend するだけで同じ動きを再利用できます。商品カードや記事カードの一覧に向いています。
%bar {
position: relative;
overflow: hidden;
border-radius: 6px;
background: rgba($color, .16);
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($stage-paper, .85), transparent);
transform: translateX(-120%);
animation: shimmer $dur-loop $ease-out infinite;
}
}
.thumb { @extend %bar; aspect-ratio: 2 / 1; }
.title { @extend %bar; height: 14px; width: 70%; }
.desc { @extend %bar; height: 11px; }
03アバターリスト
どんな動きか
丸いアバターと2行のテキストが5回繰り返されます。
<main class="stage">
<div class="list">
<div class="row"><span class="avatar"></span><span class="meta"><span class="l1"></span><span class="l2"></span></span></div>
<div class="row"><span class="avatar"></span><span class="meta"><span class="l1"></span><span class="l2"></span></span></div>
<div class="row"><span class="avatar"></span><span class="meta"><span class="l1"></span><span class="l2"></span></span></div>
<div class="row"><span class="avatar"></span><span class="meta"><span class="l1"></span><span class="l2"></span></span></div>
<div class="row"><span class="avatar"></span><span class="meta"><span class="l1"></span><span class="l2"></span></span></div>
<span class="sheen" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 빛이 목록 전체를 훑는 시간
$easing: $ease-out;
$color: $subject-cream; // 아바타·막대 색 (주황 무대라 크림)
@include stage($stage-orange);
.list {
position: relative; // 훑는 빛의 기준
overflow: hidden; // 빛이 목록 밖으로 안 나가게
width: clamp(210px, 76%, 320px);
display: grid;
gap: clamp(6px, 2.4vw, 12px);
border-radius: $r-card;
}
.row { display: flex; align-items: center; gap: clamp(8px, 2.6vw, 12px); }
.avatar {
flex: none;
width: clamp(20px, 6.4vw, 32px);
height: clamp(20px, 6.4vw, 32px);
border-radius: $r-pill; // 동그란 아바타
background: rgba($color, .22);
}
.meta { flex: 1; display: grid; gap: 5px; }
.l1 { height: clamp(8px, 2.6vw, 11px); border-radius: $r-control; background: rgba($color, .22); width: 78%; }
.l2 { height: clamp(6px, 2.2vw, 9px); border-radius: $r-control; background: rgba($color, .14); width: 48%; }
.row:nth-child(2) .l1 { width: 55%; }
.row:nth-child(3) .l1 { width: 90%; }
.row:nth-child(4) .l1 { width: 62%; }
.row:nth-child(5) .l1 { width: 82%; }
// ── 빛 한 줄기가 목록 전체를 한 번에 훑는다 (01·02는 막대마다, 03은 하나로) ──
.sheen {
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($stage-paper, .4), transparent);
transform: translateX(-120%);
animation: shimmer $duration $easing infinite;
pointer-events: none;
}
@keyframes shimmer {
to { transform: translateX(120%); }
}
@media (prefers-reduced-motion: reduce) {
.sheen { animation: none !important; opacity: 0; }
}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);
}
.list {
position: relative;
overflow: hidden;
width: clamp(210px, 76%, 320px);
display: grid;
gap: clamp(6px, 2.4vw, 12px);
border-radius: 16px;
}
.row {
display: flex;
align-items: center;
gap: clamp(8px, 2.6vw, 12px);
}
.avatar {
flex: none;
width: clamp(20px, 6.4vw, 32px);
height: clamp(20px, 6.4vw, 32px);
border-radius: 999px;
background: rgba(255, 247, 230, 0.22);
}
.meta {
flex: 1;
display: grid;
gap: 5px;
}
.l1 {
height: clamp(8px, 2.6vw, 11px);
border-radius: 12px;
background: rgba(255, 247, 230, 0.22);
width: 78%;
}
.l2 {
height: clamp(6px, 2.2vw, 9px);
border-radius: 12px;
background: rgba(255, 247, 230, 0.14);
width: 48%;
}
.row:nth-child(2) .l1 {
width: 55%;
}
.row:nth-child(3) .l1 {
width: 90%;
}
.row:nth-child(4) .l1 {
width: 62%;
}
.row:nth-child(5) .l1 {
width: 82%;
}
.sheen {
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
transform: translateX(-120%);
animation: shimmer 2s ease-out infinite;
pointer-events: none;
}
@keyframes shimmer {
to {
transform: translateX(120%);
}
}
@media (prefers-reduced-motion: reduce) {
.sheen {
animation: none !important;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./AvatarListSkeleton.module.scss";
export type AvatarListSkeletonProps = {
/** 빛이 목록 전체를 훑는 시간 (기본 2000ms) */
duration?: number;
/** CSS easing (기본 ease-out) */
easing?: string;
/** 아바타·막대 색 */
color?: string;
/** 줄 수 (기본 5) */
rows?: number;
className?: string;
};
const L1_WIDTHS = ["78%", "55%", "90%", "62%", "82%"];
export default function AvatarListSkeleton({
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
rows = 5,
className = "",
}: AvatarListSkeletonProps) {
const vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={`${styles.list} ${className}`} style={vars}>
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className={styles.row}>
<span className={styles.avatar} />
<span className={styles.meta}>
<span className={styles.l1} style={{ width: L1_WIDTHS[i % L1_WIDTHS.length] } as CSSProperties} />
<span className={styles.l2} />
</span>
</div>
))}
<span className={styles.sheen} aria-hidden="true" />
</div>
);
}03 · アバターリスト — 計測 累積面積 44.317% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使いどころ
01・02が行やカードごとに光る帯を持つのに対し、03は .list 全体を覆う .sheen をひとつだけ置き、リスト全体を1本の光が横切るようにしました。チャットやフォロワー一覧に向いています。
.list {
position: relative;
overflow: hidden;
display: grid;
gap: 10px;
border-radius: 12px;
}
.avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba($color, .22);
}
.sheen {
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($stage-paper, .4), transparent);
transform: translateX(-120%);
animation: shimmer $dur-loop $ease-out infinite;
pointer-events: none;
}
04テーブルスケルトン
何が起きるか
見出し行の文字はそのまま残し、本文のセルだけを棒に置き換えます。

<main class="stage">
<div class="table">
<div class="row head"><span>名前</span><span>状態</span><span>値</span><span>日付</span></div>
<div class="body">
<div class="row"><span class="cell"></span><span class="cell"></span><span class="cell"></span><span class="cell"></span></div>
<div class="row"><span class="cell"></span><span class="cell"></span><span class="cell"></span><span class="cell"></span></div>
<div class="row"><span class="cell"></span><span class="cell"></span><span class="cell"></span><span class="cell"></span></div>
<div class="row"><span class="cell"></span><span class="cell"></span><span class="cell"></span><span class="cell"></span></div>
<div class="row"><span class="cell"></span><span class="cell"></span><span class="cell"></span><span class="cell"></span></div>
<div class="row"><span class="cell"></span><span class="cell"></span><span class="cell"></span><span class="cell"></span></div>
<span class="sheen" aria-hidden="true"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 빛이 표 몸통을 훑는 시간
$easing: $ease-out;
$color: $subject-ink; // 셀 색 (흰 무대라 잉크 톤)
@include stage($stage-paper);
.table {
width: clamp(220px, 88%, 380px);
border-radius: $r-card;
overflow: hidden;
background: rgba($color, .03);
box-shadow: $shadow-press;
}
// 열 폭은 헤더와 몸통이 같은 grid-template-columns 를 써서 유지한다
.row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: clamp(6px, 2vw, 10px);
padding: clamp(6px, 2vw, 9px) clamp(10px, 3vw, 14px);
align-items: center;
}
.row.head {
font: 600 clamp(10px, 2.8vw, 12px)/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($color, .7);
border-bottom: 1px solid rgba($color, .12);
}
.body {
position: relative; // .sheen 의 위치 기준 (빠지면 엉뚱한 곳에 그려진다)
overflow: hidden; // 빛이 몸통 밖(헤더 포함)으로 안 나가게
}
.body .row:nth-child(even) { background: rgba($color, .03); }
.cell {
height: clamp(9px, 2.6vw, 11px);
border-radius: $r-control;
background: rgba($color, .13);
}
// ── 빛 한 줄기가 몸통만 훑는다. 헤더 글자는 그대로 둔다 ──────────
.sheen {
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($subject-blue, .45), transparent);
transform: translateX(-120%);
animation: shimmer $duration $easing infinite;
pointer-events: none;
}
@keyframes shimmer {
to { transform: translateX(120%); }
}
@media (prefers-reduced-motion: reduce) {
.sheen { animation: none !important; opacity: 0; }
}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);
}
.table {
width: clamp(220px, 88%, 380px);
border-radius: 16px;
overflow: hidden;
background: rgba(23, 20, 26, 0.03);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: clamp(6px, 2vw, 10px);
padding: clamp(6px, 2vw, 9px) clamp(10px, 3vw, 14px);
align-items: center;
}
.row.head {
font: 600 clamp(10px, 2.8vw, 12px)/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.7);
border-bottom: 1px solid rgba(23, 20, 26, 0.12);
}
.body {
position: relative;
overflow: hidden;
}
.body .row:nth-child(even) {
background: rgba(23, 20, 26, 0.03);
}
.cell {
height: clamp(9px, 2.6vw, 11px);
border-radius: 12px;
background: rgba(23, 20, 26, 0.13);
}
.sheen {
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(47, 109, 246, 0.45), transparent);
transform: translateX(-120%);
animation: shimmer 2s ease-out infinite;
pointer-events: none;
}
@keyframes shimmer {
to {
transform: translateX(120%);
}
}
@media (prefers-reduced-motion: reduce) {
.sheen {
animation: none !important;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./TableSkeleton.module.scss";
export type TableSkeletonProps = {
/** 빛이 표 몸통을 훑는 시간 (기본 2000ms) */
duration?: number;
/** CSS easing (기본 ease-out) */
easing?: string;
/** 셀 색 */
color?: string;
/** 머리글 (열 폭은 이 배열 길이로 정해진다) */
columns?: string[];
/** 몸통 행 수 (기본 6) */
rows?: number;
className?: string;
};
export default function TableSkeleton({
duration = 2000,
easing = "ease-out",
color = "#17141a",
columns = ["이름", "상태", "값", "날짜"],
rows = 6,
className = "",
}: TableSkeletonProps) {
const vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
const template = { gridTemplateColumns: `2fr repeat(${columns.length - 1}, 1fr)` } as CSSProperties;
return (
<div className={`${styles.table} ${className}`} style={vars}>
<div className={`${styles.row} ${styles.head}`} style={template}>
{columns.map((c) => <span key={c}>{c}</span>)}
</div>
<div className={styles.body}>
{Array.from({ length: rows }).map((_, r) => (
<div key={r} className={styles.row} style={template}>
{columns.map((_, c) => <span key={c} className={styles.cell} />)}
</div>
))}
<span className={styles.sheen} aria-hidden="true" />
</div>
</div>
);
}04 · テーブルスケルトン — 計測 累積面積 45.848% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
向いている場面
.row は見出しと本文で同じ grid-template-columns を共有しているので、読み込み中も列の幅がずれません。ダッシュボードの表に向いています。
.row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 8px;
padding: 8px 12px;
}
.body {
position: relative;
overflow: hidden;
}
.cell {
height: 10px;
border-radius: 6px;
background: rgba($color, .13);
}
.sheen {
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($subject-blue, .45), transparent);
transform: translateX(-120%);
animation: shimmer $dur-loop $ease-out infinite;
}
05画像ブラーアップ
動きの内容
極小サイズの画像を blur(20px) で先に敷いておき、本物の画像がその上でふわっと現れて鮮明になります。

<main class="stage">
<div class="photo">
<div class="placeholder"></div>
<div class="real"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 흐림 → 선명 → 흐림 한 바퀴
$easing: linear; // 스프링·팝은 중간에서 멈춘 듯 보였다 — 고르게 바뀌어야 눈에 계속 보인다
$color: $subject-blue; // 진짜 이미지 쪽의 강조 색
@include stage($stage-ink);
// 진짜 이미지 대신 CSS 그라디언트로 사진 자리를 흉내 낸다 (외부 이미지 의존 0)
.photo {
position: relative;
overflow: hidden;
width: clamp(200px, 70%, 320px);
aspect-ratio: 4 / 3;
border-radius: $r-card;
box-shadow: $shadow-raised;
}
// 결이 있는 무늬라야 "흐림"이 실제로 다르게 보인다 (매끈한 그라디언트는 흐려도 거의 같은 색이다)
.placeholder, .real {
position: absolute;
inset: 0;
background-image:
repeating-linear-gradient(135deg, rgba($subject-cream, .85) 0 6px, rgba($color, .85) 6px 12px),
radial-gradient(circle at 30% 30%, rgba($subject-cream, .5), transparent 55%);
background-color: $stage-ink;
}
// 아주 작은 원본을 흐리게 미리 깐다 (20px, 고정값 — 이 레이어는 움직이지 않는다). 결이 뭉개져 평평한 색 덩어리가 된다
.placeholder {
filter: blur(20px);
transform: scale(1.15); // 블러 가장자리가 비치지 않게 살짝 키운다
}
// 진짜 이미지가 흐림 위로 나타났다 사라지길 반복한다 (렌더용 루프)
.real {
opacity: 0;
transform: scale(1.04);
animation: reveal $duration $easing infinite;
}
@keyframes reveal {
0% { opacity: 0; transform: scale(1.04); } // 흐림만 보인다
50% { opacity: 1; transform: scale(1); } // 가장 선명한 순간
100% { opacity: 0; transform: scale(1.04); } // 다시 흐림으로 — 한 바퀴가 곧 로딩 한 번
}
// ── 접근성: 흐림-선명 반복 대신 선명한 최종 상태만 고정으로 보여준다 ──
@media (prefers-reduced-motion: reduce) {
.real { animation: none !important; opacity: 1; transform: scale(1); }
}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);
}
.photo {
position: relative;
overflow: hidden;
width: clamp(200px, 70%, 320px);
aspect-ratio: 4/3;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.placeholder, .real {
position: absolute;
inset: 0;
background-image: repeating-linear-gradient(135deg, rgba(255, 247, 230, 0.85) 0 6px, rgba(47, 109, 246, 0.85) 6px 12px), radial-gradient(circle at 30% 30%, rgba(255, 247, 230, 0.5), transparent 55%);
background-color: #17141a;
}
.placeholder {
filter: blur(20px);
transform: scale(1.15);
}
.real {
opacity: 0;
transform: scale(1.04);
animation: reveal 2s linear infinite;
}
@keyframes reveal {
0% {
opacity: 0;
transform: scale(1.04);
}
50% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(1.04);
}
}
@media (prefers-reduced-motion: reduce) {
.real {
animation: none !important;
opacity: 1;
transform: scale(1);
}
}import { useCallback, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ImageBlurUp.module.scss";
export type ImageBlurUpProps = {
/** 진짜 이미지 */
src: string;
/** 아주 작은(20px 안팎) 미리 준비한 흐린 이미지 */
placeholderSrc: string;
alt: string;
/** 흐림 → 선명 전환 시간 (기본 400ms) */
duration?: number;
easing?: string;
className?: string;
};
export default function ImageBlurUp({
src,
placeholderSrc,
alt,
duration = 400,
easing = "cubic-bezier(.2,.8,.2,1)",
className = "",
}: ImageBlurUpProps) {
const [loaded, setLoaded] = useState(false);
const onLoad = useCallback(() => setLoaded(true), []);
const vars = { "--duration": `${duration}ms`, "--easing": easing } as CSSProperties;
return (
<div className={`${styles.photo} ${className}`} style={vars}>
<img className={styles.placeholder} src={placeholderSrc} alt="" aria-hidden="true" />
<img
className={`${styles.real} ${loaded ? styles.loaded : ""}`}
src={src}
alt={alt}
onLoad={onLoad}
/>
</div>
);
}05 · 画像ブラーアップ — 計測 累積面積 49.817% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使う場面
イージングを ease 系ではなくあえて linear にしたのは、途中で速度が変わると「ぼけている時間」が不揃いに見えたためです。ヒーロー画像やギャラリーに向いています。
.placeholder {
filter: blur(20px);
transform: scale(1.15);
}
.real {
opacity: 0;
transform: scale(1.04);
animation: reveal $dur-loop linear infinite;
}
@keyframes reveal {
0% { opacity: 0; transform: scale(1.04); }
50% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.04); }
}
06レスポンシブスケルトン
どんな動きか
入れ物の実際の幅に応じて列数が変わります。

<main class="stage">
<div class="wrap">
<div class="grid">
<div class="card"></div>
<div class="card"></div>
<div class="card"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 상자가 늘었다 줄었다 한 바퀴 도는 시간
$easing: $ease-spring;
$color: $subject-ink; // 카드 색 (노란 무대라 잉크 톤)
@include stage($stage-yellow);
// 이 상자의 실제 폭이 컨테이너 쿼리의 기준이다 (뷰포트 폭이 아니라)
.wrap {
container-type: inline-size;
container-name: skeleton;
max-width: 420px;
width: 94%;
// 데모용: 폭 자체를 keyframes 로 늘렸다 줄인다 — 컨테이너 쿼리는 transform 이 아니라
// 실제 박스 크기에만 반응하므로, 열 수 변화를 보여주려면 여기서는 예외적으로 width 를 움직인다
animation: resize $duration $easing infinite;
}
@keyframes resize {
0% { width: 94%; } // 3열이 보이는 폭
25% { width: 45%; } // 2열로 접히는 폭
50% { width: 15%; } // 1열까지 좁아진 폭
75% { width: 45%; }
100% { width: 94%; }
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: clamp(6px, 2vw, 10px);
}
.card {
min-width: 0;
aspect-ratio: 1 / 1;
border-radius: $r-control;
background: rgba($color, .16);
}
// 상자 폭 260px 아래 → 2열, 150px 아래 → 1열. 뷰포트가 아니라 .wrap 자신의 폭 기준
@container skeleton (max-width: 260px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@container skeleton (max-width: 150px) {
.grid { grid-template-columns: 1fr; }
}
// ── 접근성: 폭이 오가는 움직임을 멈추고 가장 넓은(3열) 상태로 고정 ──
@media (prefers-reduced-motion: reduce) {
.wrap { animation: none !important; width: 94%; }
}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);
}
.wrap {
container-type: inline-size;
container-name: skeleton;
max-width: 420px;
width: 94%;
animation: resize 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes resize {
0% {
width: 94%;
}
25% {
width: 45%;
}
50% {
width: 15%;
}
75% {
width: 45%;
}
100% {
width: 94%;
}
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: clamp(6px, 2vw, 10px);
}
.card {
min-width: 0;
aspect-ratio: 1/1;
border-radius: 12px;
background: rgba(23, 20, 26, 0.16);
}
@container skeleton (max-width: 260px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@container skeleton (max-width: 150px) {
.grid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.wrap {
animation: none !important;
width: 94%;
}
}import type { CSSProperties } from "react";
import styles from "./ResponsiveSkeleton.module.scss";
export type ResponsiveSkeletonProps = {
/** 카드 색 */
color?: string;
/** 카드 수 (기본 3) */
count?: number;
className?: string;
};
// 이 컴포넌트는 뷰포트가 아니라 **자신을 담는 상자의 실제 폭**에 반응한다 (@container).
// 사이드바에 놓든 본문에 놓든 같은 파일이 알아서 열 수를 바꾼다.
export default function ResponsiveSkeleton({
color = "#17141a",
count = 3,
className = "",
}: ResponsiveSkeletonProps) {
const vars = { "--color": color } as CSSProperties;
return (
<div className={`${styles.wrap} ${className}`} style={vars}>
<div className={styles.grid}>
{Array.from({ length: count }).map((_, i) => <div key={i} className={styles.card} />)}
</div>
</div>
);
}06 · レスポンシブスケルトン — 計測 累積面積 56.241% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使いどころ
ビューポート幅ではなく .wrap 自身の幅を基準にする container-type: inline-size を使うので、同じファイルをサイドバーに置いても本文に置いても正しい列数になります。コンテナクエリは Chrome 105 以降・Safari 16 以降・Firefox 110 以降が対応済みで、対応状況は MDN の @container 対応表 で確認できます。
.wrap {
container-type: inline-size;
container-name: skeleton;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
@container skeleton (max-width: 260px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@container skeleton (max-width: 150px) {
.grid { grid-template-columns: 1fr; }
}
07スケルトン→本文
何が起きるか
棒が跳ねずに実際の内容へ切り替わります。

<main class="stage">
<div class="card">
<div class="skeleton">
<div class="bar b1"></div>
<div class="bar b2"></div>
<div class="bar b3"></div>
</div>
<div class="content">
<p class="t1">スケルトンが本文に切り替わります</p>
<p class="t2">ふわっと自然に。</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 막대 ↔ 내용 한 바퀴
$easing: $ease-out;
$color: $subject-cream; // 내용 글자·막대 색 (주황 무대라 크림)
@include stage($stage-orange);
.card {
width: clamp(200px, 74%, 320px);
display: grid; // 두 레이어를 같은 칸에 겹친다
}
.skeleton, .content {
grid-area: 1 / 1;
display: grid;
gap: clamp(8px, 2.6vw, 10px);
align-content: center;
}
// ── 막대: 튀지 않게 페이드만 ──────────────────────────
.skeleton {
animation: fade-out $duration $easing infinite;
}
.bar { height: clamp(11px, 3.4vw, 14px); border-radius: $r-control; background: rgba($color, .35); }
.bar.b1 { width: 90%; }
.bar.b2 { width: 70%; }
.bar.b3 { width: 50%; }
@keyframes fade-out {
0%, 40% { opacity: 1; }
55%, 85% { opacity: 0; }
100% { opacity: 1; }
}
// ── 내용: 페이드 + 위로 살짝 (300ms) ───────────────────
.content {
color: $color;
font: 600 clamp(13px, 3.6vw, 16px)/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
animation: fade-in $duration $easing infinite;
}
.content p { margin: 0; }
@keyframes fade-in {
0%, 40% { opacity: 0; transform: translateY(6px); }
55%, 85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(6px); }
}
// ── 접근성: 전환 반복을 멈추고 내용이 보이는 상태로 고정 ──────
@media (prefers-reduced-motion: reduce) {
.skeleton { display: none; }
.content { animation: none !important; opacity: 1; transform: translateY(0); }
}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);
}
.card {
width: clamp(200px, 74%, 320px);
display: grid;
}
.skeleton, .content {
grid-area: 1/1;
display: grid;
gap: clamp(8px, 2.6vw, 10px);
align-content: center;
}
.skeleton {
animation: fade-out 2s ease-out infinite;
}
.bar {
height: clamp(11px, 3.4vw, 14px);
border-radius: 12px;
background: rgba(255, 247, 230, 0.35);
}
.bar.b1 {
width: 90%;
}
.bar.b2 {
width: 70%;
}
.bar.b3 {
width: 50%;
}
@keyframes fade-out {
0%, 40% {
opacity: 1;
}
55%, 85% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.content {
color: #fff7e6;
font: 600 clamp(13px, 3.6vw, 16px)/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
animation: fade-in 2s ease-out infinite;
}
.content p {
margin: 0;
}
@keyframes fade-in {
0%, 40% {
opacity: 0;
transform: translateY(6px);
}
55%, 85% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(6px);
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
display: none;
}
.content {
animation: none !important;
opacity: 1;
transform: translateY(0);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./SkeletonToContent.module.scss";
export type SkeletonToContentProps = {
loading: boolean;
children: ReactNode;
/** 막대 ↔ 내용 전환 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
className?: string;
};
export default function SkeletonToContent({
loading,
children,
duration = 300,
easing = "ease-out",
color = "#fff7e6",
className = "",
}: SkeletonToContentProps) {
const vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={`${styles.card} ${className}`} style={vars} aria-busy={loading}>
<div className={`${styles.skeleton} ${loading ? "" : styles.hide}`} aria-hidden={!loading}>
<div className={styles.bar} style={{ width: "90%" } as CSSProperties} />
<div className={styles.bar} style={{ width: "70%" } as CSSProperties} />
<div className={styles.bar} style={{ width: "50%" } as CSSProperties} />
</div>
<div className={`${styles.content} ${loading ? styles.hide : ""}`}>{children}</div>
</div>
);
}07 · スケルトン→本文 — 計測 累積面積 8.035% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
向いている場面
.skeleton と .content を同じ grid-area: 1 / 1 に重ね、opacity だけをクロスフェードさせるので、幅や高さが変わって周りの要素が飛ぶことがありません。すべてのスケルトンの最後の場面として使えます。
.card {
width: 74%;
display: grid;
}
.skeleton, .content {
grid-area: 1 / 1;
}
.skeleton {
animation: fade-out $dur-loop $ease-out infinite;
}
@keyframes fade-out {
0%, 40% { opacity: 1; }
55%, 85% { opacity: 0; }
100% { opacity: 1; }
}
08行ごとに時差
動きの内容
各行が少しずつ遅れて光り、波のように見えます。

<main class="stage">
<div class="rows">
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 줄 하나가 훑는 시간 (지연은 이 값의 일부)
$easing: $ease-out;
$color: $subject-ink; // 막대 색 (흰 무대라 잉크 톤)
@include stage($stage-paper);
.rows {
width: clamp(200px, 78%, 340px);
display: grid;
gap: clamp(8px, 2.6vw, 12px);
}
.bar {
position: relative;
overflow: hidden;
height: clamp(11px, 3.2vw, 14px);
border-radius: $r-control;
background: rgba($color, .12);
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($subject-blue, .35), transparent);
transform: translateX(-120%);
animation: shimmer $duration $easing infinite;
}
}
.bar:nth-child(1) { width: 92%; }
.bar:nth-child(2) { width: 68%; }
.bar:nth-child(3) { width: 84%; }
.bar:nth-child(4) { width: 55%; }
.bar:nth-child(5) { width: 78%; }
.bar:nth-child(6) { width: 45%; }
// 줄마다 60ms 씩 늦게 시작해 파도처럼 보인다. 음수 지연 = 렌더 첫 프레임부터 이미 진행 중
@for $i from 1 through 6 {
.bar:nth-child(#{$i})::after { animation-delay: -($i - 1) * 60ms; }
}
// 짝수 줄은 반대 방향으로 훑어 파도가 갈라지는 느낌을 준다
.bar:nth-child(even)::after { animation-name: shimmer-reverse; }
@keyframes shimmer {
to { transform: translateX(120%); }
}
@keyframes shimmer-reverse {
from { transform: translateX(120%); }
to { transform: translateX(-120%); }
}
@media (prefers-reduced-motion: reduce) {
.bar::after { animation: none !important; opacity: .4; transform: translateX(0); }
}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);
}
.rows {
width: clamp(200px, 78%, 340px);
display: grid;
gap: clamp(8px, 2.6vw, 12px);
}
.bar {
position: relative;
overflow: hidden;
height: clamp(11px, 3.2vw, 14px);
border-radius: 12px;
background: rgba(23, 20, 26, 0.12);
}
.bar::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(47, 109, 246, 0.35), transparent);
transform: translateX(-120%);
animation: shimmer 2s ease-out infinite;
}
.bar:nth-child(1) {
width: 92%;
}
.bar:nth-child(2) {
width: 68%;
}
.bar:nth-child(3) {
width: 84%;
}
.bar:nth-child(4) {
width: 55%;
}
.bar:nth-child(5) {
width: 78%;
}
.bar:nth-child(6) {
width: 45%;
}
.bar:nth-child(1)::after {
animation-delay: -0ms;
}
.bar:nth-child(2)::after {
animation-delay: -60ms;
}
.bar:nth-child(3)::after {
animation-delay: -120ms;
}
.bar:nth-child(4)::after {
animation-delay: -180ms;
}
.bar:nth-child(5)::after {
animation-delay: -240ms;
}
.bar:nth-child(6)::after {
animation-delay: -300ms;
}
.bar:nth-child(even)::after {
animation-name: shimmer-reverse;
}
@keyframes shimmer {
to {
transform: translateX(120%);
}
}
@keyframes shimmer-reverse {
from {
transform: translateX(120%);
}
to {
transform: translateX(-120%);
}
}
@media (prefers-reduced-motion: reduce) {
.bar::after {
animation: none !important;
opacity: 0.4;
transform: translateX(0);
}
}import type { CSSProperties } from "react";
import styles from "./WaveStaggerSkeleton.module.scss";
export type WaveStaggerSkeletonProps = {
duration?: number;
easing?: string;
color?: string;
/** 줄 수 (기본 6) */
rows?: number;
/** 줄 사이 시차 (기본 60ms) */
stagger?: number;
className?: string;
};
const WIDTHS = ["92%", "68%", "84%", "55%", "78%", "45%"];
export default function WaveStaggerSkeleton({
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
rows = 6,
stagger = 60,
className = "",
}: WaveStaggerSkeletonProps) {
const vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div className={`${styles.rows} ${className}`} style={vars}>
{Array.from({ length: rows }).map((_, i) => (
<div
key={i}
className={styles.bar}
style={{
width: WIDTHS[i % WIDTHS.length],
animationDelay: `${-i * stagger}ms`,
animationDirection: i % 2 ? "reverse" : "normal",
} as CSSProperties}
/>
))}
</div>
);
}08 · 行ごとに時差 — 計測 累積面積 13.818% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使う場面
Sass の @for で行ごとの animation-delay を自動計算し、偶数行だけ animation-name を逆方向のキーフレームに差し替えているので、波が途中で分かれて見える効果を1つのルールで作れます。長い一覧に向いています。
.bar::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba($subject-blue, .35), transparent);
transform: translateX(-120%);
animation: shimmer $dur-loop $ease-out infinite;
}
@for $i from 1 through 6 {
.bar:nth-child(#{$i})::after { animation-delay: -($i - 1) * 60ms; }
}
.bar:nth-child(even)::after { animation-name: shimmer-reverse; }
@keyframes shimmer-reverse {
from { transform: translateX(120%); }
to { transform: translateX(-120%); }
}
09ダークモード自動配色
どんな動きか
light-dark() ひとつで、ダークモードのときに色が自動で切り替わります。

<main class="stage">
<div class="wrap">
<div class="card light"><div class="bar"></div><div class="bar short"></div></div>
<div class="card dark"><div class="bar"></div><div class="bar short"></div></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 밝은 색 ↔ 어두운 색 한 바퀴
$easing: $ease-spring;
$color: $subject-blue; // light-dark() 로 못 바꾸는 강조 색(파랑은 두 배경 다 위에서 산다)
@include stage($stage-ink);
// 실제로는 light-dark() 함수 하나가 이 크로스페이드가 하는 일을 대신한다 (본문 코드 참고).
// 렌더는 색을 실시간으로 못 바꾸므로, 여기서는 밝은 카드·어두운 카드 두 장을 겹쳐 페이드로 보여준다.
.wrap {
position: relative;
width: clamp(200px, 72%, 300px);
display: grid;
}
.card {
grid-area: 1 / 1;
padding: clamp(14px, 4vw, 18px);
border-radius: $r-card;
display: grid;
gap: clamp(8px, 2.6vw, 10px);
box-shadow: $shadow-raised;
}
.bar { height: clamp(11px, 3.2vw, 14px); border-radius: $r-control; width: 85%; }
.bar.short { width: 55%; }
.card.light {
background: $stage-paper;
animation: swap-out $duration $easing infinite;
.bar { background: rgba($subject-ink, .16); }
}
.card.dark {
background: $stage-ink;
box-shadow: $shadow-raised, inset 0 0 0 1px rgba($subject-cream, .14);
animation: swap-in $duration $easing infinite;
.bar { background: rgba($subject-cream, .22); }
}
@keyframes swap-out {
0% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes swap-in {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
// ── 접근성: 색 전환 반복을 멈추고 어두운 배색 한 장으로 고정 ──────
@media (prefers-reduced-motion: reduce) {
.card.light { animation: none !important; opacity: 0; }
.card.dark { animation: none !important; opacity: 1; }
}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);
}
.wrap {
position: relative;
width: clamp(200px, 72%, 300px);
display: grid;
}
.card {
grid-area: 1/1;
padding: clamp(14px, 4vw, 18px);
border-radius: 16px;
display: grid;
gap: clamp(8px, 2.6vw, 10px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.bar {
height: clamp(11px, 3.2vw, 14px);
border-radius: 12px;
width: 85%;
}
.bar.short {
width: 55%;
}
.card.light {
background: #ffffff;
animation: swap-out 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.card.light .bar {
background: rgba(23, 20, 26, 0.16);
}
.card.dark {
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32), inset 0 0 0 1px rgba(255, 247, 230, 0.14);
animation: swap-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.card.dark .bar {
background: rgba(255, 247, 230, 0.22);
}
@keyframes swap-out {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes swap-in {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.card.light {
animation: none !important;
opacity: 0;
}
.card.dark {
animation: none !important;
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./AutoThemeSkeleton.module.scss";
export type AutoThemeSkeletonProps = {
className?: string;
};
// light-dark() 가 색을 정하므로 이 컴포넌트에는 상태·prop 이 필요 없다.
// 시스템(또는 :root 의 color-scheme)이 바뀌면 배경·막대 색이 스스로 바뀐다.
export default function AutoThemeSkeleton({ className = "" }: AutoThemeSkeletonProps) {
return (
<div className={`${styles.card} ${className}`}>
<div className={styles.bar} style={{ width: "85%" } as CSSProperties} />
<div className={styles.bar} style={{ width: "55%" } as CSSProperties} />
</div>
);
}09 · ダークモード自動配色 — 計測 累積面積 15.289% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使いどころ
color-scheme: light dark を宣言した要素の中で使うと、ライト用とダーク用の2つの値のうちどちらを描くかをブラウザ自身が判断してくれるので、prefers-color-scheme の分岐を自分で書く必要がありません。ダークモードを持つすべてのサイトに向いています。
:root {
color-scheme: light dark;
}
.card {
background: light-dark(#f5f2e9, #1b1b1f);
color: light-dark(#1b1b1f, #f5f2e9);
}
.bar {
background: light-dark(rgba(#1b1b1f, .16), rgba(#f5f2e9, .22));
}
どこで崩れるか — 落とし穴
光の帯がはみ出す
9つとも overflow: hidden を持つ親要素の中でだけ光の帯を動かしています。01を作ったとき、この overflow: hidden を忘れると光の帯がカードの外側にまではみ出し、隣の要素の上を横切って見えるという崩れ方をしました。position: relative の親に overflow: hidden を必ずセットで書くのが、シマー系5つ(01・02・03・04・08)に共通する条件です。
コンテナクエリの例外
もうひとつは06のコンテナクエリです。デモでは仕組みを見せるためにあえて .wrap の width を keyframes で動かしていますが、これは「揺れやすい要素」を可視化するための例外で、コンテナクエリ自体は transform の変化には反応しません。実際のページでは、サイドバーの開閉やウィンドウのリサイズのような本物のボックス幅の変化に対して .grid が反応します。修正済みの9ファイルを開くには a3rjf9rd という文字列が必要です。下のカードから zip を取ってください。
07の受け渡しがカード1枚ではなく画面全体の規模になると、この落とし穴もそのまま大きくなります。その規模の切り替えはcss ページ遷移 アニメーション9選で別に扱いました。
ブラウザ対応
3つ目はブラウザの対応です。light-dark() と @container はどちらも比較的新しい構文で、09の light-dark() は Chrome・Edge 123 以降と Safari 17.5 以降が対応済みですが、MDN の light-dark() 対応表 にあるとおり古いブラウザでは値そのものが無効になり、背景色が透明のまま抜けてしまいます。zip のファイルは @supports で分岐させ、非対応ブラウザでは固定の色にフォールバックするようにしてあります。
アクセシビリティ
動きを減らす設定のとき
9つとも prefers-reduced-motion: reduce で光の移動や拡大縮小の animation を止めます。ただし止めた後に何を残すかは項目ごとに違い、01・02・03・04・08は光を止めて薄い一定の明るさだけを残し、05は最も鮮明な最終状態を、07は本文が見えている状態を、09はダークモードのカードを固定で表示します。06だけは動きを止めても列数の変化そのものが機能なので、width のアニメーションだけを止め、コンテナクエリの判定はそのまま生かしています。
@media (prefers-reduced-motion: reduce) {
.line::after, .sheen { animation: none !important; opacity: .4; }
.real { animation: none !important; opacity: 1; transform: scale(1); }
}
ローディング アニメーション css のみで作れるスケルトン以外のパターンは CSS アニメーションのカテゴリ に、このサイトについては 紹介ページ にまとめています。
FAQ
スケルトンスクリーン css とローディングスピナー、どちらを使うべきですか?
読み込むものの形があらかじめ分かっているなら、スケルトンスクリーンのほうが向いています。カードや表のように最終的な形が決まっている場所ではスケルトンが「もうすぐこの形の内容が来る」と伝えられますが、形が定まらない処理中の待ち時間にはスピナーのほうが誤解を招きません。
9つを1つのページに全部入れても大丈夫ですか?
入れられますが、06のコンテナクエリだけは実際のレイアウトで使うときに親のボックス幅がはっきり決まっている場所に置いてください。幅が決まっていない場所に置くと、列数の切り替わる境目が意図しない位置にずれることがあります。それ以外の8つは1つのページに何個置いても互いに影響しません。
React ではどう書きますか?
zip の react/ フォルダのコンポーネントは、状態管理をせずクラス名を1つ付けるだけで9つとも同じ見た目になります。09の light-dark() だけは CSS 側で完結しているため、ダークモード用の状態を React 側に持つ必要がありません。 同じ読み込み中の状態は、たいてい要求を出したボタンにも渡ります。押されたボタン自身がローディングへ変わる項目はCSS ボタン アニメーション 9選に入っています。









