04다시 시도 화살표가 도는 오프라인
끊긴 와이파이 아이콘 아래 '다시 시도' 버튼의 원형 화살표가 한 방향으로만 계속 돌아, 멈췄다 튀는 지점이 없습니다.
쓰임목록이 빈 이유가 데이터 없음이 아니라 연결 실패일 때, 재시도 버튼을 같이 줘야 하는 화면
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">연결이 끊겼습니다</h2>
<p class="of__sub" id="of-s" data-busy="연결을 확인하는 중" data-again="아직 연결되지 않았습니다 · 시도 {n}회" aria-live="polite">목록을 불러오지 못했습니다</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">다시 시도</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 다시 시도 화살표가 도는 오프라인 — 측정 누적면적 0.574% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 빈 화면 디자인 9종 — CSS로 그리는 빈 상태 카드
이 데모가 속한 글
빈 화면 디자인 9종 — CSS로 그리는 빈 상태 카드빈 화면 디자인은 목록에 표시할 데이터가 하나도 없을 때 그 빈 자리를 대신 채우는 화면입니다. 여기 아홉 개 중 하나는 데이터가 없어서가 아니라 볼 권한이 없어서 비어 있고, 그 차이를 자물쇠 하나로 구분해 말합니 이 글의 데모 전부 보기

