07Session expired — a notice that recomputes badge intensity from elapsed time since expiry
The time elapsed since the session dropped keeps getting recomputed, and the longer that stretch grows, the deeper the notice badge's color and the re-login button's emphasis both turn.
Best forIdle-session notices, auto-logout banners
getComputedStylegetPropertyValueclassList

<main class="stage">
<section class="card" id="card">
<p class="eyebrow">Session notice</p>
<span class="badgeWrap">
<span class="badge" id="badge" role="status">Session expired</span>
</span>
<p class="message">Time since disconnect <span id="elapsedSeconds">0</span>s</p>
<button type="button" class="btn" id="reloginBtn">Log in again</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-orange);
.card {
position: relative;
width: 100%;
max-width: 300px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
padding: $sp-6 $sp-4;
border-radius: $r-card;
background: $stage-paper;
box-shadow: 0 $sp-2 $sp-6 rgba($subject-ink, .18);
text-align: center;
}
.eyebrow {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba($subject-ink, .08);
color: $subject-ink;
font-weight: 600;
font-size: 11px;
letter-spacing: .04em;
}
.badgeWrap {
position: relative;
display: inline-flex;
}
.badgeWrap::before {
content: "";
position: absolute;
inset: -6px;
border-radius: $r-pill;
background: rgba($error, .28);
animation: badgeHalo $duration $easing infinite;
}
.badge {
--paper: #{$stage-paper};
--error: #{$error};
position: relative;
display: inline-flex;
align-items: center;
padding: $sp-1 $sp-4;
border: 1px solid rgba($subject-ink, .16);
border-radius: $r-pill;
background: $stage-paper;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
transition: color .2s $easing;
}
.badge.isHot {
color: $subject-cream;
}
.message {
margin: 0;
color: $subject-ink;
font-weight: 500;
font-size: 14px;
line-height: 1.5;
}
.btn {
border: none;
border-radius: $r-control;
padding: $sp-2 $sp-6;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transform-origin: center;
transition: transform .2s $easing;
}
@keyframes badgeHalo {
0%, 100% { transform: scale(.9); opacity: .4; }
50% { transform: scale(1.18); opacity: .9; }
}
@media (max-width: 340px) {
.card { max-width: 264px; padding: $sp-4 $sp-3; gap: $sp-2; }
.message { font-size: 12px; }
.badge { font-size: 12px; padding: $sp-1 $sp-3; }
.btn { font-size: 12px; padding: $sp-2 $sp-4; }
}
@media (prefers-reduced-motion: reduce) {
.badgeWrap::before { animation: none; opacity: .55; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.card {
position: relative;
width: 100%;
max-width: 300px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 24px 16px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(23, 20, 26, 0.18);
text-align: center;
}
.eyebrow {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.04em;
}
.badgeWrap {
position: relative;
display: inline-flex;
}
.badgeWrap::before {
content: "";
position: absolute;
inset: -6px;
border-radius: 999px;
background: rgba(194, 54, 26, 0.28);
animation: badgeHalo 2s ease-out infinite;
}
.badge {
--paper: #ffffff;
--error: #c2361a;
position: relative;
display: inline-flex;
align-items: center;
padding: 4px 16px;
border: 1px solid rgba(23, 20, 26, 0.16);
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-weight: 700;
font-size: 13px;
transition: color 0.2s ease-out;
}
.badge.isHot {
color: #fff7e6;
}
.message {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 14px;
line-height: 1.5;
}
.btn {
border: none;
border-radius: 12px;
padding: 8px 24px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transform-origin: center;
transition: transform 0.2s ease-out;
}
@keyframes badgeHalo {
0%, 100% {
transform: scale(0.9);
opacity: 0.4;
}
50% {
transform: scale(1.18);
opacity: 0.9;
}
}
@media (max-width: 340px) {
.card {
max-width: 264px;
padding: 16px 12px;
gap: 8px;
}
.message {
font-size: 12px;
}
.badge {
font-size: 12px;
padding: 4px 12px;
}
.btn {
font-size: 12px;
padding: 8px 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.badgeWrap::before {
animation: none;
opacity: 0.55;
}
}// Session expired(07) — 세션이 끊긴 뒤 흐른 시간을 매 프레임 다시 구해(elapsedRatio),
// 그 값을 강조 곡선(INTENSITY_POW)에 태워 배지 배경을 paper 에서 error 로 채널 보간하고
// 재로그인 버튼을 중심에서 살짝 키운다. 실키·실서버 요청 없음 — 전부 클라이언트 시뮬레이션.
(function () {
var INTENSITY_POW = 1; // 변형: 1 → 3
var RAMP_MS = 1800;
var CYCLE_MS = 2000;
var badge = document.getElementById('badge');
var elapsedSeconds = document.getElementById('elapsedSeconds');
var btn = document.getElementById('reloginBtn');
if (!badge || !elapsedSeconds || !btn) return;
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function hexToRgb(hex) {
hex = String(hex).trim().replace('#', '');
return [
parseInt(hex.substring(0, 2), 16),
parseInt(hex.substring(2, 4), 16),
parseInt(hex.substring(4, 6), 16)
];
}
function elapsedRatio(t) { return clamp(t / RAMP_MS, 0, 1); }
var cs = getComputedStyle(badge);
var paperRgb = hexToRgb(cs.getPropertyValue('--paper'));
var errorRgb = hexToRgb(cs.getPropertyValue('--error'));
function apply(t) {
var ratio = elapsedRatio(t);
var intensity = Math.pow(ratio, INTENSITY_POW);
var r = Math.round(paperRgb[0] + (errorRgb[0] - paperRgb[0]) * intensity);
var g = Math.round(paperRgb[1] + (errorRgb[1] - paperRgb[1]) * intensity);
var b = Math.round(paperRgb[2] + (errorRgb[2] - paperRgb[2]) * intensity);
badge.style.backgroundColor = 'rgb(' + r + ',' + g + ',' + b + ')';
badge.classList.toggle('isHot', intensity > 0.5);
btn.style.transform = 'scale(' + (1 + intensity * 0.04) + ')';
elapsedSeconds.textContent = String(Math.round(ratio * (RAMP_MS / 1000)));
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
apply(RAMP_MS);
return;
}
var start = null;
function loop(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();import { useEffect, useRef } from "react";
import styles from "./SessionExpired.module.scss";
// 세션 만료(07) — 세션이 끊긴 뒤 흐른 시간을 매 프레임 다시 구해(elapsedRatio),
// 그 값을 강조 곡선(INTENSITY_POW)에 태워 배지 배경을 paper 에서 error 로 채널 보간하고
// 재로그인 버튼을 중심에서 살짝 키운다. 실키·실서버 요청 없음 — 전부 클라이언트 시뮬레이션.
const INTENSITY_POW = 1; // 변형: 1 → 3
const RAMP_MS = 1800;
const CYCLE_MS = 2000;
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v));
}
function hexToRgb(hex: string): [number, number, number] {
const h = hex.trim().replace("#", "");
return [
parseInt(h.substring(0, 2), 16),
parseInt(h.substring(2, 4), 16),
parseInt(h.substring(4, 6), 16),
];
}
export default function SessionExpired() {
const badgeRef = useRef<HTMLSpanElement>(null);
const elapsedRef = useRef<HTMLSpanElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const badge = badgeRef.current;
const elapsedEl = elapsedRef.current;
const btn = btnRef.current;
if (!badge || !elapsedEl || !btn) return;
const cs = getComputedStyle(badge);
const paperRgb = hexToRgb(cs.getPropertyValue("--paper"));
const errorRgb = hexToRgb(cs.getPropertyValue("--error"));
function apply(t: number) {
const ratio = clamp(t / RAMP_MS, 0, 1);
const intensity = Math.pow(ratio, INTENSITY_POW);
const r = Math.round(paperRgb[0] + (errorRgb[0] - paperRgb[0]) * intensity);
const g = Math.round(paperRgb[1] + (errorRgb[1] - paperRgb[1]) * intensity);
const b = Math.round(paperRgb[2] + (errorRgb[2] - paperRgb[2]) * intensity);
badge!.style.backgroundColor = "rgb(" + r + "," + g + "," + b + ")";
badge!.classList.toggle(styles.isHot, intensity > 0.5);
btn!.style.transform = "scale(" + (1 + intensity * 0.04) + ")";
elapsedEl!.textContent = String(Math.round(ratio * (RAMP_MS / 1000)));
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
apply(RAMP_MS);
return;
}
let start: number | null = null;
let raf = 0;
function loop(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(loop);
}
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
return (
<main className={styles.stage}>
<section className={styles.card}>
<p className={styles.eyebrow}>세션 안내</p>
<span className={styles.badgeWrap}>
<span className={styles.badge} ref={badgeRef} role="status">세션 만료</span>
</span>
<p className={styles.message}>
세션이 끊긴 지 <span ref={elapsedRef}>0</span>초
</p>
<button type="button" className={styles.btn} ref={btnRef}>다시 로그인</button>
</section>
</main>
);
}07 Session expired — a notice that recomputes badge intensity from elapsed time since expiry — Measured area 4.845% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Login UI Design Patterns (Copy-Paste)
The post this demo lives in
9 Login UI Design Patterns (Copy-Paste)Nine login and signup patterns that compute: fill ratio refills a progress ring, remaining time recounts into an arc eve See all demos in this post
If you like this effect - similar demos
Auth UI08 Session-expired re-login modal9 Login Form Design Patterns You Can Copy-Paste
Auth UI02 Warning Badge That Springs Up When Caps Lock Is OnShow Password Toggle Icon: 9 Reveal Patterns
Auth UI05 A Check Where the Pointer Trail Drawn Along a Winding Guide Becomes a Score From Its Mean Distance to That GuideCSS Captcha: 9 Warped Text and Rotate Checks