07セッション期限切れ — 切れてから経過した時間で強調濃度を求め直す通知
セッションが切れた時点から経過した時間を求め直し、その経過が長くなるほど通知バッジの色の濃さと再ログインボタンの強調度が一緒に濃くなるセッション期限切れ通知です。
使いどころ長時間未使用セッションの通知、自動ログアウトバナー
getComputedStylegetPropertyValueclassList

<main class="stage">
<section class="card" id="card">
<p class="eyebrow">セッションのお知らせ</p>
<span class="badgeWrap">
<span class="badge" id="badge" role="status">セッション切れ</span>
</span>
<p class="message">セッションが切れてから <span id="elapsedSeconds">0</span>秒</p>
<button type="button" class="btn" id="reloginBtn">再ログイン</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;
}
}// セッション切れ(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 セッション期限切れ — 切れてから経過した時間で強調濃度を求め直す通知 — 計測 累積面積 4.845% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · ログイン 画面 デザイン9選 — 会員登録からセッション切れまで
このデモが属する記事
ログイン 画面 デザイン9選 — 会員登録からセッション切れまで「ログイン 画面 デザイン」を探すと出てくる多くの参考画像は、色や余白を真似るだけの静止した見本です。ここで扱う9つの画面はその手前に計算の層を1枚重ねます。入力の完成度で進捗表示が求め直され、残り有効時間が毎秒求め直されてリングの角度にな この記事のデモをすべて見る


