01受信箱ゼロの浮遊カード
トレイのアイコンがゆっくり浮かんで沈み、足元の楕円の影が同じ拍で細くなったり広がったりします。
使いどころ受信箱や通知一覧のように、空であることが失敗ではなく達成である画面に置くとき
translateYscaleXaria-live

<main class="stage">
<section class="ez is-demo" aria-labelledby="ez-t">
<div class="ez__art">
<span class="ez__icon" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em"><g fill="currentColor"><path d="M216 48v112h-36.69a8 8 0 0 0-5.66 2.34l-19.31 19.32a8 8 0 0 1-5.66 2.34h-41.37a8 8 0 0 1-5.66-2.34l-19.31-19.32a8 8 0 0 0-5.66-2.34H40V48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 32H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 16v104h-28.7a15.86 15.86 0 0 0-11.3 4.69L148.69 176h-41.38L88 156.68A15.9 15.9 0 0 0 76.69 152H48V48Zm0 160H48v-40h28.69L96 187.32a15.9 15.9 0 0 0 11.31 4.68h41.38a15.86 15.86 0 0 0 11.31-4.69L179.31 168H208z"/></g></svg></span>
<span class="ez__shadow" aria-hidden="true"></span>
</div>
<h2 class="ez__title" id="ez-t">受信箱は空です</h2>
<p class="ez__sub" id="ez-s" data-busy="確認中" data-done="未読メール0件・確認{n}回" aria-live="polite">未読メール0件</p>
<button class="ez__btn" type="button" id="ez-b">再読み込み</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: ease-in-out;
$color: $subject-cream;
@include stage($stage-ink);
.ez {
box-sizing: border-box;
width: min(248px, 86vw);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
color: $color;
}
.ez__art {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
}
.ez__icon {
display: block;
font-size: clamp(40px, 13vw, 56px);
line-height: 0;
color: $color;
}
.ez__shadow {
display: block;
width: 36px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, .18);
}
.ez__title {
margin: 0;
font-size: clamp(12px, 4vw, 14px);
line-height: 1.3;
font-weight: 800;
}
.ez__sub {
margin: 0;
min-height: 14px;
font-size: 11px;
line-height: 14px;
font-weight: 500;
color: rgba(255, 247, 230, .62);
}
.ez__btn {
height: 28px;
margin: 0;
padding: 0 $sp-4;
border: 1px solid rgba(255, 255, 255, .2);
border-radius: $r-pill;
background: rgba(255, 255, 255, .08);
color: $color;
font-family: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: background $dur-quick $ease-out;
}
.ez__btn:hover { background: rgba(255, 255, 255, .16); }
.ez__btn:disabled { opacity: .5; cursor: default; }
.ez__btn:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
// ── 자동 루프: 떠올랐다 내려앉는다. 0% 와 100% 가 같은 자리라 이어 붙어도 튀지 않는다 ──
.ez.is-demo .ez__icon { animation: ez-float $duration $easing infinite; }
.ez.is-demo .ez__shadow { animation: ez-cast $duration $easing infinite; }
@keyframes ez-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-14px); }
}
@keyframes ez-cast {
0%, 100% { transform: scaleX(1); opacity: .18; }
50% { transform: scaleX(.68); opacity: .1; }
}
@media (prefers-reduced-motion: reduce) {
.ez.is-demo .ez__icon,
.ez.is-demo .ez__shadow { animation: none !important; }
.ez__btn { transition: 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);
}
.ez {
box-sizing: border-box;
width: min(248px, 86vw);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
color: #fff7e6;
}
.ez__art {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.ez__icon {
display: block;
font-size: clamp(40px, 13vw, 56px);
line-height: 0;
color: #fff7e6;
}
.ez__shadow {
display: block;
width: 36px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
}
.ez__title {
margin: 0;
font-size: clamp(12px, 4vw, 14px);
line-height: 1.3;
font-weight: 800;
}
.ez__sub {
margin: 0;
min-height: 14px;
font-size: 11px;
line-height: 14px;
font-weight: 500;
color: rgba(255, 247, 230, 0.62);
}
.ez__btn {
height: 28px;
margin: 0;
padding: 0 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #fff7e6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: background 220ms ease-out;
}
.ez__btn:hover {
background: rgba(255, 255, 255, 0.16);
}
.ez__btn:disabled {
opacity: 0.5;
cursor: default;
}
.ez__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ez.is-demo .ez__icon {
animation: ez-float 2s ease-in-out infinite;
}
.ez.is-demo .ez__shadow {
animation: ez-cast 2s ease-in-out infinite;
}
@keyframes ez-float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-14px);
}
}
@keyframes ez-cast {
0%, 100% {
transform: scaleX(1);
opacity: 0.18;
}
50% {
transform: scaleX(0.68);
opacity: 0.1;
}
}
@media (prefers-reduced-motion: reduce) {
.ez.is-demo .ez__icon,
.ez.is-demo .ez__shadow {
animation: none !important;
}
.ez__btn {
transition: none;
}
}// 실제 동작: 새로 고침을 누르면 확인 중 상태로 바뀌었다가 확인 횟수와 함께 다시 빈 상태로 돌아온다.
var card = document.querySelector('.ez');
var btn = document.getElementById('ez-b');
var sub = document.getElementById('ez-s');
var n = 0;
btn.addEventListener('click', function () {
card.classList.remove('is-demo');
if (btn.disabled) return;
btn.disabled = true;
sub.textContent = sub.dataset.busy;
window.setTimeout(function () {
n += 1;
sub.textContent = sub.dataset.done.replace('{n}', n);
btn.disabled = false;
}, 700);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./InboxZeroCard.module.scss";
export type InboxZeroCardProps = {
title?: string;
idle?: string;
busy?: string;
done?: string;
action?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function InboxZeroCard({
title = "Your inbox is empty",
idle = "0 unread messages",
busy = "Checking",
done = "0 unread · checked {n}x",
action = "Refresh",
duration = 2000,
easing = "ease-in-out",
color = "#fff7e6",
}: InboxZeroCardProps) {
const [sub, setSub] = useState(idle);
const [loop, setLoop] = useState(true);
const [busyNow, setBusyNow] = useState(false);
const [count, setCount] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function refresh() {
if (busyNow) return;
setLoop(false);
setBusyNow(true);
setSub(busy);
window.setTimeout(() => {
const n = count + 1;
setCount(n);
setSub(done.replace("{n}", String(n)));
setBusyNow(false);
}, 700);
}
return (
<section className={`${styles.ez} ${loop ? styles.isDemo : ""}`} style={vars}>
<div className={styles.art}>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 256 256" width="1em" height="1em">
<g fill="currentColor">
<path d="M216 48v112h-36.69a8 8 0 0 0-5.66 2.34l-19.31 19.32a8 8 0 0 1-5.66 2.34h-41.37a8 8 0 0 1-5.66-2.34l-19.31-19.32a8 8 0 0 0-5.66-2.34H40V48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2" />
<path d="M208 32H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 16v104h-28.7a15.86 15.86 0 0 0-11.3 4.69L148.69 176h-41.38L88 156.68A15.9 15.9 0 0 0 76.69 152H48V48Zm0 160H48v-40h28.69L96 187.32a15.9 15.9 0 0 0 11.31 4.68h41.38a15.86 15.86 0 0 0 11.31-4.69L179.31 168H208z" />
</g>
</svg>
</span>
<span className={styles.shadow} aria-hidden="true" />
</div>
<h2 className={styles.title}>{title}</h2>
<p className={styles.sub} aria-live="polite">{sub}</p>
<button className={styles.btn} type="button" onClick={refresh} disabled={busyNow}>{action}</button>
</section>
);
}01 受信箱ゼロの浮遊カード — 計測 累積面積 1.28% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · エンプティステート デザイン 9選 — CSSだけで描く
このデモが属する記事
エンプティステート デザイン 9選 — CSSだけで描くエンプティステート デザインは、一覧に出すデータが1件もないときに、その空いた場所を代わりに埋める画面です。ここにある9つのうち1つは、データがないからではなく見る権限がないから空いていて、その違いは南京錠1つで見分けられます。 この記事のデモをすべて見る


