04マジックリンク — 残り有効時間を求め直しリングの弧の長さで示す送信完了カード
送信後の残り有効時間を毎秒求め直し、全体の有効時間と比べた比率でドーナツ型リングの弧の長さを描き直すマジックリンク送信完了カードです。
使いどころパスワードレスログイン案内、マジックリンク送信確認画面
stroke-dasharraystrokeDashoffsetrequestAnimationFrame

<main class="stage">
<section class="card">
<p class="eyebrow">マジックリンク送信完了</p>
<p class="recipient">[email protected] に送信済み</p>
<div class="ringWrap">
<svg class="ringSvg" viewBox="0 0 96 96" aria-hidden="true">
<circle class="ringTrack" cx="48" cy="48" r="44"></circle>
<circle class="ringProgress" id="magicRing" cx="48" cy="48" r="44" stroke-dasharray="276.46" stroke-dashoffset="0"></circle>
</svg>
<p class="time" id="magicTime">残り時間 300秒</p>
</div>
<p class="hint">期限切れの場合は再度リクエストしてください</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.card {
position: relative;
width: 100%;
max-width: 320px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
text-align: center;
padding: $sp-6;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
}
.eyebrow {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba($color, .12);
color: $color;
font-weight: 600;
font-size: 11px;
letter-spacing: .04em;
}
.recipient {
margin: 0;
color: $subject-ink;
font-weight: 400;
font-size: 13px;
opacity: .78;
}
.ringWrap {
position: relative;
width: 96px;
height: 96px;
display: grid;
place-items: center;
}
.ringSvg {
width: 96px;
height: 96px;
// 12시 방향에서 시작하도록 회전한다 — scale 이 아니라 rotate 라 transform-origin 기본(center)으로 충분
transform: rotate(-90deg);
}
.ringTrack {
fill: none;
stroke: rgba($subject-ink, .12);
stroke-width: 8px;
}
.ringProgress {
fill: none;
stroke: $color;
stroke-width: 8px;
stroke-linecap: round;
}
.time {
position: absolute;
inset: 0;
display: grid;
place-items: center;
margin: 0;
color: $subject-ink;
font-weight: 700;
font-size: 18px;
line-height: 1.2;
}
.hint {
margin: 0;
color: $subject-ink;
opacity: .72;
font-weight: 400;
font-size: 12px;
line-height: 1.4;
}
@media (max-width: 340px) {
.card { max-width: 240px; padding: $sp-3; gap: $sp-1; }
.eyebrow { font-size: 10px; padding: $sp-1 $sp-2; }
.recipient { font-size: 11px; }
.ringWrap, .ringSvg { width: 64px; height: 64px; }
.ringTrack, .ringProgress { stroke-width: 6px; }
.time { font-size: 12px; }
.hint { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ringProgress { transition: none; }
}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);
}
.card {
position: relative;
width: 100%;
max-width: 320px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
padding: 24px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.eyebrow {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(10, 79, 232, 0.12);
color: #0a4fe8;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.04em;
}
.recipient {
margin: 0;
color: #17141a;
font-weight: 400;
font-size: 13px;
opacity: 0.78;
}
.ringWrap {
position: relative;
width: 96px;
height: 96px;
display: grid;
place-items: center;
}
.ringSvg {
width: 96px;
height: 96px;
transform: rotate(-90deg);
}
.ringTrack {
fill: none;
stroke: rgba(23, 20, 26, 0.12);
stroke-width: 8px;
}
.ringProgress {
fill: none;
stroke: #0a4fe8;
stroke-width: 8px;
stroke-linecap: round;
}
.time {
position: absolute;
inset: 0;
display: grid;
place-items: center;
margin: 0;
color: #17141a;
font-weight: 700;
font-size: 18px;
line-height: 1.2;
}
.hint {
margin: 0;
color: #17141a;
opacity: 0.72;
font-weight: 400;
font-size: 12px;
line-height: 1.4;
}
@media (max-width: 340px) {
.card {
max-width: 240px;
padding: 12px;
gap: 4px;
}
.eyebrow {
font-size: 10px;
padding: 4px 8px;
}
.recipient {
font-size: 11px;
}
.ringWrap, .ringSvg {
width: 64px;
height: 64px;
}
.ringTrack, .ringProgress {
stroke-width: 6px;
}
.time {
font-size: 12px;
}
.hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ringProgress {
transition: none;
}
}// 매직 링크(04) — 발송 시각으로부터 남은 유효시간을 매 프레임 새로 구해(누적하지 않음)
// 그 비율만큼 SVG 링의 stroke-dashoffset 을 다시 그린다. CSS keyframes 없이 rAF 로만 진행해
// 캡처 창(2秒) 전체가 실제 경과 시간대로 기록되게 한다.
(function () {
var ring = document.getElementById('magicRing');
var timeText = document.getElementById('magicTime');
if (!ring || !timeText) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var VALID_SEC = 300; // 변형: 300 → 900
var CIRCUMFERENCE = 276.46;
var CYCLE_MS = 2000;
function ratio(t) {
return 1 - Math.min(Math.max(t / CYCLE_MS, 0), 1);
}
function apply(t) {
var r = ratio(t);
var remainingDisplaySec = Math.ceil(r * VALID_SEC);
ring.style.strokeDashoffset = String(CIRCUMFERENCE * (1 - r));
timeText.textContent = '残り時間' + ' ' + remainingDisplaySec + '秒';
}
var start = null;
function frame(ts) {
if (start === null) start = ts;
var t = (ts - start) % CYCLE_MS;
apply(t);
requestAnimationFrame(frame);
}
if (reduce) {
apply(0);
} else {
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from "react";
import styles from "./MagicLink.module.scss";
// 매직 링크(04) — 발송 시각으로부터 남은 유효시간을 매 프레임 새로 구해(누적하지 않음)
// 그 비율만큼 SVG 링의 stroke-dashoffset 을 다시 그린다. CSS keyframes 없이 rAF 로만 진행한다.
const VALID_SEC = 300; // 변형: 300 → 900
const CIRCUMFERENCE = 276.46;
const CYCLE_MS = 2000;
function ratio(t: number): number {
return 1 - Math.min(Math.max(t / CYCLE_MS, 0), 1);
}
export default function MagicLink() {
const ringRef = useRef<SVGCircleElement>(null);
const timeRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const ring = ringRef.current;
const timeText = timeRef.current;
if (!ring || !timeText) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function apply(t: number) {
const r = ratio(t);
const remainingDisplaySec = Math.ceil(r * VALID_SEC);
if (ring) ring.style.strokeDashoffset = String(CIRCUMFERENCE * (1 - r));
if (timeText) timeText.textContent = "남은 시간" + " " + remainingDisplaySec + "초";
}
let start: number | null = null;
let raf = 0;
function frame(ts: number) {
if (start === null) start = ts;
const t = (ts - start) % CYCLE_MS;
apply(t);
raf = requestAnimationFrame(frame);
}
if (reduce) {
apply(0);
} else {
raf = requestAnimationFrame(frame);
}
return () => {
if (raf) cancelAnimationFrame(raf);
};
}, []);
return (
<main className={styles.stage}>
<section className={styles.card}>
<p className={styles.eyebrow}>매직 링크 발송 완료</p>
<p className={styles.recipient}>[email protected] 으로 전송됨</p>
<div className={styles.ringWrap}>
<svg className={styles.ringSvg} viewBox="0 0 96 96" aria-hidden="true">
<circle className={styles.ringTrack} cx={48} cy={48} r={44} />
<circle
ref={ringRef}
className={styles.ringProgress}
cx={48}
cy={48}
r={44}
strokeDasharray={276.46}
strokeDashoffset={0}
/>
</svg>
<p className={styles.time} ref={timeRef}>
남은 시간 300초
</p>
</div>
<p className={styles.hint}>만료되면 다시 요청해 주세요</p>
</section>
</main>
);
}04 マジックリンク — 残り有効時間を求め直しリングの弧の長さで示す送信完了カード — 計測 累積面積 2.65% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · ログイン 画面 デザイン9選 — 会員登録からセッション切れまで
このデモが属する記事
ログイン 画面 デザイン9選 — 会員登録からセッション切れまで「ログイン 画面 デザイン」を探すと出てくる多くの参考画像は、色や余白を真似るだけの静止した見本です。ここで扱う9つの画面はその手前に計算の層を1枚重ねます。入力の完成度で進捗表示が求め直され、残り有効時間が毎秒求め直されてリングの角度にな この記事のデモをすべて見る


