08空の状態の案内カード
まだ何もないリストの位置に破線の枠のカードが置かれ、その破線が1目盛りずつ流れることで、「ここに追加してください」が空白ではなくやるべきこととして読めます。
使いどころ最初のプロジェクト・記事がないリスト画面、アップロード待ちの領域
점선 흐름dashoffsetradius 16

<main class="stage is-demo">
<div class="empty">
<svg class="edge" viewBox="0 0 240 128" preserveAspectRatio="none" aria-hidden="true">
<rect x="1" y="1" width="238" height="126" rx="16" ry="16"
fill="none" stroke="#e8a8c0" stroke-width="2" stroke-dasharray="10 8"/>
</svg>
<span class="fi" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M208,88v24H69.77a8,8,0,0,0-7.59,5.47L32,208V64a8,8,0,0,1,8-8H93.33a8,8,0,0,1,4.8,1.6L128,80h72A8,8,0,0,1,208,88Z" opacity="0.2"/><path d="M245,110.64A16,16,0,0,0,232,104H216V88a16,16,0,0,0-16-16H130.67L102.94,51.2a16.14,16.14,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V208a8,8,0,0,0,8,8H211.1a8,8,0,0,0,7.59-5.47l28.49-85.47A16.05,16.05,0,0,0,245,110.64ZM93.34,64,123.2,86.4A8,8,0,0,0,128,88h72v16H69.77a16,16,0,0,0-15.18,10.94L40,158.7V64Zm112,136H43.1l26.67-80H232Z"/></svg></span>
<p class="tt">最初のプロジェクトを追加</p>
<span class="cta">プロジェクトを作成</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 점선이 한 눈금(18px) 흐르는 한 바퀴
$easing: linear; // 점선 흐름은 등속이어야 이음매가 안 보인다
$color: $subject-pink; // 큐트 계열 — 테두리·아이콘 전용
@include stage(#fdf0f4);
.empty {
position: relative;
box-sizing: border-box;
width: 240px;
max-width: 100%;
height: 128px;
display: grid; place-content: center; justify-items: center;
gap: $sp-1;
padding: $sp-3;
border-radius: $r-card;
background: #fff;
}
// 점선 테두리 — border-style: dashed 는 흐르게 할 수 없다. SVG stroke-dashoffset 으로
.edge {
position: absolute; inset: 0;
width: 100%; height: 100%;
border-radius: $r-card;
}
.stage.is-demo .edge rect { animation: flow $duration $easing infinite; }
.fi {
display: block; width: 24px; height: 24px; color: $color;
transform-origin: 50% 100%; // 아래 끝이 축 — 매달린 진자
}
.fi svg { width: 100%; height: 100%; display: block; }
// 한 겹(폴더 진자 + CTA 호흡) — 2026-09-15 형태 2차. 점선 카드 안이 매달린 폴더와 절제된 호흡으로 살아 낸다
// 한 겹 더(진자 rotate · CTA pop — 개별 속성, 콤마 확장) — fi-pendulum 은 회전 방향이 좌우 섞여 진자 감쇠가 안 보인다.
// rotate: 로 순수 감쇠 진자(우→좌→… 8→-4.8→2.4→-.8→0)를 얹고, CTA 는 breathe 위에 절제된 pop 1.08
.stage.is-demo .fi {
animation: fi-pendulum $duration $ease-lerp-settle infinite, swing $duration ease-in-out infinite;
}
.stage.is-demo .cta {
animation: cta-breathe $duration $ease-lerp-settle infinite, cta-pop $duration $ease-spring infinite .2s;
}
.tt {
margin: $sp-1 0 0;
font-size: 12px; font-weight: 700;
color: $subject-ink; // 분홍 바탕에서 읽히는 건 잉크뿐이다
text-align: center;
}
.cta {
display: inline-flex; align-items: center; justify-content: center;
margin: $sp-1 0 0;
padding: $sp-2 $sp-4;
border-radius: $r-pill;
background: $subject-ink;
color: #fff;
font-size: 11px; font-weight: 700;
}
// dasharray 10+8 = 18. 한 주기에 정확히 18 을 흘려야 이음매가 안 생긴다
@keyframes flow {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -18; }
}
// 한 겹(감쇠 진자 ±6deg 이내 — 카드 안에서 자기 자리를 벗어나지 않는다)
@keyframes fi-pendulum {
0% { transform: rotate(0); }
18% { transform: rotate(-6deg); }
38% { transform: rotate(4deg); }
58% { transform: rotate(-2deg); }
78% { transform: rotate(1deg); }
92%,100% { transform: rotate(0); }
}
@keyframes cta-breathe {
0% { transform: scale(1); }
30% { transform: scale(1.04); }
55% { transform: scale(.99); }
75%,100% { transform: scale(1); }
}
// 한 겹(개별 속성 키프레임) — 기존 transform 키프레임과 병렬 합성
// 순수 감쇠 진자 — 한 방향에서 시작해 8 → -4.8 → 2.4 → -.8 → 0. fi-pendulum 과 달리 감쇠곡선이 읽힌다
@keyframes swing {
0% { rotate: 0deg; }
8% { rotate: 8deg; }
28% { rotate: -4.8deg; }
48% { rotate: 2.4deg; }
66% { rotate: -.8deg; }
80%, 100% { rotate: 0deg; }
}
// 절제된 탄성 pop — CTA 답게 1.08 까지만. breathe 정점(30%=.6s) 뒤 .8s 지점에서 한 번
@keyframes cta-pop {
0% { scale: 1; }
6% { scale: 1.08; }
16% { scale: .96; }
28% { scale: 1.02; }
38%, 100% { scale: 1; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .edge rect { animation: none; }
.stage.is-demo .fi { animation: none; transform: none; rotate: 0deg; }
.stage.is-demo .cta { animation: none; transform: none; scale: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #fdf0f4;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.empty {
position: relative;
box-sizing: border-box;
width: 240px;
max-width: 100%;
height: 128px;
display: grid;
place-content: center;
justify-items: center;
gap: 4px;
padding: 12px;
border-radius: 16px;
background: #fff;
}
.edge {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: 16px;
}
.stage.is-demo .edge rect {
animation: flow 2s linear infinite;
}
.fi {
display: block;
width: 24px;
height: 24px;
color: #ff9ebb;
transform-origin: 50% 100%;
}
.fi svg {
width: 100%;
height: 100%;
display: block;
}
.stage.is-demo .fi {
animation: fi-pendulum 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite, swing 2s ease-in-out infinite;
}
.stage.is-demo .cta {
animation: cta-breathe 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite, cta-pop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite 0.2s;
}
.tt {
margin: 4px 0 0;
font-size: 12px;
font-weight: 700;
color: #17141a;
text-align: center;
}
.cta {
display: inline-flex;
align-items: center;
justify-content: center;
margin: 4px 0 0;
padding: 8px 16px;
border-radius: 999px;
background: #17141a;
color: #fff;
font-size: 11px;
font-weight: 700;
}
@keyframes flow {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: -18;
}
}
@keyframes fi-pendulum {
0% {
transform: rotate(0);
}
18% {
transform: rotate(-6deg);
}
38% {
transform: rotate(4deg);
}
58% {
transform: rotate(-2deg);
}
78% {
transform: rotate(1deg);
}
92%, 100% {
transform: rotate(0);
}
}
@keyframes cta-breathe {
0% {
transform: scale(1);
}
30% {
transform: scale(1.04);
}
55% {
transform: scale(0.99);
}
75%, 100% {
transform: scale(1);
}
}
@keyframes swing {
0% {
rotate: 0deg;
}
8% {
rotate: 8deg;
}
28% {
rotate: -4.8deg;
}
48% {
rotate: 2.4deg;
}
66% {
rotate: -0.8deg;
}
80%, 100% {
rotate: 0deg;
}
}
@keyframes cta-pop {
0% {
scale: 1;
}
6% {
scale: 1.08;
}
16% {
scale: 0.96;
}
28% {
scale: 1.02;
}
38%, 100% {
scale: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .edge rect {
animation: none;
}
.stage.is-demo .fi {
animation: none;
transform: none;
rotate: 0deg;
}
.stage.is-demo .cta {
animation: none;
transform: none;
scale: 1;
}
}import type { CSSProperties } from "react";
import styles from "./EmptyStateGuideCard.module.scss";
export type EmptyStateGuideCardProps = {
duration?: number;
easing?: string;
color?: string;
title?: string;
ctaLabel?: string;
onCta?: () => void;
};
// dasharray 10 + 8 = 18. 한 주기에 정확히 18 을 흘려야 이음매가 안 생긴다
const DASH = 18;
export default function EmptyStateGuideCard({
duration = 2000,
easing = "linear",
color = "#ff9ebb",
title = "Add your first project",
ctaLabel = "Create project",
onCta,
}: EmptyStateGuideCardProps) {
const vars: CSSProperties = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--dash": `${-DASH}`,
} as CSSProperties;
return (
<div className={styles.empty} style={vars}>
{/* border-style: dashed 는 흐르게 할 수 없다. SVG stroke-dashoffset 으로 흘린다 */}
<svg className={styles.edge} viewBox="0 0 240 128" preserveAspectRatio="none" aria-hidden="true">
<rect
x="1" y="1" width="238" height="126" rx="16" ry="16"
fill="none" stroke="#e8a8c0" strokeWidth="2" strokeDasharray="10 8"
/>
</svg>
<span className={styles.fi} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor">
<path d="M208,88v24H69.77a8,8,0,0,0-7.59,5.47L32,208V64a8,8,0,0,1,8-8H93.33a8,8,0,0,1,4.8,1.6L128,80h72A8,8,0,0,1,208,88Z" opacity="0.2" />
<path d="M245,110.64A16,16,0,0,0,232,104H216V88a16,16,0,0,0-16-16H130.67L102.94,51.2a16.14,16.14,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V208a8,8,0,0,0,8,8H211.1a8,8,0,0,0,7.59-5.47l28.49-85.47A16.05,16.05,0,0,0,245,110.64ZM93.34,64,123.2,86.4A8,8,0,0,0,128,88h72v16H69.77a16,16,0,0,0-15.18,10.94L40,158.7V64Zm112,136H43.1l26.67-80H232Z" />
</svg>
</span>
<p className={styles.tt}>{title}</p>
<button type="button" className={styles.cta} onClick={onCta}>{ctaLabel}</button>
</div>
);
}08 空の状態の案内カード — 計測 累積面積 2.603% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · チュートリアル UI デザイン9選 — コピペOKのツアー部品
このデモが属する記事
チュートリアル UI デザイン9選 — コピペOKのツアー部品チュートリアル ui デザインは、登録したばかりの人に「次にどこを押せばいいか」を画面上で直接指し示す部品です。チェックリスト・スポットライト・ステップ式ツールチップなど、形はだいたい決まっているので、選んで貼るだけで済みます。実際に動かし この記事のデモをすべて見る


