09끊긴 연결을 세어 가며 다시 붙는 상태 띠
연결이 끊기면 띠가 오프라인으로 바뀌고 재시도까지 남은 초가 steps(1, end) 로 한 칸씩 줄어들며, 바뀐 문구는 role="status" 영역이 읽어 줍니다.
쓰임지하철·엘리베이터에서 연결이 자주 끊기는 모바일 웹앱
role="status"steps(1, end)navigator.onLine

<main class="stage">
<section class="of is-demo" id="of">
<div class="of__bar" role="status" aria-live="polite">
<span class="of__dot" aria-hidden="true"></span>
<span class="of__ic" aria-hidden="true">
<span class="of__ic-on"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><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="M247.89 80.91a15.93 15.93 0 0 0-6.17-10.81A186.67 186.67 0 0 0 128 32A186.67 186.67 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.77l104-125.43a15.65 15.65 0 0 0 3.58-11.89M128 208L24.09 82.74A170.76 170.76 0 0 1 128 48a170.76 170.76 0 0 1 103.91 34.74Z"/></g></svg></span>
<span class="of__ic-off"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><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>
</span>
<span class="of__msg">
<span class="of__m of__m--on">연결됨</span>
<span class="of__m of__m--off">연결이 끊겼어요</span>
<span class="of__m of__m--back">다시 연결됐어요</span>
</span>
<span class="of__side">
<span class="of__pill of__pill--wait">
<span class="of__num" aria-hidden="true">
<span class="of__n" data-sec="3">3</span>
<span class="of__n" data-sec="2">2</span>
<span class="of__n" data-sec="1">1</span>
</span>
<span class="of__unit">초 뒤 다시 연결</span>
</span>
<span class="of__pill of__pill--ok">신호 좋음</span>
</span>
</div>
<div class="of__track" aria-hidden="true">
<span class="of__rail"></span>
<span class="of__fill" id="of-fill"></span>
</div>
<button class="of__btn" type="button" id="of-btn">연결 끊어 보기</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 오프라인·재연결 상태 띠 — 띠는 루프 내내 자리에 남고 안의 문구·색만 한 프레임에 컷으로 바뀐다.
// 컷만으로는 프레임이 모자라므로 남은 시간 막대(scaleX)와 상태 점(translateY)이 사이를 채운다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $error;
@include stage($stage-ink);
$tick: 1s; // 카운트다운 한 칸 = JS setInterval 과 같은 길이. 막대가 그 사이를 고르게 눕는다
$cream-soft: rgba(255, 247, 230, .62);
$cream-line: rgba(255, 247, 230, .18);
$ink-veil: rgba(23, 20, 26, .16);
.of {
box-sizing: border-box;
display: grid;
gap: $sp-2;
width: min(300px, 92vw);
color: $subject-cream;
}
.of * { box-sizing: border-box; }
// ── 상태 띠 ──
.of__bar {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
box-shadow: $shadow-press;
transition: background-color $duration $easing, color $duration $easing;
}
.of.is-off .of__bar { background: $color; color: $subject-cream; }
.of.is-back .of__bar { background: $subject-mint; color: $subject-ink; }
.of__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
// 아이콘 두 장을 같은 칸에 겹쳐 두고 컷으로 갈아 끼운다 — 반투명 겹침이 생기지 않는다
.of__ic {
display: inline-grid;
font-size: 18px;
line-height: 1;
}
.of__ic-on,
.of__ic-off {
grid-area: 1 / 1;
display: inline-flex;
}
.of__ic-off { opacity: 0; }
.of.is-off .of__ic-on { opacity: 0; }
.of.is-off .of__ic-off { opacity: 1; }
.of__msg { display: inline-grid; }
.of__m {
grid-area: 1 / 1;
justify-self: start;
white-space: nowrap;
}
.of__m--off,
.of__m--back { opacity: 0; }
.of.is-off .of__m--on { opacity: 0; }
.of.is-off .of__m--off { opacity: 1; }
.of.is-back .of__m--on { opacity: 0; }
.of.is-back .of__m--back { opacity: 1; }
// 오른쪽 칸: 두 알약을 같은 자리에 겹쳐 두고 컷으로 갈아 끼운다 — 칸 폭이 바뀌지 않는다
.of__side { display: inline-grid; }
.of__pill {
grid-area: 1 / 1;
justify-self: end;
display: inline-flex;
align-items: center;
gap: 0;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $ink-veil;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.of__pill--wait { opacity: 0; }
.of.is-off .of__pill--wait { opacity: 1; }
.of.is-off .of__pill--ok { opacity: 0; }
.of__num { display: inline-grid; }
.of__n {
grid-area: 1 / 1;
justify-self: center;
opacity: 0;
font-size: 13px;
font-weight: 800;
line-height: 1.2;
}
.of__n.is-now { opacity: 1; }
// ── 남은 시간 막대: 칸 하나에 바탕과 채움을 겹친다 (flex 1 1 0 + scaleX 조합을 쓰지 않는다) ──
.of__track {
display: grid;
height: 6px;
border-radius: $r-pill;
overflow: hidden;
}
.of__rail,
.of__fill {
grid-area: 1 / 1;
width: 100%;
height: 100%;
border-radius: $r-pill;
}
.of__rail { background: $cream-line; }
// 채움은 재시도까지 남은 시간이므로 끊긴 동안에만 보인다 — 띠와 레일은 계속 자리에 있다
.of__fill {
background: $color;
transform: scaleX(1);
transform-origin: left center; // 규칙8: 재시도 잔여 막대 좌측 기준
opacity: 0;
transition: transform $tick linear, opacity $duration $easing;
}
.of.is-off .of__fill { opacity: 1; }
.of__btn {
justify-self: start;
margin: 0;
padding: $sp-1 $sp-3;
border: 1px solid $cream-line;
border-radius: $r-pill;
background: transparent;
color: $cream-soft;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
cursor: pointer;
}
.of__btn:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 격자 미리보기 전용 2초 루프 ──
// 연결됨 → 끊김(3·2·1) → 재연결됨 → 연결됨. 띠는 한 번도 사라지지 않는다.
@keyframes of-bar {
0% { background-color: $subject-cream; color: $subject-ink; }
18% { background-color: $color; color: $subject-cream; }
72% { background-color: $subject-mint; color: $subject-ink; }
88%, 100% { background-color: $subject-cream; color: $subject-ink; }
}
@keyframes of-m-on {
0% { opacity: 1; }
18% { opacity: 0; }
88%, 100% { opacity: 1; }
}
@keyframes of-m-off {
0% { opacity: 0; }
18% { opacity: 1; }
72%, 100% { opacity: 0; }
}
@keyframes of-m-back {
0% { opacity: 0; }
72% { opacity: 1; }
88%, 100% { opacity: 0; }
}
@keyframes of-ic-on {
0% { opacity: 1; }
18% { opacity: 0; }
72%, 100% { opacity: 1; }
}
@keyframes of-ic-off {
0% { opacity: 0; }
18% { opacity: 1; }
72%, 100% { opacity: 0; }
}
@keyframes of-pill-wait {
0% { opacity: 0; }
18% { opacity: 1; }
72%, 100% { opacity: 0; }
}
@keyframes of-pill-ok {
0% { opacity: 1; }
18% { opacity: 0; }
72%, 100% { opacity: 1; }
}
@keyframes of-n-3 {
0% { opacity: 0; }
18% { opacity: 1; }
40%, 100% { opacity: 0; }
}
@keyframes of-n-2 {
0% { opacity: 0; }
40% { opacity: 1; }
56%, 100% { opacity: 0; }
}
@keyframes of-n-1 {
0% { opacity: 0; }
56% { opacity: 1; }
72%, 100% { opacity: 0; }
}
// 컷 사이를 채우는 움직임 — 남은 시간이 줄어드는 만큼 막대가 눕는다
@keyframes of-fill {
0%, 17.99% { transform: scaleX(1); opacity: 0; }
18% { transform: scaleX(1); opacity: 1; }
72% { transform: scaleX(0); opacity: 1; }
72.01%, 100% { transform: scaleX(1); opacity: 0; }
}
@keyframes of-dot {
0%, 100% { transform: translateY(0); }
25% { transform: translateY(-3px); }
50% { transform: translateY(0); }
75% { transform: translateY(3px); }
}
.of.is-demo {
.of__bar { animation-name: of-bar; }
.of__m--on { animation-name: of-m-on; }
.of__m--off { animation-name: of-m-off; }
.of__m--back { animation-name: of-m-back; }
.of__ic-on { animation-name: of-ic-on; }
.of__ic-off { animation-name: of-ic-off; }
.of__pill--wait { animation-name: of-pill-wait; }
.of__pill--ok { animation-name: of-pill-ok; }
.of__n[data-sec="3"] { animation-name: of-n-3; }
.of__n[data-sec="2"] { animation-name: of-n-2; }
.of__n[data-sec="1"] { animation-name: of-n-1; }
.of__bar,
.of__m--on,
.of__m--off,
.of__m--back,
.of__ic-on,
.of__ic-off,
.of__pill,
.of__n {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.of__fill {
animation-name: of-fill;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.of__dot {
animation-name: of-dot;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
}
@media (max-width: 340px) {
.of { gap: $sp-1; width: min(292px, 96vw); }
.of__bar { gap: $sp-1; padding: $sp-1 $sp-2; font-size: 10px; }
.of__ic { font-size: 14px; }
.of__dot { width: 6px; height: 6px; }
.of__pill { padding: 0 $sp-1; font-size: 9px; }
.of__n { font-size: 11px; }
.of__btn { padding: 0 $sp-2; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.of.is-demo .of__bar,
.of.is-demo .of__m--on,
.of.is-demo .of__m--off,
.of.is-demo .of__m--back,
.of.is-demo .of__ic-on,
.of.is-demo .of__ic-off,
.of.is-demo .of__pill,
.of.is-demo .of__n,
.of.is-demo .of__fill,
.of.is-demo .of__dot { animation: none !important; transition: none !important; }
// 움직임만 끄고 끊긴 상태의 결과는 읽히도록 남긴다
.of.is-demo .of__bar { background-color: $color !important; color: $subject-cream !important; }
.of.is-demo .of__m--on { opacity: 0 !important; }
.of.is-demo .of__m--off { opacity: 1 !important; }
.of.is-demo .of__ic-on { opacity: 0 !important; }
.of.is-demo .of__ic-off { opacity: 1 !important; }
.of.is-demo .of__pill--wait { opacity: 1 !important; }
.of.is-demo .of__pill--ok { opacity: 0 !important; }
.of.is-demo .of__n[data-sec="3"] { opacity: 1 !important; }
.of.is-demo .of__fill { transform: scaleX(1) !important; opacity: 1 !important; }
.of.is-demo .of__dot { transform: none !important; }
}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);
}
.of {
box-sizing: border-box;
display: grid;
gap: 8px;
width: min(300px, 92vw);
color: #fff7e6;
}
.of * {
box-sizing: border-box;
}
.of__bar {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.of.is-off .of__bar {
background: #c2361a;
color: #fff7e6;
}
.of.is-back .of__bar {
background: #4cd4a6;
color: #17141a;
}
.of__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.of__ic {
display: inline-grid;
font-size: 18px;
line-height: 1;
}
.of__ic-on,
.of__ic-off {
grid-area: 1/1;
display: inline-flex;
}
.of__ic-off {
opacity: 0;
}
.of.is-off .of__ic-on {
opacity: 0;
}
.of.is-off .of__ic-off {
opacity: 1;
}
.of__msg {
display: inline-grid;
}
.of__m {
grid-area: 1/1;
justify-self: start;
white-space: nowrap;
}
.of__m--off,
.of__m--back {
opacity: 0;
}
.of.is-off .of__m--on {
opacity: 0;
}
.of.is-off .of__m--off {
opacity: 1;
}
.of.is-back .of__m--on {
opacity: 0;
}
.of.is-back .of__m--back {
opacity: 1;
}
.of__side {
display: inline-grid;
}
.of__pill {
grid-area: 1/1;
justify-self: end;
display: inline-flex;
align-items: center;
gap: 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.16);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.of__pill--wait {
opacity: 0;
}
.of.is-off .of__pill--wait {
opacity: 1;
}
.of.is-off .of__pill--ok {
opacity: 0;
}
.of__num {
display: inline-grid;
}
.of__n {
grid-area: 1/1;
justify-self: center;
opacity: 0;
font-size: 13px;
font-weight: 800;
line-height: 1.2;
}
.of__n.is-now {
opacity: 1;
}
.of__track {
display: grid;
height: 6px;
border-radius: 999px;
overflow: hidden;
}
.of__rail,
.of__fill {
grid-area: 1/1;
width: 100%;
height: 100%;
border-radius: 999px;
}
.of__rail {
background: rgba(255, 247, 230, 0.18);
}
.of__fill {
background: #c2361a;
transform: scaleX(1);
transform-origin: left center;
opacity: 0;
transition: transform 1s linear, opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.of.is-off .of__fill {
opacity: 1;
}
.of__btn {
justify-self: start;
margin: 0;
padding: 4px 12px;
border: 1px solid rgba(255, 247, 230, 0.18);
border-radius: 999px;
background: transparent;
color: rgba(255, 247, 230, 0.62);
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
cursor: pointer;
}
.of__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
@keyframes of-bar {
0% {
background-color: #fff7e6;
color: #17141a;
}
18% {
background-color: #c2361a;
color: #fff7e6;
}
72% {
background-color: #4cd4a6;
color: #17141a;
}
88%, 100% {
background-color: #fff7e6;
color: #17141a;
}
}
@keyframes of-m-on {
0% {
opacity: 1;
}
18% {
opacity: 0;
}
88%, 100% {
opacity: 1;
}
}
@keyframes of-m-off {
0% {
opacity: 0;
}
18% {
opacity: 1;
}
72%, 100% {
opacity: 0;
}
}
@keyframes of-m-back {
0% {
opacity: 0;
}
72% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@keyframes of-ic-on {
0% {
opacity: 1;
}
18% {
opacity: 0;
}
72%, 100% {
opacity: 1;
}
}
@keyframes of-ic-off {
0% {
opacity: 0;
}
18% {
opacity: 1;
}
72%, 100% {
opacity: 0;
}
}
@keyframes of-pill-wait {
0% {
opacity: 0;
}
18% {
opacity: 1;
}
72%, 100% {
opacity: 0;
}
}
@keyframes of-pill-ok {
0% {
opacity: 1;
}
18% {
opacity: 0;
}
72%, 100% {
opacity: 1;
}
}
@keyframes of-n-3 {
0% {
opacity: 0;
}
18% {
opacity: 1;
}
40%, 100% {
opacity: 0;
}
}
@keyframes of-n-2 {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
56%, 100% {
opacity: 0;
}
}
@keyframes of-n-1 {
0% {
opacity: 0;
}
56% {
opacity: 1;
}
72%, 100% {
opacity: 0;
}
}
@keyframes of-fill {
0%, 17.99% {
transform: scaleX(1);
opacity: 0;
}
18% {
transform: scaleX(1);
opacity: 1;
}
72% {
transform: scaleX(0);
opacity: 1;
}
72.01%, 100% {
transform: scaleX(1);
opacity: 0;
}
}
@keyframes of-dot {
0%, 100% {
transform: translateY(0);
}
25% {
transform: translateY(-3px);
}
50% {
transform: translateY(0);
}
75% {
transform: translateY(3px);
}
}
.of.is-demo .of__bar {
animation-name: of-bar;
}
.of.is-demo .of__m--on {
animation-name: of-m-on;
}
.of.is-demo .of__m--off {
animation-name: of-m-off;
}
.of.is-demo .of__m--back {
animation-name: of-m-back;
}
.of.is-demo .of__ic-on {
animation-name: of-ic-on;
}
.of.is-demo .of__ic-off {
animation-name: of-ic-off;
}
.of.is-demo .of__pill--wait {
animation-name: of-pill-wait;
}
.of.is-demo .of__pill--ok {
animation-name: of-pill-ok;
}
.of.is-demo .of__n[data-sec="3"] {
animation-name: of-n-3;
}
.of.is-demo .of__n[data-sec="2"] {
animation-name: of-n-2;
}
.of.is-demo .of__n[data-sec="1"] {
animation-name: of-n-1;
}
.of.is-demo .of__bar,
.of.is-demo .of__m--on,
.of.is-demo .of__m--off,
.of.is-demo .of__m--back,
.of.is-demo .of__ic-on,
.of.is-demo .of__ic-off,
.of.is-demo .of__pill,
.of.is-demo .of__n {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.of.is-demo .of__fill {
animation-name: of-fill;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.of.is-demo .of__dot {
animation-name: of-dot;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@media (max-width: 340px) {
.of {
gap: 4px;
width: min(292px, 96vw);
}
.of__bar {
gap: 4px;
padding: 4px 8px;
font-size: 10px;
}
.of__ic {
font-size: 14px;
}
.of__dot {
width: 6px;
height: 6px;
}
.of__pill {
padding: 0 4px;
font-size: 9px;
}
.of__n {
font-size: 11px;
}
.of__btn {
padding: 0 8px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.of.is-demo .of__bar,
.of.is-demo .of__m--on,
.of.is-demo .of__m--off,
.of.is-demo .of__m--back,
.of.is-demo .of__ic-on,
.of.is-demo .of__ic-off,
.of.is-demo .of__pill,
.of.is-demo .of__n,
.of.is-demo .of__fill,
.of.is-demo .of__dot {
animation: none !important;
transition: none !important;
}
.of.is-demo .of__bar {
background-color: #c2361a !important;
color: #fff7e6 !important;
}
.of.is-demo .of__m--on {
opacity: 0 !important;
}
.of.is-demo .of__m--off {
opacity: 1 !important;
}
.of.is-demo .of__ic-on {
opacity: 0 !important;
}
.of.is-demo .of__ic-off {
opacity: 1 !important;
}
.of.is-demo .of__pill--wait {
opacity: 1 !important;
}
.of.is-demo .of__pill--ok {
opacity: 0 !important;
}
.of.is-demo .of__n[data-sec="3"] {
opacity: 1 !important;
}
.of.is-demo .of__fill {
transform: scaleX(1) !important;
opacity: 1 !important;
}
.of.is-demo .of__dot {
transform: none !important;
}
}// 상태 띠는 사라지지 않는다 — 안의 문구와 색만 바뀐다. 진짜 연결 상태는 navigator.onLine 이 알려 준다.
var of = document.getElementById('of');
var ofBtn = document.getElementById('of-btn');
var ofFill = document.getElementById('of-fill');
var ofNums = [].slice.call(of.querySelectorAll('.of__n'));
var ofTimer = 0;
function ofSec(n) {
ofNums.forEach(function (s) { s.classList.toggle('is-now', Number(s.dataset.sec) === n); });
ofFill.style.transform = 'scaleX(' + (n / 3) + ')';
}
function ofState(s) {
of.classList.remove('is-demo');
of.classList.toggle('is-off', s === 'off');
of.classList.toggle('is-back', s === 'back');
ofBtn.textContent = s === 'off' ? '연결 되돌리기' : '연결 끊어 보기';
}
function ofDrop() {
ofState('off');
var n = 3;
ofSec(n);
clearInterval(ofTimer);
ofTimer = setInterval(function () {
n -= 1;
if (n > 0) { ofSec(n); return; }
clearInterval(ofTimer);
ofSec(0);
ofState('back');
}, 1000);
}
function ofUp() {
clearInterval(ofTimer);
ofSec(0);
ofState('back');
}
ofBtn.addEventListener('click', function () {
if (of.classList.contains('is-off')) ofUp(); else ofDrop();
});
if (!navigator.onLine) ofDrop();
window.addEventListener('offline', ofDrop);
window.addEventListener('online', ofUp);import { useState, useEffect } from "react";
import type { CSSProperties } from "react";
import styles from "./OfflineRetryBanner.module.scss";
export type OfflineRetryBannerProps = {
onlineText?: string;
offlineText?: string;
backText?: string;
/** 온라인일 때 오른쪽 알약에 들어가는 짧은 라벨 */
okLabel?: string;
/** 카운트다운 숫자 뒤에 붙는 말 */
unitLabel?: string;
dropLabel?: string;
restoreLabel?: string;
/** 재시도까지 세는 초 */
seconds?: number;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Status = "on" | "off" | "back";
// ambient 가 얇다 — 브라우저 전역 중 쓰는 것만 로컬 타입으로 좁혀 받는다
type Host = {
setInterval(fn: () => void, ms: number): number;
clearInterval(id: number): void;
addEventListener(type: string, fn: () => void): void;
removeEventListener(type: string, fn: () => void): void;
navigator: { onLine: boolean };
};
const host = globalThis as unknown as Host;
function WifiOnIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><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="M247.89 80.91a15.93 15.93 0 0 0-6.17-10.81A186.67 186.67 0 0 0 128 32A186.67 186.67 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.77l104-125.43a15.65 15.65 0 0 0 3.58-11.89M128 208L24.09 82.74A170.76 170.76 0 0 1 128 48a170.76 170.76 0 0 1 103.91 34.74Z"/></g></svg>
);
}
function WifiOffIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><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>
);
}
export default function OfflineRetryBanner({
onlineText = "Connected",
offlineText = "You are offline",
backText = "Back online",
okLabel = "Good signal",
unitLabel = "s until retry",
dropLabel = "Drop the connection",
restoreLabel = "Bring it back",
seconds = 3,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#c2361a",
}: OfflineRetryBannerProps) {
const [live, setLive] = useState(demo);
const [status, setStatus] = useState<Status>("on");
const [left, setLeft] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function drop() {
setLive(false);
setLeft(seconds);
setStatus("off");
}
function restore() {
setLive(false);
setLeft(0);
setStatus("back");
}
// 끊긴 동안에만 1초마다 한 칸씩 센다
useEffect(() => {
if (status !== "off") return;
const id = host.setInterval(() => setLeft((p: number) => (p > 0 ? p - 1 : 0)), 1000);
return () => host.clearInterval(id);
}, [status]);
useEffect(() => {
if (status === "off" && left <= 0) setStatus("back");
}, [status, left]);
// 진짜 연결 상태도 읽는다
useEffect(() => {
const goOff = () => drop();
const goOn = () => restore();
host.addEventListener("offline", goOff);
host.addEventListener("online", goOn);
if (host.navigator && host.navigator.onLine === false) goOff();
return () => {
host.removeEventListener("offline", goOff);
host.removeEventListener("online", goOn);
};
}, [seconds]);
const cls = [
styles.of,
live ? styles.isDemo : "",
status === "off" ? styles.isOff : "",
status === "back" ? styles.isBack : "",
]
.filter(Boolean)
.join(" ");
const ratio = seconds > 0 ? left / seconds : 0;
// 세 문구·두 알약을 같은 자리에 겹쳐 두고 컷으로 갈아 끼운다 — 반투명 겹침도 칸 폭 변화도 없다
const ticks: number[] = [];
for (let n = seconds; n > 0; n -= 1) ticks.push(n);
return (
<section className={cls} style={vars}>
{/* 띠는 사라지지 않는다 — 안의 문구와 색만 바뀐다 */}
<div className={styles.bar} role="status" aria-live="polite">
<span className={styles.dot} aria-hidden="true" />
<span className={styles.ic} aria-hidden="true">
<span className={styles.icOn}><WifiOnIcon /></span>
<span className={styles.icOff}><WifiOffIcon /></span>
</span>
<span className={styles.msg}>
<span className={[styles.m, styles.mOn].join(" ")}>{onlineText}</span>
<span className={[styles.m, styles.mOff].join(" ")}>{offlineText}</span>
<span className={[styles.m, styles.mBack].join(" ")}>{backText}</span>
</span>
<span className={styles.side}>
<span className={[styles.pill, styles.pillWait].join(" ")}>
<span className={styles.num} aria-hidden="true">
{ticks.map((n) => (
<span
key={n}
className={[styles.n, n === left ? styles.isNow : ""].filter(Boolean).join(" ")}
data-sec={n}
>
{n}
</span>
))}
</span>
<span className={styles.unit}>{unitLabel}</span>
</span>
<span className={[styles.pill, styles.pillOk].join(" ")}>{okLabel}</span>
</span>
</div>
<div className={styles.track} aria-hidden="true">
<span className={styles.rail} />
<span className={styles.fill} style={{ transform: `scaleX(${status === "off" ? ratio : 1})` }} />
</div>
<button className={styles.btn} type="button" onClick={status === "off" ? restore : drop}>
{status === "off" ? restoreLabel : dropLabel}
</button>
</section>
);
}09 끊긴 연결을 세어 가며 다시 붙는 상태 띠 — 측정 누적면적 9.118% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 페이지 로딩 애니메이션 9종 — 화면이 바뀌는 동안
이 데모가 속한 글
페이지 로딩 애니메이션 9종 — 화면이 바뀌는 동안페이지 로딩 애니메이션은 화면이 다음 화면으로 바뀌는 짧은 동안 기다림을 눈에 보이게 만드는 장치입니다. 여기 아홉 개는 한가운데서 빙글 도는 스피너가 아니라 진행 바·색 띠·자리표시·낙관적 이동처럼 화면을 통째로 이 글의 데모 전부 보기


