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

밀어서 잠금해제 9종 — 당길수록 무거운 고무줄 트랙부터 호 위 전원 슬라이더까지

밀어서 잠금해제 버튼은 손잡이를 끝까지 밀어야 동작이 실행되는 확인 장치이다. 밀어서 잠금해제는 손가락을 놓는 순간이 승부인데, 02번은 놓은 자리가 짧아도 속도를 더해 끝까지 보내고 03번은 못 미치면 스프링으로 되돌리며 08번은 직선이 아닌 호 위에서 각도로 진행도를 잰다.

밀어서 잠금해제는 버튼이 아니라 위치를 재는 일이다 — 밀어서 잠금해제 UI는 손잡이 위치를 0에서 1 사이 진행도로 바꾸는 데서 시작한다

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

손잡이 하나가 진행도 하나로 모든 것을 움직인다

아홉 장면의 손잡이는 모두 role="slider" 이고, 위치는 sl.p 하나로 0 에서 시작해 1 에서 끝난다. 채움·글자·색·그림자는 전부 이 값에서 계산하고, 손잡이와 채움 끝은 translateX() 로, 글자·카드·링은 opacity 와 transform 으로 움직인다. 08 만 SVG 호를 쓰고 나머지는 DOM 만 쓴다.

놓는 순간에만 판정한다

끌고 있는 동안은 완료인지 아닌지 정하지 않는다. 놓는 순간 release() 가 verdict(p, vEma) 를 한 번 부르고, 완료면 끝으로 가는 스프링을, 아니면 처음으로 가는 스프링을 건다. 04 만 예외라서 누르는 중에도 임계를 넘으면 바로 완료로 넘긴다.

이웃한 편과 다른 점

버튼 눌림 효과 UI 편은 눌림 값이 되돌아오는 방식을 다루고, 카드 스와이프 ui 편은 카드를 끌어 넘기는 드래그를 다룬다. 이 편은 끝까지 밀어야 하나의 결과가 정해진다는 점이 다르고, 손을 계산으로 읽는 점은 가상 조이스틱 편과 닿아 있다.

밀어서 잠금해제 9종 — 당길수록 무거워지는 고무줄 트랙·글자를 훑는 광택 라벨·못 미치면 되돌리는 스프링 복귀·넘으면 끝까지 보내는 임계 자동 완료·속도를 더하는 플릭 판정·밀어서 확인하는 결제 단계·힌트로 일렁이는 쉐브론·호 위를 도는 전원 슬라이더·배경이 선명해지는 잠금 화면 히어로

01 은 끝을 넘겨 당기는 손에 저항을 주고, 02~04 는 놓는 순간의 판정을 속도·스프링·임계로 나눈다. 05~07 은 글자·색·힌트로 손잡이 바깥을 채우고, 08 은 직선을 버리고 호로 가며, 09 는 같은 값 하나로 배경 전체를 움직인다.

아홉 장면의 면적·움직임·강도 실측

장면 누적면적 움직인 프레임 강도 평균
01 고무줄 트랙 19.1% 14/23 83.1
02 플릭 판정 14.5% 14/23 61.2
03 스프링 복귀 12.3% 18/23 99.0
04 임계 자동 완료 18.6% 23/23 70.4
05 광택 라벨 13.6% 17/23 78.4
06 결제 단계 15.7% 21/23 109.5
07 힌트 쉐브론 12.7% 18/23 83.3
08 호 슬라이더 19.8% 16/23 69.8
09 잠금 화면 40.1% 15/23 92.5

가장 넓게 바뀌는 것은 풍경·시계·카드가 한 값으로 움직이는 09 의 40.1% 이고, 가장 좁은 것은 03 의 12.3% 이다. 움직인 프레임은 완료 뒤 링·체크·트랙이 이어서 바뀌는 04 가 23/23 으로 가장 많고, 01 과 02 는 14/23 이다. 강도 평균은 06 이 109.5 로 가장 높고 02 가 61.2 로 가장 낮다.

01끝에 다가갈수록 당기는 힘이 무거워지는 고무줄 트랙

끝 근처에서 손가락을 1.3 까지 당겨도 손잡이는 mapDrag 가 1.078 까지만 보내고, 넘긴 만큼 고무줄 띠가 늘어나며 1/(1+over·BAND_THIN) 만큼 가늘어진다. 0.78(KNEE) 까지는 손가락을 그대로 따라가다 그 뒤로는 지수로 줄어드는 곡선이라, 끝에 가까울수록 손에 무게가 실린다. 결제나 삭제를 확인하는 화면에서 손가락이 끝을 넘어도 손잡이가 트랙 밖으로 튀지 않게 하는 기본형이다.

끝에 다가갈수록 당기는 힘이 무거워지는 고무줄 트랙
새 창에서 열기

01 · 끝에 다가갈수록 당기는 힘이 무거워지는 고무줄 트랙 — 측정 누적면적 19.147% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

KNEESTRETCHmapDrag
    // 손가락 값 x 를 손잡이 진행도로 — KNEE 까지는 그대로, 그 뒤는 끝에 다가갈수록 점점 덜 따라간다
    function mapDrag(x) {
        if (x <= KNEE) {
            return Math.max(x, 0);
        }
        const span = 1 + STRETCH - KNEE;
        return KNEE + span * (1 - Math.exp(-(x - KNEE) / span));
    }
    function draw() {
        placeHandle(main, sl.p);
        const over = Math.max(0, sl.p - 1);
        const w = over > 0 ? 4 - PAD_CQW + over * travelCqw() : 0;
        band.style.transform = 'scaleX(' + (w / BAND_W).toFixed(3) + ') scaleY(' + (1 / (1 + over * BAND_THIN)).toFixed(3) + ')';
        band.style.opacity = over > 0 ? '1' : '0';
        // 손가락이 실제로 있는 자리 — 손잡이와의 거리가 곧 저항이다
        ring.style.transform = 'translateX(' + (xOf(sl.fx) - 3).toFixed(2) + 'cqw)';
        ring.style.opacity = sl.down ? clamp((sl.fx - sl.p - 0.06) / 0.06, 0, 1).toFixed(3) : '0';
        placeHandle(mini, badX);
    }

02짧게 놓아도 속도를 더해 끝까지 보내는 플릭 판정

놓는 순간 위치에 속도를 더해 landing(p, v) = p + max(v, 0)·FLICK_T 로 예상 도착점을 계산하고, 0.80 을 넘으면 남은 거리를 미끄러져 완료한다. 시연의 빠른 손은 위치 0.503·속도 1.35 에서 놓아 예상 도착 0.84 로 1.18초에 끝나고, 같은 자리까지 천천히 끈 손은 위치 0.543·속도 0.39 라 예상 도착 0.64 로 되돌아간다. 가볍게 튕기는 동작을 인정해야 하는 잠금 해제에 맞고, 점선 잔상이 예상 도착점을 미리 보여 준다.

짧게 놓아도 속도를 더해 끝까지 보내는 플릭 판정
새 창에서 열기

02 · 짧게 놓아도 속도를 더해 끝까지 보내는 플릭 판정 — 측정 누적면적 14.492% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLICK_TlandingTHRESH
    // 장면 02 는 고무줄이 없다 — 손가락 값을 0 과 1 사이로만 자른다
    function mapDrag(x) { return clamp(x, 0, 1); }
    // 예상 도착점 — 위치 + 속도 × FLICK_T. 뒤로 가는 속도는 보태지 않는다
    function landing(p, v) { return p + Math.max(v, 0) * FLICK_T; }
    function verdict(p, v) { return landing(p, v) >= THRESH; }
    // 시연 손가락 — 짝수 바퀴는 빠르게 훑다 짧게 놓고, 홀수 바퀴는 같은 자리까지 천천히 끌고 가 놓는다
    function fingerAt(t, c) {
        if (c % 2 === 0) {
            return { down: t >= 0.2 && t < 0.6, x: ramp(t, [[0.2, 0], [0.6, 0.55]]) };
        }
        return { down: t >= 0.2 && t < 1.6, x: ramp(t, [[0.2, 0], [1.6, 0.55]]) };
    }
    function tick(h) {
        void h;
        if (sl.down) {
            land = landing(sl.p, sl.vEma);
        }
    }
    function draw() {
        placeHandle(main, sl.p);
        // 점선 잔상 — 누르는 동안은 지금 속도로 잰 예상 도착점, 놓은 뒤에는 그 자리에 남았다가 1.2초에 걸쳐 옅어진다
        const since = sl.relT < 0 ? 0 : sceneT - sl.relT;
        const keep = sl.down ? 1 : (sl.relT < 0 ? 0 : clamp(1 - since / 1.2, 0, 1));
        const shown = land;
        const hit = shown >= THRESH;
        ghost.style.transform = 'translateX(' + (clamp(shown, 0, 1.05) * travelCqw()).toFixed(3) + 'cqw)';
        ghost.style.opacity = (keep * (shown > 0.05 ? 1 : 0)).toFixed(3);
        ghost.classList.toggle(cls('is-hit'), hit);
        const txt = shown <= 0.05 ? '' : (hit ? '예상 도착 {n}% — 임계를 넘어 끝까지 간다' : '예상 도착 {n}% — 임계에 못 미쳐 되돌아간다');
        note.textContent = txt.replace('{n}', String(Math.round(shown * 100)));
        note.style.opacity = txt ? '1' : '0';
    }

03못 미치면 오버슈트를 남기며 돌아오는 스프링 복귀

임계 0.80 에 못 미친 채 놓으면 v += (−SPR_K·p − SPR_C·v)·h 로 감쇠 스프링이 손잡이를 처음으로 돌려보내고, 손잡이는 출발점을 넘었다 되돌아온다. 시연에서 0.525 에 놓은 손잡이는 출발점을 두 번 지나며 최저 −0.117 까지 내려갔다가, 놓은 뒤 1.17초에 처음 자리에 멈춘다. 실수로 건드려도 안전하게 되돌아가야 하는 잠금 화면에 쓰며, 아래 대비판은 놓은 자리에 채움이 어정쩡하게 남는다.

못 미치면 오버슈트를 남기며 돌아오는 스프링 복귀
새 창에서 열기

03 · 못 미치면 오버슈트를 남기며 돌아오는 스프링 복귀 — 측정 누적면적 12.287% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPR_KSPR_CTHRESH
        else if (sl.mode === M_BACK) {
            sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
            sl.p += sl.v * h;
            if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) {
                sl.p = 0;
                sl.v = 0;
                sl.mode = M_IDLE;
            }
        }
        else if (sl.mode === M_FIN) {
            sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
            sl.p += sl.v * h;
            if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) {
                sl.p = 1;
                sl.v = 0;
                sl.mode = M_DONE;
                sl.doneT = 0;
            }
        }
    function draw() {
        placeHandle(main, sl.p);
        placeHandle(mini, badP);
        // 스프링이 출발점을 넘어 간 만큼(진행도 0 미만)을 글자로 보여 준다
        const under = Math.max(0, -sl.p);
        const txt = under > 0.004 ? '출발점을 {n}% 넘어갔다 돌아온다' : '';
        note.textContent = txt.replace('{n}', String(Math.round(under * 100)));
        note.style.opacity = txt ? '1' : '0';
    }

04임계를 넘으면 남은 길을 스스로 가고 체크로 바뀌는 자동 완료

누르는 동안에도 진행도가 THRESH 0.6 에 닿으면 autoCross 가 M_FIN 으로 넘겨, 손을 떼지 않아도 남은 길을 스스로 간다. 시연에서는 1.03초에 0.60 에 닿아 자동 완료로 들어가 1.82초에 완료 상태가 되고, 그때부터 화살표가 체크로 바뀌며 둘레 링이 한 번 퍼지고 트랙이 옅어진다. 끝까지 쥐고 있게 하고 싶지 않은 확인 단계의 완료 피드백에 맞는다.

임계를 넘으면 남은 길을 스스로 가고 체크로 바뀌는 자동 완료
새 창에서 열기

04 · 임계를 넘으면 남은 길을 스스로 가고 체크로 바뀌는 자동 완료 — 측정 누적면적 18.602% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

THRESHautoCrossRING_S
        if (sl.mode === M_DRAG) {
            const old = sl.p;
            sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
            sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
            if (autoCross && sl.p >= THRESH) {
                sl.down = false;
                sl.relP = sl.p;
                sl.relV = sl.vEma;
                sl.relT = sceneT;
                sl.ok = true;
                sl.v = sl.vEma;
                sl.mode = M_FIN;
            }
        }
    function draw() {
        placeHandle(main, sl.p);
        const t = sl.mode === M_DONE ? sl.doneT : -1;
        const u = t < 0 ? 0 : clamp(t / CHECK_S, 0, 1);
        const arrow = main.ico;
        arrow.style.opacity = (1 - u).toFixed(3);
        check.style.opacity = u.toFixed(3);
        check.style.transform = 'scale(' + (0.6 + 0.4 * easeOut3(u)).toFixed(3) + ')';
        // 둘레 링 — 손잡이 뒤에서 한 번 커지며 옅어진다
        const r = t < 0 ? 0 : clamp(t / RING_S, 0, 1);
        pulse.style.transform = 'translateX(' + (travelCqw()).toFixed(3) + 'cqw) scale(' + (1 + 1.3 * easeOut3(r)).toFixed(3) + ')';
        pulse.style.opacity = t < 0 ? '0' : (0.7 * (1 - r)).toFixed(3);
        // 완료 뒤에는 트랙 전체가 옅어진다
        const f = t < 0 ? 0 : easeInOut((t - FADE_AT_S) / 0.6);
        main.bg.style.opacity = (1 - 0.45 * f).toFixed(3);
        const txt = t >= 0 ? '잠금 해제됨' : '밀어서 잠금 해제';
        if (main.label.textContent !== txt) {
            main.label.textContent = txt;
        }
    }

05손잡이가 지나간 글자를 흐리게 지우고 광택이 훑는 안내 라벨

대기 중에는 광택 띠가 SHIM_PERIOD 1.6초마다 글자를 좌에서 우로 훑고, 손잡이가 지나간 글자는 (손잡이 오른쪽 끝 − 글자 중심)/FADE_W 만큼 투명해진다. 글자 9개의 가운데 위치는 measure() 가 getBoundingClientRect 로 한 번 재 두고 폭이 바뀔 때만 다시 재며, 손을 대면 idle = 1 − p/0.12 로 광택이 꺼진다. 버튼 안내 문구를 눈에 띄게 하면서 지나간 말은 지우고 싶을 때 쓴다.

손잡이가 지나간 글자를 흐리게 지우고 광택이 훑는 안내 라벨
새 창에서 열기

05 · 손잡이가 지나간 글자를 흐리게 지우고 광택이 훑는 안내 라벨 — 측정 누적면적 13.636% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SHIM_PERIODSHIM_WFADE_W
    function measure() {
        const w = rootW();
        if (w === measuredW) {
            return;
        }
        measuredW = w;
        const tr = main.track.getBoundingClientRect();
        const k = 100 / w;
        charX = [];
        for (let i = 0; i < chars.length; i += 1) {
            const r = chars[i].getBoundingClientRect();
            charX.push((r.left + r.width / 2 - tr.left) * k);
        }
        const first = chars[0].getBoundingClientRect();
        const last = chars[chars.length - 1].getBoundingClientRect();
        lineL = (first.left - tr.left) * k;
        lineW = Math.max(1, (last.right - first.left) * k);
    }
    function draw() {
        measure();
        placeHandle(main, sl.p);
        const xr = PAD_CQW + sl.p * travelCqw() + HANDLE_CQW;
        // 광택 띠의 중심(글줄 길이에 대한 비율) — 한 번 훑고 쉬지 않고 이어진다. 손을 대면 서서히 꺼진다
        const idle = 1 - clamp(sl.p / 0.12, 0, 1);
        const s = ((sceneT / SHIM_PERIOD) % 1) * (1 + 2 * SHIM_W) - SHIM_W;
        for (let i = 0; i < chars.length; i += 1) {
            const f = (charX[i] - lineL) / lineW;
            const gain = Math.max(0, 1 - Math.abs(f - s) / SHIM_W) * idle;
            const gone = clamp((xr - charX[i]) / FADE_W, 0, 1);
            chars[i].style.opacity = ((BASE_ALPHA + (1 - BASE_ALPHA) * gain) * (1 - gone)).toFixed(3);
            chars[i].style.transform = 'scale(' + (1 + 0.1 * gain).toFixed(3) + ')';
        }
        const sx = lineL + s * lineW - 10;
        sheen.style.transform = 'translateX(' + sx.toFixed(2) + 'cqw) skewX(-20deg)';
        sheen.style.opacity = (0.6 * idle).toFixed(3);
    }

06밀수록 색과 금액 문구가 단계로 바뀌는 결제 확인 슬라이더

진행도가 STAGE_1 0.33 과 STAGE_2 0.7 을 지날 때 채움 색이 라일락에서 하늘색, 박하색으로 바뀌고 금액 문구가 「밀어서 확인」「조금 더」「놓으면 결제」 로 바뀌며, 색은 경계 BLEND_W 0.12 폭에서만 섞인다. 완료 뒤에는 로딩 링이 SPIN_S 0.9초 도는 동안 「결제 중」 이라고 쓰고 그다음 체크로 닫혀 「결제 완료」 가 된다. 송금·주문처럼 실수 결제를 막아야 하는 확인 단계에 쓴다.

밀수록 색과 금액 문구가 단계로 바뀌는 결제 확인 슬라이더
새 창에서 열기

06 · 밀수록 색과 금액 문구가 단계로 바뀌는 결제 확인 슬라이더 — 측정 누적면적 15.686% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STAGE_1STAGE_2stageColor
    // 진행도 → 채움 색 — 세 단계 색을 경계 BLEND_W 폭에서만 섞는다
    function stageColor(p) {
        const a = easeInOut((p - STAGE_1 + BLEND_W / 2) / BLEND_W);
        const b = easeInOut((p - STAGE_2 + BLEND_W / 2) / BLEND_W);
        const c = [0, 0, 0];
        for (let i = 0; i < 3; i += 1) {
            const m1 = lerp(COLORS[0][i], COLORS[1][i], a);
            c[i] = Math.round(lerp(m1, COLORS[2][i], b));
        }
        return 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')';
    }
    function stageText(p) {
        if (p >= STAGE_2) {
            return '{a} 놓으면 결제'.replace('{a}', AMOUNT);
        }
        if (p >= STAGE_1) {
            return '{a} 조금 더'.replace('{a}', AMOUNT);
        }
        return '{a} 밀어서 확인'.replace('{a}', AMOUNT);
    }
    function draw() {
        placeHandle(main, sl.p);
        placeHandle(mini, badP);
        const p = clamp(sl.p, 0, 1);
        const t = sl.mode === M_DONE ? sl.doneT : -1;
        main.fill.style.background = stageColor(p);
        // 완료 직후에는 로딩 링이 돌고, 다 돌면 체크로 닫힌다
        const spin = t >= 0 && t < SPIN_S;
        const ok = t >= SPIN_S;
        spinner.style.opacity = spin ? '1' : '0';
        spinner.style.transform = 'rotate(' + ((t / SPIN_TURN_S) * 360).toFixed(1) + 'deg)';
        main.ico.style.opacity = t >= 0 ? '0' : '1';
        check.style.opacity = ok ? '1' : '0';
        check.style.transform = 'scale(' + (ok ? 0.6 + 0.4 * easeOut3((t - SPIN_S) / 0.25) : 0.6).toFixed(3) + ')';
        const txt = ok ? '결제 완료' : (spin ? '결제 중' : stageText(p));
        if (main.label.textContent !== txt) {
            main.label.textContent = txt;
        }
    }

07가만히 두면 쉐브론이 물결로 일렁이며 방향을 알리는 힌트

손이 닿지 않는 동안 쉐브론 셋이 HINT_PHASE 0.16 의 위상 차로 차례로 밝아지고, 손잡이는 HINT_AMP·sin² 로 0.14 만큼 앞으로 나갔다 돌아오는 일렁임을 HINT_P 1.2초마다 되풀이한다. 누르면 mode 가 바뀌어 힌트가 즉시 꺼지고, 놓은 뒤에도 HINT_RESUME_S 0.6초가 지나야 다시 켜진다. 처음 쓰는 사람에게 밀 방향을 말없이 알려야 하는 온보딩에 쓴다.

가만히 두면 쉐브론이 물결로 일렁이며 방향을 알리는 힌트
새 창에서 열기

07 · 가만히 두면 쉐브론이 물결로 일렁이며 방향을 알리는 힌트 — 측정 누적면적 12.687% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

HINT_PHINT_PHASEHINT_AMP
    // 힌트가 켜진 상태 — 손잡이가 처음 자리에 있고 누르지도 놓은 직후도 아닐 때
    function hintOn() {
        return sl.mode === M_IDLE && sl.p < 0.001 && (sl.relT < 0 || sceneT - sl.relT > HINT_RESUME_S);
    }
    function draw() {
        const on = hintOn();
        const ph = (sceneT / HINT_P) % 1;
        // 손잡이는 사인 곡선(sin 제곱)으로 조금 앞으로 나갔다 돌아온다
        const hint = on ? HINT_AMP * Math.pow(Math.sin(Math.PI * ph), 2) : 0;
        placeHandle(main, sl.p + hint);
        for (let i = 0; i < chevs.length; i += 1) {
            const w = Math.sin(2 * Math.PI * (sceneT / HINT_P - i * HINT_PHASE));
            const a = on ? 0.22 + 0.78 * Math.pow(Math.max(0, w), 2) : 0.22;
            chevs[i].style.opacity = a.toFixed(3);
        }
    }

08직선이 아니라 호 위를 각도로 재며 도는 전원 슬라이더

손잡이는 arcX·arcY 로 반원 호를 따라 움직이고, 진행도는 angleP 가 atan2 로 잰 중심각에서 1 − 각도/π 로 구한다. 임계는 THRESH_DEG 144° 이고, 넘겨 놓으면 호 전체가 FLASH_TAU 0.18초 감쇠로 한 번 번쩍이며 전원 아이콘이 옅어지고 문구는 「전원 꺼짐」 으로 바뀐다. 전원 끄기·긴급 중지처럼 위험한 조작 화면에 쓴다.

직선이 아니라 호 위를 각도로 재며 도는 전원 슬라이더
새 창에서 열기

08 · 직선이 아니라 호 위를 각도로 재며 도는 전원 슬라이더 — 측정 누적면적 19.829% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

THRESH_DEGanglePFLASH_TAU
    // 호 위 한 점 — 진행도 p 는 왼쪽 끝(180도)에서 오른쪽 끝(0도)까지, 중심에서의 각도 θ = π(1 − p)
    function arcX(p) { return CX + R * Math.cos(Math.PI * (1 - p)); }
    function arcY(p) { return CY - R * Math.sin(Math.PI * (1 - p)); }
    // 포인터 위치 → 진행도 — 중심에서 본 각도를 재서 극좌표로 바꾼다. 호 아래쪽은 가까운 끝으로 붙인다
    function angleP(e) {
        const r = scene.getBoundingClientRect();
        const ctrX = r.left + r.width * CX / 100;
        const ctrY = r.top + r.height * CY / 62.5;
        let a = Math.atan2(ctrY - e.clientY, e.clientX - ctrX);
        if (a < -Math.PI / 2) {
            a = Math.PI;
        }
        else if (a < 0) {
            a = 0;
        }
        return 1 - a / Math.PI;
    }
    function draw() {
        const p = sl.p;
        handle.style.transform = 'translate(' + (arcX(p) - HANDLE_CQW / 2).toFixed(3) + 'cqw,' + (arcY(p) - HANDLE_CQW / 2).toFixed(3) + 'cqw)';
        // 손잡이 안 화살표는 호의 접선 방향을 본다 — 왼쪽 끝에서는 위, 꼭대기에서는 오른쪽, 오른쪽 끝에서는 아래
        arrow.style.transform = 'rotate(' + ((p - 0.5) * 180).toFixed(1) + 'deg)';
        const shown = clamp(p, 0, 1);
        fillArc.style.strokeDasharray = (ARC_LEN * shown).toFixed(2) + ' ' + ARC_LEN.toFixed(2);
        const off = sl.mode === M_DONE;
        const t = off ? sl.doneT : 0;
        flashArc.style.opacity = off ? (0.9 * Math.exp(-t / FLASH_TAU)).toFixed(3) : '0';
        fillArc.classList.toggle(cls('is-off'), off);
        // 가운데 전원 아이콘은 진행도가 반을 넘기면서 옅어지고, 꺼진 뒤에는 가장 옅다
        const dim = easeInOut((shown - 0.5) / 0.5);
        power.style.opacity = (1 - 0.65 * dim).toFixed(3);
        const txt = off ? '전원 꺼짐' : '전원 켜짐';
        if (powerLabel.textContent !== txt) {
            powerLabel.textContent = txt;
        }
    }

09밀수록 배경이 선명해지고 시계가 물러나는 잠금 화면 히어로

값 하나 p 에서 blur(BLUR_MAX·(1−p)) 로 풍경의 흐림이 줄고, 시계는 CLOCK_RISE 9cqw 만큼 올라가며 CLOCK_MIN 0.5 배까지 작아지고, 알림 카드 둘은 CARD_FROM 0.3 부터 CARD_GAP 0.15 간격으로 아래에서 올라온다. 놓아 되돌리면 같은 식이 거꾸로 풀려 모든 값이 같은 길로 돌아간다. 앱·키오스크 첫 화면이나 서비스 랜딩의 히어로에 쓴다.

밀수록 배경이 선명해지고 시계가 물러나는 잠금 화면 히어로
새 창에서 열기

09 · 밀수록 배경이 선명해지고 시계가 물러나는 잠금 화면 히어로 — 측정 누적면적 40.138% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BLUR_MAXCLOCK_MINCARD_FROM
    function draw() {
        placeHandle(main, sl.p);
        const p = clamp(sl.p, 0, 1);
        // 풍경은 진행도에 반비례해 흐려지고, 시계는 위로 올라가며 작아지고 옅어진다 — 값 하나(p)에서 모두 나온다
        img.style.filter = 'blur(' + (BLUR_MAX * (1 - p)).toFixed(3) + 'cqw)';
        clock.style.transform = 'translateY(' + (-CLOCK_RISE * p).toFixed(3) + 'cqw) scale(' + lerp(1, CLOCK_MIN, p).toFixed(3) + ')';
        clock.style.opacity = (1 - 0.3 * p).toFixed(3);
        for (let i = 0; i < cards.length; i += 1) {
            const u = easeOut3((p - CARD_FROM - i * CARD_GAP) / CARD_SPAN);
            cards[i].style.transform = 'translateY(' + (16 * (1 - u)).toFixed(3) + 'cqw)';
            cards[i].style.opacity = u.toFixed(3);
        }
        const txt = sl.mode === M_DONE ? '해제됨' : '밀어서 잠금 해제';
        if (main.label.textContent !== txt) {
            main.label.textContent = txt;
        }
    }

밀어서 잠금해제 UI를 코드로 옮기면 손잡이는 몇 가지 상태를 가져야 할까?

다섯 상태면 충분하다

M_IDLE(가만히)·M_DRAG(끌기)·M_BACK(스프링으로 처음으로)·M_FIN(스프링으로 끝으로)·M_DONE(완료) 다섯 개가 전부이다. 끌기가 시작되면 M_DRAG, 놓으면 verdict 결과에 따라 M_BACK 이나 M_FIN, 스프링이 멈추면 M_IDLE 이나 M_DONE 으로 간다.

완료 뒤에는 되돌릴 길을 하나 남긴다

사람이 만진 장면은 완료 뒤 DONE_HOLD_S 1.8초가 지나면 처음으로 돌아가고, 키보드는 완료 뒤 Enter·Space·Home 으로 되돌린다. 자동 시연은 LOOP_S 가 돌 때마다 cycle 이 하나 올라 완료하는 손과 되돌아가는 손을 번갈아 보여 준다.

어디에 붙이나 — 결제·전원·첫 화면

송금·주문 확인에는 06 의 단계 색을, 전원 끄기·긴급 중지에는 08 의 호를, 앱이나 키오스크 첫 화면에는 09 를 붙인다. 밀어서 잠금해제 설정 화면을 따로 둘 때는 임계 비율과 스프링 세기 두 값만 바깥으로 빼 두면 된다.

트랙 폭을 재고, 손잡이 위치를 진행도로 바꾸고, 놓은 자리와 속도로 완료를 정하고, 못 미치면 되돌리기까지 — 밀어서 잠금해제 한 번이 처리되는 순서

트랙 폭은 cqw 로 재서 무대 폭에 비례시킨다

손잡이가 움직이는 거리는 TRACK_CQW − HANDLE_CQW − 2·PAD_CQW 이고, 72 이면 57cqw 로 480px 무대에서 273.6px 이다. 단위를 px 가 아니라 cqw 로 잡았으므로 무대가 줄어들면 같은 비율로 줄어든다.

손가락은 따라가고 속도는 걸러서 읽는다

끌기 중 위치는 p += (mapDrag(x) − p)·(1 − e^(−h·FOLLOW)), 속도는 vEma += (dp/h − vEma)·(1 − e^(−h·VEL_SMOOTH)) 로 FOLLOW 가 40, VEL_SMOOTH 가 20 이다. 위치를 손가락에 그대로 붙이지 않고 한 박자 늦게 따라가게 하므로 놓는 순간의 속도가 프레임마다 흔들리지 않는다.

한 프레임을 1/240초 조각으로 쪼갠다

update 는 ceil(dt·240) 조각으로 나눠 stepPhysics 를 돌리고, 한 프레임 시간은 DT_MAX 0.05초까지만 받는다. 그래서 프레임 간격이 달라도 스프링이 같은 곡선을 그린다.

    function update(dt) {
        const n = Math.max(1, Math.ceil(dt * 240));
        const h = dt / n;
        for (let i = 0; i < n; i += 1) {
            stepPhysics(h);
            tick(h);
        }
    }
    // 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
    const DT_MAX = 0.05;
    function advance(dt) {
        sceneT += dt;
        if (demo && sceneT >= LOOP_S) {
            sceneT -= LOOP_S;
            cycle += 1;
            fullReset();
        }
        update(dt);
    }

밀어서 잠금해제가 깨지는 순간 — 끝을 넘어가는 손잡이·놓았는데 중간에 멈춘 채 남는 채움·터치가 트랙 밖으로 나가면 끊기는 끌기

한계가 없으면 손잡이가 끝을 넘어 튄다

01 의 아래 작은 트랙은 badX = clamp(fx, 0, 1.3) 로 손가락을 그대로 따라가서 손잡이가 끝을 넘어 튄다. 위의 큰 트랙은 같은 손에 1.078 에서 멈추므로 한 화면에서 두 방식을 나란히 비교할 수 있다.

되돌리지 않으면 채움이 중간에 남는다

03 의 대비판은 놓으면 그 자리에 선다. badP 가 누르는 동안만 clamp(p, 0, 1) 을 따라가기 때문에 0.525 에서 놓으면 반쯤 찬 채움이 그대로 남는다.

포인터가 트랙 밖으로 나가면 끌기가 끊긴다

06 의 대비판은 손가락이 LEAVE_X 0.55 를 지나면 lost = true 가 되어 손잡이가 그 자리에 멈추고 더는 손가락을 따라가지 않는다. 실제 조작에서는 손잡이에 setPointerCapture 를 걸고 touch-action: none 을 두므로 손가락이 트랙 밖으로 나가도 끌기가 이어진다. 압축 파일은 암호 smsvvdkv 로 열고 06 폴더의 index.html 에서 손잡이를 0.55 너머까지 끌어 보면 아래 작은 줄이 그 자리에서 끊기는 것을 볼 수 있다.

트랙 길이와 임계 비율과 스프링 세기를 바꾸면 완료 판정은 얼마나 달라질까 — 폭·임계·강성을 바꿔 본 차이

세 값을 하나씩만 바꿔 같은 시연 손에 돌려 본 결과이다. 수치는 렌더 측정이 아니라 60Hz 프레임·1/240초 조각으로 stepPhysics 를 옮겨 돌린 시뮬레이션 값이다.

이름 바꾼 값 느낌
트랙을 길게 02 TRACK_CQW 72 → 84 손잡이가 움직이는 거리가 57cqw 에서 69cqw 로(480px 무대에서 274px 에서 331px) 늘어난다. 같은 손(138px 지점에서 초속 369px 로 놓음)이 진행도 0.50·속도 1.35 에서 0.42·1.12 로 읽혀 예상 도착이 0.84 에서 0.69 로 내려가고, 임계 0.80 아래가 되어 완료가 복귀로 뒤집힌다
임계를 높게 04 THRESH 0.6 → 0.85 0.75 까지 끌었다 놓는 같은 손이 0.6 에서는 누른 채 1.03초에 자동 완료(완료 상태 1.82초)로 가지만, 0.85 에서는 1.62초에 놓일 때까지 임계에 닿지 못한다. 출발점을 5%쯤 넘었다가 2.47초에 처음 자리에서 멈춘다
스프링을 단단하게 03 SPR_K 110 → 240 감쇠비가 0.43 에서 0.29 로 내려가 출발점을 넘어가는 폭이 놓은 거리의 22.5%에서 38.5%로 늘고(0.525 에서 놓으면 −0.12 에서 −0.20), 출발점에 처음 닿는 시간이 0.20초에서 0.10초로 당겨진다. 진동수는 1.51Hz 에서 2.36Hz 로 빨라지고, SPR_C 9 가 그대로라 멈추는 시간은 둘 다 1.2초 안팎이다

값을 바꾸기 전에 볼 것

세 변형은 시연 손이 고정이라 값 하나가 판정을 뒤집는 지점을 보여 줄 뿐이고, 실제 사용자의 손은 매번 다르다. THRESH 는 낮출수록 가볍게 완료되고 높일수록 끝까지 밀어야 하므로, 결제 확인처럼 실수가 비싼 화면은 높은 쪽으로 잡고 가벼운 잠금은 낮은 쪽으로 잡는다.

움직임 줄이기에서는 잠금이 풀린 한 장 — 진행도를 문장으로 읽어 주고 키보드 화살표로 손잡이를 옮기는 접근성

움직임 줄이기와 풀린 한 장

prefers-reduced-motion: reduce 이면 미디어 쿼리 값을 읽은 reduceQuery 가 루프를 세우고, showRest() 가 트윈 없이 p = 1·M_DONE 인 잠금이 풀린 한 장을 그린다. 이 상태에서도 끌 수 있어서 pointermove 가 p 를 바로 바꾸고, 놓으면 settleNow() 가 속도를 0 으로 놓고 판정해 즉시 최종 상태를 고른다. 그래서 02 의 속도를 더하는 판정은 이 상태에서 적용되지 않는다. 페이지를 연 채 설정을 바꿔도 change 리스너가 다시 읽는다.

슬라이더 역할과 문장으로 읽는 진행도

손잡이는 slider 역할에 aria-valuemin·aria-valuemax(0~100) 와 aria-valuenow 를 갖고, aria-valuetext 에 그 순간의 상태 문장이 들어간다. 눈으로 보는 상태 줄은 aria-hidden 이며, 완료는 .vj__live 에 finalText() 한 문장을 한 번만 적는다. 시연 중에는 aria-live="off" 이고 사람이 처음 손대면 polite 로 켠다.

키보드와 일시정지

오른쪽·위 화살표는 KEY_STEP 0.1 만큼 올리고 왼쪽·아래 화살표는 내리며, Home 은 처음으로 End·Enter·Space 는 끝까지 보낸다. 화살표로 임계를 넘겨도 그대로 완료되고, 완료 뒤에는 Enter·Space·Home 이 처음으로 되돌린다. 일시정지 버튼은 aria-pressed 로 상태를 알리고 탭이 숨으면(visibilitychange) 루프가 멈춘다.

밀어서 잠금해제 FAQ — 라이브러리 없이 만들기·터치와 마우스 함께 받기·밀어서 잠금해제 없애기 대신 쓰는 확인 버튼

라이브러리 없이도 만들 수 있나요

아홉 장면 모두 라이브러리·WebGL·CDN 을 쓰지 않으며 Math.random·Date·fetch 도 없는 vanilla 코드입니다. 손잡이 하나를 pointerdown·pointermove·pointerup 으로 읽고 requestAnimationFrame 이 위치를 그리면 됩니다.

터치와 마우스를 함께 받으려면 어떻게 하나요

포인터 이벤트 하나가 마우스·터치·펜을 같이 받습니다. pointerdown 에서 setPointerCapture 를 걸고, 마우스는 e.button !== 0 이면 무시하며, pointercancel 도 놓는 것과 같이 처리합니다.

밀어서 잠금해제 없애기 대신 쓸 확인 버튼은 어떤 모양인가요

슬라이더를 빼고 싶다면 <button> 하나로 같은 확인을 받으면 됩니다. 이 편의 슬라이더도 Enter·Space 가 completeNow() 로 바로 끝까지 보내므로 키보드 사용자는 이미 버튼처럼 쓰고 있고, 실수 방지가 목적이라면 버튼을 한 번 더 눌러 확인하게 만드는 쪽이 같은 역할을 합니다.

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

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