06Empty-state crossfade — a message that fades out the instant the item count value leaves zero
While the item count value stays at zero, the message keeps its opacity value, and once that count value moves to one or more, the message's opacity value falls as the list's opacity value rises in a crossing pair.
Best forA cart's initial screen with nothing added
crossfadeApplyDUR_MSframe

<main class="stage">
<div class="cesc is-demo" id="cesc">
<div class="cesc__panel">
<header class="cesc__head">Cart</header>
<div class="cesc__body">
<div class="cesc__empty" id="cescEmpty">
<span class="cesc__icon" aria-hidden="true">
<svg viewBox="0 0 256 256" width="34" height="34"><g fill="currentColor" opacity=".6"><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16ZM96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Z"/></g></svg>
</span>
<p class="cesc__msg">Your cart is empty</p>
</div>
<ul class="cesc__list" id="cescList">
<li class="cesc__row"><span>Leather Wallet</span><b>$36</b></li>
<li class="cesc__row"><span>Sunglasses</span><b>$29</b></li>
</ul>
</div>
<button class="cesc__toggle" id="cescToggle" type="button">Fill / Empty</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 하나의 진행값(p)을 이징해 두 상태의 opacity·scale 을 상보적으로 계산한다
// (crossfadeApply(), index.html) — 빈 상태와 목록을 각각 애니메이션하지 않고 한 값에서 도출.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.stage { position: relative; }
.cesc__panel {
width: min(240px, 70vw);
display: flex;
flex-direction: column;
gap: $sp-3;
}
.cesc__head {
color: $color;
font-weight: 700;
font-size: 14px;
}
.cesc__body {
position: relative;
height: 130px;
}
.cesc__empty,
.cesc__list {
position: absolute;
inset: 0;
transform-origin: center;
}
.cesc__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-2;
color: $color;
text-align: center;
}
.cesc__msg {
margin: 0;
font-size: 12px;
font-weight: 600;
opacity: .72;
}
.cesc__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.cesc__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-3;
border-radius: $r-control;
background: $subject-cream;
color: $color;
font-weight: 600;
font-size: 13px;
}
.cesc__toggle {
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
background: $color;
color: $stage-yellow;
font-family: inherit;
font-weight: 700;
font-size: 12px;
cursor: pointer;
align-self: flex-start;
}
@media (max-width: 340px) {
.cesc__panel { width: min(190px, 66vw); gap: $sp-2; }
.cesc__head { font-size: 12px; }
.cesc__body { height: 84px; }
.cesc__row { padding: $sp-2; font-size: 12px; }
.cesc__toggle { padding: $sp-1 $sp-4; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.cesc__empty, .cesc__list { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.cesc__panel {
width: min(240px, 70vw);
display: flex;
flex-direction: column;
gap: 12px;
}
.cesc__head {
color: #17141a;
font-weight: 700;
font-size: 14px;
}
.cesc__body {
position: relative;
height: 130px;
}
.cesc__empty,
.cesc__list {
position: absolute;
inset: 0;
transform-origin: center;
}
.cesc__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #17141a;
text-align: center;
}
.cesc__msg {
margin: 0;
font-size: 12px;
font-weight: 600;
opacity: 0.72;
}
.cesc__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.cesc__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 600;
font-size: 13px;
}
.cesc__toggle {
border: 0;
border-radius: 999px;
padding: 8px 20px;
background: #17141a;
color: #ffd23f;
font-family: inherit;
font-weight: 700;
font-size: 12px;
cursor: pointer;
align-self: flex-start;
}
@media (max-width: 340px) {
.cesc__panel {
width: min(190px, 66vw);
gap: 8px;
}
.cesc__head {
font-size: 12px;
}
.cesc__body {
height: 84px;
}
.cesc__row {
padding: 8px;
font-size: 12px;
}
.cesc__toggle {
padding: 4px 16px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.cesc__empty, .cesc__list {
transition: none !important;
}
}(function () {
var root = document.getElementById('cesc');
var emptyEl = document.getElementById('cescEmpty');
var listEl = document.getElementById('cescList');
var toggleBtn = document.getElementById('cescToggle');
// 계산 겹: 하나의 진행값(p, 0~1)을 이징해 두 상태의 opacity 를 서로 반대 방향으로 동시에
// 계산한다(crossfadeApply()) — 두 애니메이션을 따로 걸지 않고 한 값에서 상보적으로 도출한다.
function easeInOutCubic(p) {
return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
}
var DUR_MS = 420;
var hasItems = false;
var raf = null;
function crossfadeApply(p, toItems) {
var eased = easeInOutCubic(p);
var itemsOpacity = toItems ? eased : 1 - eased;
var emptyOpacity = 1 - itemsOpacity;
listEl.style.opacity = itemsOpacity.toFixed(3);
listEl.style.transform = 'scale(' + (0.96 + itemsOpacity * 0.04).toFixed(3) + ')';
emptyEl.style.opacity = emptyOpacity.toFixed(3);
emptyEl.style.transform = 'scale(' + (0.96 + emptyOpacity * 0.04).toFixed(3) + ')';
}
function run(toItems) {
if (raf) { cancelAnimationFrame(raf); }
var start = performance.now();
function frame(now) {
var p = Math.min(1, (now - start) / DUR_MS);
crossfadeApply(p, toItems);
if (p < 1) {
raf = requestAnimationFrame(frame);
} else {
raf = null;
hasItems = toItems;
}
}
raf = requestAnimationFrame(frame);
}
toggleBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
run(!hasItems);
});
var autoplayTimer = null;
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
crossfadeApply(0, false);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayTimer = setInterval(function () { run(!hasItems); }, 1300);
}
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CartDrawerEmptyStateCrossfade.module.scss";
export type CartDrawerEmptyStateCrossfadeProps = {
duration?: number;
color?: string;
};
// 계산 겹: 하나의 진행값(p, 0~1)을 이징해 두 상태의 opacity 를 서로 반대 방향으로 동시에
// 계산한다(crossfadeApply()) — 두 애니메이션을 따로 걸지 않고 한 값에서 상보적으로 도출한다.
function easeInOutCubic(p: number): number {
return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
}
const DUR_MS = 420;
export default function CartDrawerEmptyStateCrossfade({
color = "#17141a",
}: CartDrawerEmptyStateCrossfadeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const emptyRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLUListElement | null>(null);
const hasItemsRef = useRef(false);
const rafRef = useRef<number | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
function crossfadeApply(p: number, toItems: boolean) {
const list = listRef.current;
const empty = emptyRef.current;
if (!list || !empty) return;
const eased = easeInOutCubic(p);
const itemsOpacity = toItems ? eased : 1 - eased;
const emptyOpacity = 1 - itemsOpacity;
list.style.opacity = itemsOpacity.toFixed(3);
list.style.transform = `scale(${(0.96 + itemsOpacity * 0.04).toFixed(3)})`;
empty.style.opacity = emptyOpacity.toFixed(3);
empty.style.transform = `scale(${(0.96 + emptyOpacity * 0.04).toFixed(3)})`;
}
function run(toItems: boolean) {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
const start = performance.now();
function frame(now: number) {
const p = Math.min(1, (now - start) / DUR_MS);
crossfadeApply(p, toItems);
if (p < 1) {
rafRef.current = requestAnimationFrame(frame);
} else {
rafRef.current = null;
hasItemsRef.current = toItems;
}
}
rafRef.current = requestAnimationFrame(frame);
}
function stopAutoplay() {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}
const onToggle = () => {
rootRef.current?.classList.remove(styles.isDemo);
stopAutoplay();
run(!hasItemsRef.current);
};
const toggleEl = rootRef.current?.querySelector<HTMLButtonElement>(`.${styles.toggle}`);
toggleEl?.addEventListener("click", onToggle);
crossfadeApply(0, false);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
timerRef.current = setInterval(() => run(!hasItemsRef.current), 1300);
}
return () => {
stopAutoplay();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
toggleEl?.removeEventListener("click", onToggle);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ "--color": color } as CSSProperties}>
<div className={styles.panel}>
<header className={styles.head}>장바구니</header>
<div className={styles.body}>
<div ref={emptyRef} className={styles.empty}>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 256 256" width="34" height="34"><g fill="currentColor" opacity=".6"><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16ZM96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Z"/></g></svg>
</span>
<p className={styles.msg}>장바구니가 비어 있어요</p>
</div>
<ul ref={listRef} className={styles.list}>
<li className={styles.row}><span>가죽 지갑</span><b>$36</b></li>
<li className={styles.row}><span>선글라스</span><b>$29</b></li>
</ul>
</div>
<button className={styles.toggle} type="button">담기 / 비우기</button>
</div>
</div>
);
}06 Empty-state crossfade — a message that fades out the instant the item count value leaves zero — Measured area 14.408% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · Cart Drawer UI Patterns: 9 Motion Techniques
The post this demo lives in
Cart Drawer UI Patterns: 9 Motion TechniquesOne panel overshoots its open position before a spring curve settles it, one drag distance meets growing resistance past See all demos in this post
If you like this effect - similar demos
Transitions07 Empty-state Message That Swaps In When a Remaining-count Value Hits ZeroNotification Animation CSS: 9 Panel UI Patterns
Transitions08 Empty-list state value toggle — when the no-rows state value turns on, an empty-state message's appearance value gradually risesCollapsible Table Row: 9 UI Patterns
Transitions07 Side drawer that slides in from the right9 mega menu ui patterns, link list to site map