04Offline card with a turning retry arrow
Under a slashed Wi-Fi icon the retry button's circular arrow keeps turning one way, so there is no frame where it snaps back.
Best forWhen the list is empty because the request failed, not because there is no data, and a retry button belongs next to it
rotatelineararia-live

<main class="stage">
<section class="of is-demo" aria-labelledby="of-t">
<span class="of__icon" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em"><g fill="currentColor"><path d="m238.2 87.69l-104 125.43a8 8 0 0 1-12.3 0L17.8 87.69a7.79 7.79 0 0 1 1.31-11.21a180.75 180.75 0 0 1 217.78 0a7.79 7.79 0 0 1 1.31 11.21" opacity=".2"/><path d="M53.92 34.62a8 8 0 1 0-11.84 10.76l4.55 5A188 188 0 0 0 14.28 70.1a15.93 15.93 0 0 0-6.17 10.81a15.65 15.65 0 0 0 3.54 11.89l104 125.43A15.93 15.93 0 0 0 128 224a15.93 15.93 0 0 0 12.31-5.77l28.1-33.88l33.67 37a8 8 0 1 0 11.84-10.76ZM128 208L24.09 82.74a171.5 171.5 0 0 1 33.82-19.95l99.63 109.6ZM244.35 92.8l-49.42 59.58a8 8 0 0 1-12.32-10.21l49.3-59.43A170.76 170.76 0 0 0 128 48a175 175 0 0 0-32.39 3a8 8 0 1 1-3-15.72A190.6 190.6 0 0 1 128 32a186.67 186.67 0 0 1 113.72 38.1a15.93 15.93 0 0 1 6.17 10.81a15.65 15.65 0 0 1-3.54 11.89"/></g></svg></span>
<h2 class="of__title" id="of-t">You are offline</h2>
<p class="of__sub" id="of-s" data-busy="Checking the connection" data-again="Still offline · {n} tries" aria-live="polite">Could not load the list</p>
<button class="of__btn" type="button" id="of-b">
<span class="of__arrow" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z"/></g></svg></span>
<span class="of__btn-t">Try again</span>
</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-deep;
@include stage($stage-paper);
.of {
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: $subject-ink;
}
.of__icon {
display: block;
font-size: clamp(38px, 12vw, 52px);
line-height: 0;
color: rgba(23, 20, 26, .42);
}
.of__title {
margin: 0;
font-size: clamp(12px, 4vw, 14px);
line-height: 1.3;
font-weight: 800;
}
.of__sub {
margin: 0;
min-height: 14px;
font-size: 11px;
line-height: 14px;
font-weight: 500;
color: rgba(23, 20, 26, .72); // AA 4.5 (blog-design)
}
.of__btn {
height: 30px;
margin: 0;
padding: 0 $sp-4;
display: inline-flex;
align-items: center;
gap: $sp-2;
border: 0;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-family: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.of__btn:disabled { opacity: .64; cursor: default; }
.of__btn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.of__arrow {
display: block;
font-size: 14px;
line-height: 0;
}
.of__btn-t { line-height: 1; }
// ── 자동 루프: 화살표가 한 방향으로만 돈다. 360deg 끝이 0deg 와 같은 그림이라 되감기가 없다 ──
.of.is-demo .of__arrow,
.of.is-trying .of__arrow { animation: of-turn $duration $easing infinite; }
@keyframes of-turn {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.of.is-demo .of__icon { animation: of-dim $duration ease-in-out infinite; }
@keyframes of-dim {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .46; transform: scale(.94); }
}
@media (prefers-reduced-motion: reduce) {
.of.is-demo .of__arrow,
.of.is-trying .of__arrow,
.of.is-demo .of__icon { animation: none !important; }
}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);
}
.of {
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: #17141a;
}
.of__icon {
display: block;
font-size: clamp(38px, 12vw, 52px);
line-height: 0;
color: rgba(23, 20, 26, 0.42);
}
.of__title {
margin: 0;
font-size: clamp(12px, 4vw, 14px);
line-height: 1.3;
font-weight: 800;
}
.of__sub {
margin: 0;
min-height: 14px;
font-size: 11px;
line-height: 14px;
font-weight: 500;
color: rgba(23, 20, 26, 0.72);
}
.of__btn {
height: 30px;
margin: 0;
padding: 0 16px;
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.of__btn:disabled {
opacity: 0.64;
cursor: default;
}
.of__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.of__arrow {
display: block;
font-size: 14px;
line-height: 0;
}
.of__btn-t {
line-height: 1;
}
.of.is-demo .of__arrow,
.of.is-trying .of__arrow {
animation: of-turn 2s linear infinite;
}
@keyframes of-turn {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.of.is-demo .of__icon {
animation: of-dim 2s ease-in-out infinite;
}
@keyframes of-dim {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.46;
transform: scale(0.94);
}
}
@media (prefers-reduced-motion: reduce) {
.of.is-demo .of__arrow,
.of.is-trying .of__arrow,
.of.is-demo .of__icon {
animation: none !important;
}
}// 실제 동작: 다시 시도를 누르면 확인 중으로 바뀌고, 시도 횟수를 세어 다시 오프라인 상태로 돌아온다.
var card = document.querySelector('.of');
var btn = document.getElementById('of-b');
var sub = document.getElementById('of-s');
var tries = 0;
btn.addEventListener('click', function () {
card.classList.remove('is-demo');
if (btn.disabled) return;
btn.disabled = true;
card.classList.add('is-trying');
sub.textContent = sub.dataset.busy;
window.setTimeout(function () {
tries += 1;
card.classList.remove('is-trying');
sub.textContent = sub.dataset.again.replace('{n}', tries);
btn.disabled = false;
}, 900);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./OfflineCard.module.scss";
export type OfflineCardProps = {
title?: string;
idle?: string;
busy?: string;
again?: string;
action?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function OfflineCard({
title = "You are offline",
idle = "Could not load the list",
busy = "Checking the connection",
again = "Still offline · {n} tries",
action = "Try again",
duration = 2000,
easing = "linear",
color = "#0a4fe8",
}: OfflineCardProps) {
const [sub, setSub] = useState(idle);
const [trying, setTrying] = useState(false);
const [loop, setLoop] = useState(true);
const [tries, setTries] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function retry() {
if (trying) return;
setLoop(false);
setTrying(true);
setSub(busy);
window.setTimeout(() => {
const n = tries + 1;
setTries(n);
setTrying(false);
setSub(again.replace("{n}", String(n)));
}, 900);
}
const cls = [styles.of, loop ? styles.isDemo : "", trying ? styles.isTrying : ""].join(" ");
return (
<section className={cls} style={vars}>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 256 256" width="1em" height="1em">
<g fill="currentColor">
<path d="m238.2 87.69l-104 125.43a8 8 0 0 1-12.3 0L17.8 87.69a7.79 7.79 0 0 1 1.31-11.21a180.75 180.75 0 0 1 217.78 0a7.79 7.79 0 0 1 1.31 11.21" opacity=".2" />
<path d="M53.92 34.62a8 8 0 1 0-11.84 10.76l4.55 5A188 188 0 0 0 14.28 70.1a15.93 15.93 0 0 0-6.17 10.81a15.65 15.65 0 0 0 3.54 11.89l104 125.43A15.93 15.93 0 0 0 128 224a15.93 15.93 0 0 0 12.31-5.77l28.1-33.88l33.67 37a8 8 0 1 0 11.84-10.76ZM128 208L24.09 82.74a171.5 171.5 0 0 1 33.82-19.95l99.63 109.6ZM244.35 92.8l-49.42 59.58a8 8 0 0 1-12.32-10.21l49.3-59.43A170.76 170.76 0 0 0 128 48a175 175 0 0 0-32.39 3a8 8 0 1 1-3-15.72A190.6 190.6 0 0 1 128 32a186.67 186.67 0 0 1 113.72 38.1a15.93 15.93 0 0 1 6.17 10.81a15.65 15.65 0 0 1-3.54 11.89" />
</g>
</svg>
</span>
<h2 className={styles.title}>{title}</h2>
<p className={styles.sub} aria-live="polite">{sub}</p>
<button className={styles.btn} type="button" onClick={retry} disabled={trying}>
<span className={styles.arrow} aria-hidden="true">
<svg viewBox="0 0 256 256" width="1em" height="1em">
<g fill="currentColor">
<path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" />
<path d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z" />
</g>
</svg>
</span>
<span className={styles.btnT}>{action}</span>
</button>
</section>
);
}04 Offline card with a turning retry arrow — Measured area 0.574% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Empty State UI Design Cards Drawn in Pure CSS
The post this demo lives in
9 Empty State UI Design Cards Drawn in Pure CSSEmpty state UI design fills the space a list would occupy when there is no data to put in it. One of these nine is blank See all demos in this post
If you like this effect - similar demos
Lists & tables01 Priority sort that holds two columns at once9 Data Table UI Patterns — Drag, Resize, Select
Lists & tables01 Dial-rotating marker — stacking an angle value per item so each bullet turns like a clockCSS List Style: 9 Marker Design Patterns
Lists & tables02 Type icons with indent guides9 File Tree CSS Views — Folders That Really Open