05アイコン進行リング — 引いた距離の値だけ円形の進行の値が満ちていき、完了するとチェック表示に変わる表示
引いた距離の値が大きくなるほど円形リングの進行の値が円周に沿って満ちていき、進行の値が満杯の状態になるとリングの内側にチェック表示の線の値が描かれて更新完了を示します。
使いどころタスクリストの更新、チャットルームの最新メッセージ読み込み
RcheckLenringOf
<main class="stage">
<div class="prim is-demo" id="prim">
<div class="prim__phone" id="primPhone">
<div class="prim__ringwrap">
<svg class="prim__ring" viewBox="0 0 56 56" width="56" height="56">
<circle class="prim__ring-track" cx="28" cy="28" r="22"></circle>
<circle class="prim__ring-bar" id="primBar" cx="28" cy="28" r="22"></circle>
<polyline class="prim__check" id="primCheck" points="17,29 24,36 40,19"></polyline>
</svg>
</div>
<div class="prim__list">
<div class="prim__row"></div>
<div class="prim__row"></div>
</div>
</div>
<span class="prim__hint" id="primHint">下に引っぱってください</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(진행률→strokeDashoffset 환산)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-ink);
.prim {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.prim__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: $r-card;
background: rgba($subject-cream, .07);
touch-action: none;
cursor: grab;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-3;
}
.prim__ringwrap {
position: relative;
width: 56px;
height: 56px;
}
.prim__ring {
width: 56px;
height: 56px;
}
.prim__ring-track {
fill: none;
stroke: rgba($subject-cream, .18);
stroke-width: 4;
}
.prim__ring-bar {
fill: none;
stroke: $subject-blue-light;
stroke-width: 4;
stroke-linecap: round;
transform: rotate(-90deg);
transform-origin: 28px 28px;
}
.prim__check {
fill: none;
stroke: $subject-mint;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.prim__list {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-2;
width: 100%;
padding: 0 $sp-4;
}
.prim__row {
height: 16px;
border-radius: $r-xs;
background: rgba($subject-cream, .10);
}
.prim__hint {
color: $subject-cream;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prim__phone { width: 176px; height: 140px; }
.prim__ringwrap, .prim__ring { width: 46px; height: 46px; }
}
@media (prefers-reduced-motion: reduce) {
.prim__ring-bar, .prim__check { transition: 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);
}
.prim {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.prim__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 247, 230, 0.07);
touch-action: none;
cursor: grab;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.prim__ringwrap {
position: relative;
width: 56px;
height: 56px;
}
.prim__ring {
width: 56px;
height: 56px;
}
.prim__ring-track {
fill: none;
stroke: rgba(255, 247, 230, 0.18);
stroke-width: 4;
}
.prim__ring-bar {
fill: none;
stroke: #5b8cf8;
stroke-width: 4;
stroke-linecap: round;
transform: rotate(-90deg);
transform-origin: 28px 28px;
}
.prim__check {
fill: none;
stroke: #4cd4a6;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.prim__list {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
padding: 0 16px;
}
.prim__row {
height: 16px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.1);
}
.prim__hint {
color: #fff7e6;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prim__phone {
width: 176px;
height: 140px;
}
.prim__ringwrap, .prim__ring {
width: 46px;
height: 46px;
}
}
@media (prefers-reduced-motion: reduce) {
.prim__ring-bar, .prim__check {
transition: none !important;
}
}(function () {
var phone = document.getElementById('primPhone');
var bar = document.getElementById('primBar');
var check = document.getElementById('primCheck');
var hint = document.getElementById('primHint');
var root = document.getElementById('prim');
// 계산 겹: 引っぱり 거리(raw)를 REF 로 나눈 progress(0~1)를 원 둘레(C)에 곱해
// strokeDashoffset 을 직접 놓는다 — 프레임마다 새 각도를 계산하는 것이지 CSS 트랜지션이 아니다.
var REF = 130, MAX = 150;
var R = 22, C = 2 * Math.PI * R;
bar.style.strokeDasharray = C.toFixed(2);
var checkLen = check.getTotalLength();
check.style.strokeDasharray = checkLen.toFixed(2);
check.style.strokeDashoffset = checkLen.toFixed(2);
function ringOf(progress) { return C * (1 - progress); }
function apply(raw) {
var progress = Math.max(0, Math.min(1, raw / REF));
bar.style.strokeDashoffset = ringOf(progress).toFixed(2);
var pct = Math.round(progress * 100);
hint.textContent = progress >= 1 ? '離すと更新' : (raw > 4 ? ('引っぱり進行 ' + pct + '%') : '下に引っぱってください');
}
var committing = false, commitTimer = null;
function commit() {
committing = true;
hint.textContent = '更新完了';
var start = null, dur = 320;
function step(ts) {
if (start === null) { start = ts; }
var t = Math.min(1, (ts - start) / dur);
check.style.strokeDashoffset = (checkLen * (1 - t)).toFixed(2);
if (t < 1) { requestAnimationFrame(step); }
}
requestAnimationFrame(step);
commitTimer = setTimeout(function () {
check.style.strokeDashoffset = checkLen.toFixed(2);
committing = false;
bar.style.strokeDashoffset = C.toFixed(2);
hint.textContent = '下に引っぱってください';
apply(0);
}, 760);
}
var raw = 0, target = 0, raf = null;
function settle() {
raw += (target - raw) * 0.28;
if (Math.abs(target - raw) < 0.3) { raw = target; }
apply(raw);
if (raw !== target) { raf = requestAnimationFrame(settle); } else { raf = null; }
}
function goTo(v) { target = v; if (raf === null) { raf = requestAnimationFrame(settle); } }
var dragging = false, startY = 0, startRaw = 0;
function onDown(y) { if (committing) { return; } dragging = true; startY = y; startRaw = raw; if (raf) { cancelAnimationFrame(raf); raf = null; } }
function onMove(y) {
if (!dragging) { return; }
raw = Math.max(0, Math.min(MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() {
if (!dragging) { return; }
dragging = false;
if (raw >= REF) { commit(); } else { goTo(0); }
}
function wireReal() {
phone.addEventListener('pointerdown', function (e) { root.classList.remove('is-demo'); stopAutoplay(); onDown(e.clientY); });
window.addEventListener('pointermove', function (e) { onMove(e.clientY); });
window.addEventListener('pointerup', onUp);
}
phone.style.touchAction = 'none';
wireReal();
apply(0);
var autoRaw = 0, autoTimer = null;
function autoTick() {
if (committing) { return; }
autoRaw += 22;
if (autoRaw >= REF) { raw = REF; apply(raw); autoRaw = 0; commit(); return; }
raw = autoRaw; apply(raw);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } if (commitTimer) { clearTimeout(commitTimer); } }
if (!reduce) { autoTimer = setInterval(autoTick, 190); }
})();import { useEffect, useRef } from "react";
import styles from "./PullRefreshIconProgressMorph.module.scss";
export type PullRefreshIconProgressMorphProps = {
color?: string;
};
export default function PullRefreshIconProgressMorph({
color = "#5b8cf8",
}: PullRefreshIconProgressMorphProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const phone = root.querySelector<HTMLDivElement>(`.${styles.phone}`);
const bar = root.querySelector<SVGCircleElement>(`.${styles["ring-bar"]}`);
const check = root.querySelector<SVGPolylineElement>(`.${styles.check}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!phone || !bar || !check || !hint) return;
// 계산 겹: 당김 거리(raw)를 REF 로 나눈 progress(0~1)를 원 둘레(C)에 곱해
// strokeDashoffset 을 직접 놓는다 — 프레임마다 새 각도를 계산하는 것이지 CSS 트랜지션이 아니다.
const REF = 130, MAX = 150;
const R = 22, C = 2 * Math.PI * R;
bar.style.strokeDasharray = C.toFixed(2);
const checkLen = check.getTotalLength();
check.style.strokeDasharray = checkLen.toFixed(2);
check.style.strokeDashoffset = checkLen.toFixed(2);
function ringOf(progress: number) { return C * (1 - progress); }
function apply(raw: number) {
const progress = Math.max(0, Math.min(1, raw / REF));
bar!.style.strokeDashoffset = ringOf(progress).toFixed(2);
const pct = Math.round(progress * 100);
hint!.textContent = progress >= 1 ? "놓으면 새로고침" : (raw > 4 ? ("당김 진행 " + pct + "%") : "아래로 당기세요");
}
let committing = false, commitTimer: number | null = null;
function commit() {
committing = true;
hint!.textContent = "새로고침 완료";
let start: number | null = null;
const dur = 320;
function step(ts: number) {
if (start === null) start = ts;
const t = Math.min(1, (ts - start) / dur);
check!.style.strokeDashoffset = (checkLen * (1 - t)).toFixed(2);
if (t < 1) { requestAnimationFrame(step); }
}
requestAnimationFrame(step);
commitTimer = window.setTimeout(() => {
check!.style.strokeDashoffset = checkLen.toFixed(2);
committing = false;
bar!.style.strokeDashoffset = C.toFixed(2);
hint!.textContent = "아래로 당기세요";
apply(0);
}, 760);
}
let raw = 0, target = 0, raf: number | null = null;
function settle() {
raw += (target - raw) * 0.28;
if (Math.abs(target - raw) < 0.3) raw = target;
apply(raw);
raf = raw !== target ? requestAnimationFrame(settle) : null;
}
function goTo(v: number) { target = v; if (raf === null) { raf = requestAnimationFrame(settle); } }
let dragging = false, startY = 0, startRaw = 0;
function onDown(y: number) {
if (committing) return;
dragging = true; startY = y; startRaw = raw;
if (raf !== null) { cancelAnimationFrame(raf); raf = null; }
}
function onMove(y: number) {
if (!dragging) return;
raw = Math.max(0, Math.min(MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() {
if (!dragging) return;
dragging = false;
if (raw >= REF) { commit(); } else { goTo(0); }
}
function onPointerDown(e: PointerEvent) { stopAutoplay(); onDown(e.clientY); }
function onPointerMove(e: PointerEvent) { onMove(e.clientY); }
phone.style.touchAction = "none";
phone.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onUp);
apply(0);
let autoRaw = 0, autoTimer: number | null = null;
function autoTick() {
if (committing) return;
autoRaw += 22;
if (autoRaw >= REF) { raw = REF; apply(raw); autoRaw = 0; commit(); return; }
raw = autoRaw; apply(raw);
}
function stopAutoplay() {
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; }
if (commitTimer !== null) { window.clearTimeout(commitTimer); }
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 190); }
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
phone.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.prim}>
<div className={styles.phone}>
<div className={styles.ringwrap}>
<svg className={styles.ring} viewBox="0 0 56 56" width="56" height="56">
<circle className={styles["ring-track"]} cx="28" cy="28" r="22" />
<circle className={styles["ring-bar"]} cx="28" cy="28" r="22" style={{ "--accent": color } as any} />
<polyline className={styles.check} points="17,29 24,36 40,19" />
</svg>
</div>
<div className={styles.list}>
<div className={styles.row} />
<div className={styles.row} />
</div>
</div>
<span className={styles.hint}>아래로 당기세요</span>
</div>
</div>
);
}05 アイコン進行リング — 引いた距離の値だけ円形の進行の値が満ちていき、完了するとチェック表示に変わる表示 — 計測 累積面積 1.133% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · 引っ張って更新 スマホ 9選 — 引いた距離で変わる更新のかたち
このデモが属する記事
引っ張って更新 スマホ 9選 — 引いた距離で変わる更新のかたち9つのうち1つは引いた距離の値が大きくなるほど実際に動く幅の値がだんだん狭くなり、1つはその距離の割合の値だけ回転の値も一緒に回り、もう1つは基準を超えた状態で指を離してはじめて確定した状態に変わります。引っ張って更新 スマホの9パターンで この記事のデモをすべて見る


