GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

당겨서 새로고침 9종 — 당긴 거리 값이 새로고침을 만드는 방식

당겨서 새로고침은 리스트를 위로 당겼다가 놓는 손짓에 거리 값을 계산해 새로고침을 트리거하는 동작인데, 아홉 개 중 하나는 당긴 거리 값이 커질수록 실제로 밀리는 폭 값이 점점 좁아지고, 하나는 그 거리의 비율 값만큼 회전 값이 함께 돌며, 또 하나는 기준을 넘은 채로 손을 뗀 상태가

아홉 화면 모두 실제 fetch 요청이나 navigator.onLine 없이 값 계산 시뮬레이션이고, 새로고침 뒤 나오는 목록·메시지는 전부 로컬 setTimeout 으로 만든 가상 데이터입니다. 풀 투 리프레시 아홉 개 전부 리스너가 pointerdown·pointermove·pointerup 으로 같아 자동으로만 재생되고 마는 항목은 하나도 없이, 화면을 직접 눌러 당기는 순간 자동 재생이 멈추고 실제 손 입력 값으로 같은 함수가 다시 계산합니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

모바일 웹 당겨서 새로고침을 직접 만들 때는 당긴 거리를 어디서 꺾고 어디서 확정 짓는지가 손맛을 가릅니다 — 아래 표의 누적면적 값은 05 를 빼면 대체로 두 자리인데, 05 처럼 작은 링 하나만 그리는 항목은 면적이 아니라 진행 값이 채워지는 계산 한 겹의 정교함으로 재는 것이 맞습니다. 아홉 개는 당기는 동안 값이 계속 재계산되는 것(01·02·05·06) → 손을 뗀 순간의 기준으로 확정 여부가 갈리는 것(03·04) → 손을 뗀 뒤 시간에 따라 값이 스스로 바뀌는 것(07·08·09) 순으로 묶었습니다.

아홉 개의 해부 — 어떤 값이 언제 바뀌는가

실측 한눈에

항목 초점 누적면적% 강도 평균 움직인 프레임
01 탄성 당김 저항 곡선 값 → resist() 재계산 15.39 30.6 14/23
02 회전 연동 비율 값 → knob 각도 재계산 14.13 125.9 20/23
03 기준 확정 히스테리시스 값 → candidate 재계산 13.56 35.0 21/23
04 고무줄 한계 점근 곡선 값 → displayedOf() 재계산 9.80 50.2 15/23
05 아이콘 진행 링 진행 값 → stroke-dashoffset 재계산 1.13 133.0 13/23
06 래칫 눈금 눈금 통과 값 → burst 재계산 8.08 74.2 22/23
07 스프링 안착 가속도 값 → pos·vel 재계산 7.86 76.8 17/23
08 행 순차 등장 순서 값 → setTimeout 지연 재계산 8.64 46.2 15/23
09 실패 흔들림 시도 횟수 값 → 흔들림 재계산 2.94 55.5 17/23

01 이 15.39%로 아홉 중 누적면적이 가장 커 그림자처럼 목록 전체가 밀려나는 폭이 크다는 뜻이고, 05 는 1.13%로 가장 작아 원형 링 하나만 채워진다는 뜻입니다. 강도 평균은 05 가 133.0으로 가장 높아 진행 링 바탕과 채움 사이 픽셀 값 차이가 뚜렷하다는 뜻이고, 01 은 30.6으로 가장 낮아 저항 곡선을 따라 완만하게 밀린다는 뜻입니다. 움직인 프레임은 06 이 22/23으로 가장 높아 눈금을 지날 때마다 거의 매 프레임 값이 갱신된다는 뜻이고, 05 는 13/23으로 가장 낮아 진행 값이 짧게 채워지고 멈춘다는 뜻입니다.

당기는 동안 값이 계속 재계산되는 것 — 01·02·05·06

01 은 resist(raw)가 D_MAX * raw / (raw + K)(D_MAX 56·K 80)로 당긴 원 거리를 밀림 거리로 바꿔, raw 가 커질수록 분모가 함께 커져 밀림 값이 늘어나는 폭이 점점 좁아지는 포화 곡선을 매 프레임 그립니다. 02 는 당긴 거리를 기준 거리(REF 120)로 나눈 비율(ratio)만큼 knob을 궤도(SWEEP 270도)로 돌리다가, 비율이 1 이 되면 spinFrame()이 거리 연동을 놓고 각속도(SPIN_SPEED 0.62)로 계속 도는 로딩 상태로 넘어갑니다. 05 는 당긴 거리를 REF 로 나눈 진행 값(progress)만큼 ringOf()가 원둘레(C)를 따라 stroke-dashoffset을 채우고, 06 은 당긴 거리가 눈금 간격(TICK 22)의 배수를 새로 지날 때마다 triggerBurst()가 감쇠 진동(AMP 10·TAU 0.15·W 26)을 위치 값에 얹어 손가락이 칸을 지날 때마다 짧게 튀는 래칫감을 만듭니다.

손을 뗀 순간의 기준으로 확정 여부가 갈리는 것 — 03·04

03 은 setCandidate()가 raw 하나의 부등호가 아니라 두 문턱의 히스테리시스로 확정 후보를 정합니다 — ON(100px)을 넘어야 candidate 가 true 로 바뀌고, 다시 OFF(70px) 아래로 내려가야만 false 로 돌아와 경계 근처에서 값이 깜빡이지 않습니다. 04 는 displayedOf()가 LINEAR(70px)까지는 raw 에 0.9배로 거의 그대로 따라가다가, 그 뒤로는 지수 감쇠(1-e^(-x/K), K 42)로 MAX_D(120px)에 점근해 같은 힘이라도 한계 근처에선 늘어나는 폭 값이 급격히 줄어듭니다.

손을 뗀 뒤 시간에 따라 값이 스스로 바뀌는 것 — 07·08·09

07 은 손을 뗀 뒤 integrating이 true 가 되면 매 프레임 accel = -K * (pos - target) - C * vel(K 170·C 9)로 스프링 힘과 감쇠를 더해 위치 값이 목표를 살짝 지나쳤다가 제자리에 앉는 값을 계산합니다. 08 은 새로고침이 끝난 상태가 되면 refreshCycle()이 행마다 idx * STAGGER_MS(90ms)만큼 지연된 setTimeout을 따로 걸어 순서 값이 큰 행일수록 나중에 나타나게 하고, 09 는 클릭이 아니라 당김이 확정될 때마다 attempt()가 attemptCount의 홀짝(attemptCount % 2)으로 성공·실패를 결정적으로 번갈아 정하고 실패 시에만 감쇠 진동(AMP 10·TAU 0.12·FREQ 46)으로 흔들림 값을 계산합니다.

패턴별 코드

01탄성 당김 — 당긴 거리 값이 늘어날수록 저항이 커지는 새로고침 트리거

손가락으로 당긴 원 거리 값이 커질수록 실제로 밀리는 거리 값은 점점 더 적게 늘어나고, 손을 뗀 상태가 되면 그 거리 값이 다시 0으로 돌아옵니다. 모바일 웹 피드 새로고침, 리스트 최상단 당김 영역에 맞는 형태입니다.

D_MAX·K 로 저항 곡선을 매 프레임 계산하는 resist()

  // displayed = D_MAX * raw / (raw + K) — raw 가 커질수록 늘어나는 폭이 점점 좁아지는 포화 곡선(Michaelis-Menten).
  var D_MAX = 56, K = 80, RAW_MAX = 200;

  function resist(raw) { return D_MAX * raw / (raw + K); }

  function apply(raw) {
    var displayed = resist(raw);
    var ratio = displayed / D_MAX;
    ghost.style.transform = 'translateY(' + Math.min(raw, D_MAX + 24).toFixed(1) + 'px)';
    ghost.style.opacity = String(Math.min(1, raw / 30));
    dot.style.transform = 'translateY(' + displayed.toFixed(1) + 'px) scale(' + (0.6 + 0.4 * ratio).toFixed(2) + ')';
    dot.style.opacity = String(ratio);
    list.style.transform = 'translateY(' + displayed.toFixed(1) + 'px)';
    hint.textContent = raw > 4 ? ('당김 ' + Math.round(raw) + 'px · 밀림 ' + Math.round(displayed) + 'px') : '아래로 당겨보세요';
  }

  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; }
  }
탄성 당김 — 당긴 거리 값이 늘어날수록 저항이 커지는 새로고침 트리거
새 창에서 열기

01 · 탄성 당김 — 당긴 거리 값이 늘어날수록 저항이 커지는 새로고침 트리거 — 측정 누적면적 15.385% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

D_MAXKresist

02회전 연동 — 당긴 거리 값의 비율만큼 회전 값이 함께 도는 표시

당긴 거리 값을 기준 거리로 나눈 비율 값만큼 표시의 회전 값이 함께 돌아가고, 비율 값이 1이 된 상태가 되면 회전이 계속 이어지는 상태로 바뀝니다. 뉴스 피드 상단 표시, 받은편지함 새로고침에 맞는 형태입니다.

REF·SWEEP 비율로 회전 각도를 계산하는 spinFrame()

  function place(angleDeg, pullPx) {
    var rad = (angleDeg - 90) * Math.PI / 180;
    var x = Math.cos(rad) * RADIUS, y = Math.sin(rad) * RADIUS;
    knob.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
    list.style.transform = 'translateY(' + pullPx.toFixed(1) + 'px)';
  }

  var state = 'idle'; // idle -> dragging -> spinning -> idle
  var raw = 0, ratio = 0, spinAngle = 0, spinStart = 0, spinRaf = null;

  function fromRatio() { return ratio * SWEEP; }

  function spinFrame(ts) {
    if (!spinStart) { spinStart = ts; }
    var el = ts - spinStart;
    spinAngle = (el * SPIN_SPEED) % 360;
    place(spinAngle, 24);
    if (el < 900) {
      spinRaf = requestAnimationFrame(spinFrame);
    } else {
      state = 'idle'; ratio = 0; raw = 0; spinStart = 0;
      hint.textContent = '당겨서 새로고침';
      place(0, 0);
    }
  }
회전 연동 — 당긴 거리 값의 비율만큼 회전 값이 함께 도는 표시
새 창에서 열기

02 · 회전 연동 — 당긴 거리 값의 비율만큼 회전 값이 함께 도는 표시 — 측정 누적면적 14.129% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SWEEPRADIUSspinFrame

03기준 확정 — 당긴 거리 값이 기준을 넘으면 확정 상태로 바뀌는 트리거

당긴 거리 값이 정해진 기준 값을 넘은 상태에서 손을 뗀 상태가 되면 확정된 상태로 바뀌어 새로고침이 시작되고, 기준에 못 미친 채로 손을 뗀 상태가 되면 거리 값이 0으로 되돌아갑니다. 메일함 새로고침, SNS 타임라인 새로고침에 맞는 형태입니다.

ON·OFF 히스테리시스로 확정 후보를 정하는 setCandidate()

  // 계산 겹: 확정 여부(committed candidate)는 raw 하나의 부등호가 아니라 두 문턱의 히스테리시스로 정한다.
  // ON(100px) 을 넘으면 true 로, 다시 OFF(70px) 아래로 내려가야만 false 로 — 경계 근처에서 깜빡이지 않는다.
  var ON = 100, OFF = 70, MAX = 160;
  var candidate = false, state = 'idle'; // idle -> dragging -> committing -> idle

  function setCandidate(raw) {
    if (!candidate && raw >= ON) { candidate = true; }
    else if (candidate && raw < OFF) { candidate = false; }
  }

  function apply(raw) {
    setCandidate(raw);
    var pull = Math.min(raw, MAX) * 0.3;
    disc.style.transform = 'translateY(' + pull.toFixed(1) + 'px) scale(' + (0.7 + 0.3 * Math.min(1, raw / ON)).toFixed(2) + ')';
    disc.classList.toggle('prtc__disc--ready', candidate);
    list.style.transform = 'translateY(' + pull.toFixed(1) + 'px)';
    hint.textContent = candidate ? '놓으면 새로고침' : (raw > 4 ? '더 당기세요 (' + Math.round(raw) + '/' + ON + 'px)' : '아래로 당기세요');
  }
기준 확정 — 당긴 거리 값이 기준을 넘으면 확정 상태로 바뀌는 트리거
새 창에서 열기

03 · 기준 확정 — 당긴 거리 값이 기준을 넘으면 확정 상태로 바뀌는 트리거 — 측정 누적면적 13.559% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ONOFFsetCandidate

04고무줄 한계 — 최대 거리 값을 넘어서면 늘어나는 폭이 급격히 줄어드는 당김

당긴 거리 값이 정해진 최대 값에 가까워질수록 같은 힘에도 늘어나는 폭 값이 급격히 줄어들고, 손을 뗀 상태가 되면 거리 값이 원래 위치로 튕겨 돌아옵니다. 짧은 리스트 새로고침, 과도한 당김 방지 영역에 맞는 형태입니다.

LINEAR·MAX_D 로 늘어나는 폭을 계산하는 displayedOf()

  // 계산 겹: LINEAR(70px) 까지는 raw 에 0.9 배로 거의 그대로 따라가다가, 그 뒤로는 MAX_D(120px) 에
  // 점근하는 지수 감쇠(1-e^-x/K)로 늘어나는 폭이 급격히 줄어든다 — 같은 힘이라도 한계 근처에선 거의 안 늘어난다.
  var LINEAR = 70, MAX_D = 120, K = 42, RAW_MAX = 260;
  var TRACK_H = 92, SCALE = TRACK_H / 150;

  function displayedOf(raw) {
    if (raw <= LINEAR) { return raw * 0.9; }
    var base = LINEAR * 0.9;
    var excess = raw - LINEAR;
    return base + (MAX_D - base) * (1 - Math.exp(-excess / K));
  }

  function apply(raw) {
    var displayed = displayedOf(raw);
    marker.style.transform = 'translateY(' + (-(displayed * SCALE)).toFixed(1) + 'px)';
    list.style.transform = 'translateY(' + (displayed * 0.55).toFixed(1) + 'px)';
    var pctOfCap = Math.round((displayed / MAX_D) * 100);
    hint.textContent = raw > 4 ? ('밀림 ' + Math.round(displayed) + 'px · 한계의 ' + pctOfCap + '%') : '아래로 당기세요';
  }
고무줄 한계 — 최대 거리 값을 넘어서면 늘어나는 폭이 급격히 줄어드는 당김
새 창에서 열기

04 · 고무줄 한계 — 최대 거리 값을 넘어서면 늘어나는 폭이 급격히 줄어드는 당김 — 측정 누적면적 9.799% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LINEARMAX_DdisplayedOf

05아이콘 진행 링 — 당긴 거리 값만큼 원형 진행 값이 채워지다 완료되면 체크 표시로 바뀌는 표시

당긴 거리 값이 커질수록 원형 링의 진행 값이 둘레를 따라 채워지고, 진행 값이 가득 찬 상태가 되면 링 안쪽에 체크 표시의 획 값이 그려지며 새로고침 완료를 나타냅니다. 할 일 목록 새로고침, 채팅방 최신 메시지 불러오기에 맞는 형태입니다.

R·checkLen 으로 링 진행과 체크 획을 그리는 ringOf()

  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 + '%') : '아래로 당기세요');
  }
아이콘 진행 링 — 당긴 거리 값만큼 원형 진행 값이 채워지다 완료되면 체크 표시로 바뀌는 표시
새 창에서 열기

05 · 아이콘 진행 링 — 당긴 거리 값만큼 원형 진행 값이 채워지다 완료되면 체크 표시로 바뀌는 표시 — 측정 누적면적 1.133% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RcheckLenringOf

06래칫 눈금 — 당긴 거리 값이 눈금 값을 지날 때마다 위치가 짧게 튀는 표시

당긴 거리 값이 일정한 간격의 눈금 값을 지날 때마다 표시의 위치 값이 짧게 튀었다가 가라앉고, 지나온 눈금의 개수 값이 늘어날수록 그 튐이 반복됩니다. 재시도 확인이 필요한 새로고침, 강한 피드백이 필요한 리스트에 맞는 형태입니다.

TICK·AMP 로 눈금을 지날 때마다 튐을 얹는 triggerBurst()

  function triggerBurst(now) { burstActive = true; burstStart = now; }

  function frame(now) {
    var clamped = Math.min(raw, MAX_TICK);
    var tickIndex = Math.floor(clamped / TICK);
    if (tickIndex > lastTick) { triggerBurst(now); }
    lastTick = tickIndex;

    var basePos = (clamped / MAX_TICK) * TRACK_H;
    var burst = 0;
    if (burstActive) {
      var t = (now - burstStart) / 1000;
      burst = AMP * Math.exp(-t / TAU) * Math.sin(t * W);
      if (t > 0.6) { burstActive = false; }
    }
    marker.style.transform = 'translateY(' + (-(basePos) + burst).toFixed(1) + 'px)';
    list.style.transform = 'translateY(' + (clamped * 0.35).toFixed(1) + 'px)';
    hint.textContent = raw > 4 ? ('칸 ' + Math.min(tickIndex, N) + '/' + N) : '아래로 당기세요';
    requestAnimationFrame(frame);
  }
래칫 눈금 — 당긴 거리 값이 눈금 값을 지날 때마다 위치가 짧게 튀는 표시
새 창에서 열기

06 · 래칫 눈금 — 당긴 거리 값이 눈금 값을 지날 때마다 위치가 짧게 튀는 표시 — 측정 누적면적 8.078% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TICKAMPtriggerBurst

07스프링 안착 — 손을 뗀 뒤 위치 값이 목표를 살짝 지나쳤다가 제자리로 돌아오는 표시

손을 뗀 상태가 되면 표시의 위치 값이 고정 위치를 살짝 지나친 값까지 갔다가, 시간이 지나면서 그 값이 잦아들어 고정 위치 값으로 안착한 상태가 됩니다. 상단 고정 새로고침 표시, 카드형 피드 새로고침에 맞는 형태입니다.

K·C 스프링 상수로 오버슈트를 계산하는 accel

  var last = null;
  function frame(now) {
    if (last === null) { last = now; }
    var dt = Math.min(0.032, (now - last) / 1000);
    last = now;
    if (integrating) {
      var accel = -K * (pos - target) - C * vel;
      vel += accel * dt;
      pos += vel * dt;
      if (Math.abs(pos - target) < 0.5 && Math.abs(vel) < 5) {
        pos = target; vel = 0; integrating = false;
        if (pendingCommit) { pendingCommit = false; commit(); }
      }
    }
    render();
    requestAnimationFrame(frame);
  }
스프링 안착 — 손을 뗀 뒤 위치 값이 목표를 살짝 지나쳤다가 제자리로 돌아오는 표시
새 창에서 열기

07 · 스프링 안착 — 손을 뗀 뒤 위치 값이 목표를 살짝 지나쳤다가 제자리로 돌아오는 표시 — 측정 누적면적 7.859% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

KCaccel

08행 순차 등장 — 새로고침이 끝나면 목록의 각 행이 순서 값을 따라 나타나는 효과

새로고침이 끝난 상태가 되면 목록의 각 행이 자신의 순서 값만큼 지연된 뒤 위치 값과 투명도 값이 원래대로 돌아오고, 순서 값이 큰 행일수록 나중에 나타납니다. 새 글 목록 새로고침, 알림 목록 새로고침에 맞는 형태입니다.

STAGGER_MS·LATENCY_MS 로 행 지연을 계산하는 refreshCycle()

  function refreshCycle() {
    disc.classList.add('prsg__disc--spin');
    hint.textContent = '새로고침 중';
    timers.push(setTimeout(function () {
      for (var i = 0; i < rows.length; i++) { rows[i].classList.remove('prsg__row--in'); }
      // 강제 리플로우 — 클래스를 뗀 상태를 브라우저가 실제로 반영하게 한다.
      void list.offsetHeight;
      for (var j = 0; j < rows.length; j++) {
        (function (idx) {
          timers.push(setTimeout(function () { rows[idx].classList.add('prsg__row--in'); }, idx * STAGGER_MS));
        })(j);
      }
      var totalDelay = (rows.length - 1) * STAGGER_MS + 260;
      timers.push(setTimeout(function () {
        disc.classList.remove('prsg__disc--spin', 'prsg__disc--ready');
        hint.textContent = '아래로 당기세요';
        raw = 0; apply(0);
      }, totalDelay));
    }, LATENCY_MS));
  }
행 순차 등장 — 새로고침이 끝나면 목록의 각 행이 순서 값을 따라 나타나는 효과
새 창에서 열기

08 · 행 순차 등장 — 새로고침이 끝나면 목록의 각 행이 순서 값을 따라 나타나는 효과 — 측정 누적면적 8.638% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STAGGER_MSLATENCY_MSrefreshCycle

09실패 흔들림 — 새로고침이 실패한 상태가 되면 표시가 좌우로 짧게 흔들리는 효과

새로고침이 실패한 상태가 되면 표시의 좌우 위치 값이 짧은 시간 동안 여러 번 오갔다가 0으로 돌아오고, 다시 당긴 상태가 되면 처음 절차가 다시 시작됩니다. 네트워크 불안정 상태 안내, 재시도가 필요한 새로고침에 맞는 형태입니다.

attemptCount 홀짝과 FREQ 흔들림을 계산하는 attempt()

  function attempt() {
    attemptCount += 1;
    var fail = attemptCount % 2 === 1;
    committing = true;
    if (fail) {
      hint.textContent = '오프라인 - 다시 시도';
      disc.classList.add('prof__disc--fail');
      shaking = true; shakeStart = performance.now();
      commitTimer = setTimeout(function () {
        disc.classList.remove('prof__disc--fail', 'prof__disc--ready');
        committing = false;
        raw = 0; apply(0);
        hint.textContent = '아래로 당기세요';
      }, 640);
    } else {
      hint.textContent = '새로고침 완료';
      disc.classList.add('prof__disc--ok');
      commitTimer = setTimeout(function () {
        disc.classList.remove('prof__disc--ok', 'prof__disc--ready');
        committing = false;
        raw = 0; apply(0);
        hint.textContent = '아래로 당기세요';
      }, 640);
    }
  }

09 는 Math.random을 쓰지 않고 attemptCount % 2로 실패·성공을 결정적으로 번갈아 정하는 구조라, 무작위로 실패하는 게 아니라 1회차는 항상 실패하고 2회차는 항상 성공합니다.

실패 흔들림 — 새로고침이 실패한 상태가 되면 표시가 좌우로 짧게 흔들리는 효과
새 창에서 열기

09 · 실패 흔들림 — 새로고침이 실패한 상태가 되면 표시가 좌우로 짧게 흔들리는 효과 — 측정 누적면적 2.943% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

attemptCountFREQshaking

값이 패턴을 바꾸는 곳 — 거리·비율·기준 값의 경계

실제 당겨서 새로고침 예시에서 거리·비율·기준 값을 어디서 조정하면 되는지 보겠습니다 — 아홉 개 중 세 값만 골라도 손맛이 크게 갈립니다.

저항 거리 기준 — 01 D_MAX·K

01 의 D_MAX(56)는 밀림 거리 값의 상한이라, 30 대로 낮추면 아무리 세게 당겨도 조금만 밀리는 뻑뻑한 느낌이 되고, 90 대로 올리면 더 많이 밀려 헐거운 느낌이 됩니다. K(80)는 저항이 커지는 속도를 정해, 20 대로 낮추면 초반부터 저항이 강해 조금만 당겨도 밀림이 빨리 멈추고, 200 대로 올리면 한참을 당겨도 거의 선형에 가깝게 술술 밀립니다.

회전 비율 기준 — 02 REF·SWEEP

02 의 REF(120)는 비율(ratio)이 1 이 되는 기준 거리라, 60 으로 낮추면 짧게만 당겨도 로딩 상태로 넘어가 새로고침이 성급하게 트리거되고, 200 으로 올리면 훨씬 길게 당겨야 로딩 상태가 됩니다. SWEEP(270도)는 그 비율 값이 다 찼을 때 knob 이 도는 각도 폭이라, 90도로 줄이면 회전이 눈에 덜 띄고, 360도로 늘리면 완전히 한 바퀴를 도는 느낌이 됩니다.

확정 문턱 기준 — 03 ON·OFF

03 의 ON(100)은 확정 후보(candidate)가 되는 거리 기준이라, 60 으로 낮추면 살짝만 당겨도 후보 상태가 되어 손을 떼면 바로 새로고침이 시작되고, 150 으로 올리면 훨씬 더 당겨야만 후보가 됩니다. OFF(70)는 후보가 된 뒤 다시 취소되는 기준이라, ON 과 가깝게 두면(예: 95) 경계 근처에서 손이 미세하게 떨릴 때 candidate 가 자주 뒤집혀 깜빡이는 느낌이 나고, 지금처럼 30px 간격을 벌려 두면 그 깜빡임이 사라집니다.

어디서 깨지는가 — 빠른 당김·짧은 당김·중간에 놓기

빠른 당김 — 06

06 은 frame()이 매 프레임 raw의 현재 값에서 tickIndex를 한 번만 계산하고 if (tickIndex > lastTick)로 새로 지난 눈금이 있는지만 확인하므로, 손가락을 아주 빠르게 움직여 pointermove 한 번에 raw가 눈금 두세 칸을 한꺼번에 건너뛰어도 triggerBurst()는 딱 한 번만 불립니다 — 눈금을 하나씩 다 지난 것처럼 여러 번 튀는 게 아니라, 빠르게 당기면 오히려 튐 횟수가 실제로 지난 칸 수보다 적게 느껴집니다.

짧은 당김 — 03

03 은 setCandidate()가 raw >= ON(100)일 때만 candidate 를 true 로 바꾸므로, OFF(70)보다는 멀리 당겼지만 ON 에는 못 미친 짧은 당김(예: 85px)에서 손을 떼면 candidate 가 한 번도 true 가 된 적이 없어 commit() 없이 그대로 0 으로 되돌아갑니다 — OFF 문턱을 넘었으니 절반은 확정된 것처럼 보여도, 히스테리시스는 애초에 candidate 가 한 번 true 가 된 적이 있을 때만 의미가 있는 값입니다.

중간에 놓기 — 02

02 는 onUp()이 ratio >= 1이면 startSpin()으로 넘어가지만, 그 전에(비율 값이 1 이 되기 전에) 손을 놓으면 state = 'idle'; apply(0);가 그 자리에서 곧바로 실행됩니다 — 01·03·04·08·09 처럼 lerp 로 몇 프레임에 걸쳐 서서히 0 으로 돌아가는 게 아니라, bffdpzhj 안 데모에서 이 즉시 복귀가 딱딱하게 느껴지면 apply(0) 대신 같은 파일의 goTo 류 감쇠 함수를 하나 얹어야 다른 여덟 개와 같은 결의 복귀가 됩니다.

변형 세 가지와 접근성

직접 당겨서 새로고침 끄기 버튼까지 포함한 UI 를 만들 때는 값 하나만 슬쩍 바꿔도 손맛의 인상이 크게 달라집니다.

이름 바뀐 값 느낌
확정 기준 거리 03 ON 100px → 150px 기준을 100px 로 두면 살짝만 당겨도 확정 후보 상태가 되어 손을 떼면 바로 새로고침이 시작되고, 150px 로 늘리면 훨씬 더 당겨야만 확정 후보가 돼 오작동이 줄어드는 대신 손짓이 커져야 한다
스프링 강성 07 K 170 → 80 강성을 170 으로 두면 목표로 빠르고 팽팽하게 당겨져 짧게 튕기고, 80 으로 낮추면 더 느슨하게 당겨져 목표에 닿기까지 시간이 걸리고 오버슈트 폭도 커진다
행 지연 간격 08 STAGGER_MS 90ms → 180ms 간격을 90ms 로 두면 행들이 거의 동시에 우르르 나타나는 느낌이고, 180ms 로 늘리면 한 행씩 뚜렷하게 시간차를 두고 나타나 순서가 눈에 잘 들어온다

아홉 개 모두 prefers-reduced-motion: reduce가 참인 상태가 되면 자동 재생 setInterval 루프만 걸지 않을 뿐, pointerdown·pointermove·pointerup 리스너는 그대로 등록돼 있어 실제 손으로 당기는 조작과 그 결과 값 계산은 전혀 막히지 않습니다. 다만 아홉 개 전부 tabindex나 keydown 대체 입력이 없어 마우스·터치 드래그가 유일한 입력 경로이고, 상태가 바뀔 때 스크린 리더에 알리는 aria-live 장치도 없습니다 — 실제 서비스에 쓸 때는 Pointer Events 문서를 참고해 새로고침 버튼 같은 키보드 대체 경로를 별도로 두어야 하고, 브라우저 자체의 당겨서 새로고침 끄기 설정과 겹치지 않는지도 함께 확인해야 합니다.

FAQ

아홉 개 중 실제 서비스에 쓰기 가장 무난한 패턴은 뭔가요?

거리 값이 기준을 넘었는지만 확정하는 03(기준 확정)이 히스테리시스 덕분에 경계에서 값이 깜빡이지 않아 가장 예측 가능하고, 04(고무줄 한계)는 아무리 세게 당겨도 값이 무한히 늘지 않아 레이아웃이 안전합니다. 06(래칫 눈금)처럼 눈금마다 튀는 표시는 강한 피드백이 필요한 화면에는 맞지만 일반적인 새로고침에는 과할 수 있습니다.

풀 투 리프레시를 만들 때 서버 네트워크 상태를 어떻게 반영하나요?

이 편의 아홉 개는 실제 fetch 나 navigator.onLine 을 읽지 않고 값을 시간·거리 기반으로 시뮬레이션할 뿐이라, 09(실패 흔들림)의 실패·성공도 attemptCount의 홀짝으로 정해지는 결정적 값입니다. 실제 서비스에서는 이 계산 겹을 그대로 두고 commit()·attempt() 안의 성공·실패 분기만 실제 fetch 응답으로 바꿔 끼우면 됩니다.

모바일 웹 당겨서 새로고침은 CSS만으로도 만들 수 있나요?

아홉 개 모두 당긴 거리 값을 실시간으로 읽어 저항 곡선·비율·감쇠 진동 같은 계산을 매 프레임 다시 해야 해서, CSS 트랜지션만으로는 같은 동작이 나오지 않습니다. 05 의 원형 진행 링처럼 stroke-dashoffset 하나만 움직이는 것처럼 보여도 그 값이 매 프레임 손가락 위치에서 다시 계산되는 자바스크립트 계산 겹이 필요합니다.

더 읽을거리

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.