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

가로 스크롤 css 9가지 — 휠 관성·원통 갤러리·어안 렌즈 가장자리

가로 스크롤 css 는 세로로 들어온 휠 값을 옆으로 돌려 놓는 데서 끝나지 않는다 — 01번은 그 값을 관성으로 미끄러뜨리고, 06번은 카드를 원통 위에 투영하고, 08번은 가장자리 픽셀을 어안 렌즈로 휜다.

아홉 가지 가로 스크롤 장면을 이 화면에서 바로 굴려 본다

아래 격자의 카드를 누르면 그 항목 절로 내려가고, 각 데모 안에서 휠을 굴리면 옆으로 흐르는 모습을 바로 확인할 수 있다. 순서는 입력이 화면을 바꾸는 층수를 따랐다 — 01 부터 03 까지는 입력 처리의 세 단계(관성·구간 매핑·플링 스냅), 04 부터 07 까지는 옆 이동을 입체로 보이게 하는 네 방식(조명·시차·원통·줄), 08 과 09 는 픽셀과 글자가 휘는 두 가지 마무리다.

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

가로 스크롤 css 9가지 — 휠 관성·원통 투영·어안 렌즈로 만드는 옆으로 흐르는 화면

먼저 정확히 밝혀 둔다. 이 편의 아홉 장면은 전부 <canvas> 위에서 자바스크립트가 프레임마다 계산해 그린 그림이고, CSS 애니메이션은 버튼에 포인터를 올렸을 때 배경색이 바뀌는 transition 하나가 전부다. 휠의 세로 값을 옆 이동으로 돌리고 관성을 붙이는 일은 CSS 속성만으로는 안 되는 영역이라 wheel 이벤트의 값을 직접 다룬다. CSS 는 무대의 폭·비율·모서리와 상태 알약 모양 같은 바깥틀만 맡는다.

아홉 장면은 같은 뼈대를 공유한다. rAF 타임스탬프를 쌓아 16ms 고정 스텝(FIXED_DT)으로 최대 6번 움직이고 그리기는 프레임당 한 번이며, 탭이 숨었다 돌아와도 한 프레임이 흘려보내는 시간은 100ms 로 막혀 있어 순간이동이 없다. 난수 함수는 쓰지 않으며(05 의 능선만 정수 해시로 만든다), 라이브러리·CDN·WebGL 은 아홉 파일 전부에서 0회다.

휠 가로채기도 아홉 장면이 같은 한 쌍으로 한다. 들어온 값은 wheelPx 가 먼저 눈금을 맞춘다 — 줄 단위 장치(deltaMode 1)는 16 을, 쪽 단위(2)는 480 을 곱하고, |deltaX| > |deltaY| 이면 가로 값을 택한다. 트랙패드의 가로 쓸기와 마우스 휠의 세로 굴림이 같은 축으로 들어와 같은 길을 지나는 셈이다. 그다음 canMove(d) 로 지금이 끝 방향인지를 보고 이동이 일어날 때만 preventDefault 를 부르는데, 프로브로 실측하면 끝 방향 wheel 은 안 막고(pass) 이동 가능 방향은 막는다(blocked) 는 결과가 아홉 장면 전부에서 나온다. 그래서 트랙 끝에서는 페이지의 세로 스크롤이 그대로 이어진다.

사람이 처음 휠·포인터·키를 만지는 순간 wake() 가 자동 시연을 멈추고 상태 알약의 aria-live 를 off 에서 polite 로 바꾼다 — 시연 중에는 화면 읽기 프로그램이 잡담하지 않게 하려는 설계다. 캔버스에는 touch-action: pan-y 가 걸려 손가락 가로 끌기는 장면이 받고 세로 쓸기는 브라우저에 남는다.

css 가로 스크롤 만들기는 라이브러리 없이 어디까지 되는가?

생각보다 멀리 간다 — 이 편의 관성·스냅·원근 투영·픽셀 리맵이 전부 곱셈과 덧셈 몇 줄 안에서 돌아가기 때문이다. 01 은 등비급수 하나, 03 은 스프링 두 항, 06 은 나눗셈 한 줄이 핵심 계산이고, 05 의 산맥도 격자 해시 네 겹으로 그린다. 쓰임으로 고르자면 상품 가로 목록과 추천 줄은 01, 제품 랜딩과 연혁은 02, 모바일 카드 캐러셀은 03, 단계 소개와 브랜드 스토리는 04, 여행 히어로는 05, 앨범과 포트폴리오 표지는 06, 사진 줄 진열은 07, 아이콘 독과 칩 줄은 08, 슬로건 띠는 09 부터 열어 보면 된다. 아래 아홉 절의 코드 조각은 각 장면 index.html 스크립트에서 줄과 들여쓰기를 그대로 옮긴 것이어서, zip 을 열면 같은 함수 이름으로 원문을 다시 찾을 수 있다.

01세로 휠 값이 가로 이동으로 바뀌고 입력이 멈춘 뒤에도 마찰로 서서히 멈추는 카드 트랙

세로 휠 한 칸(100)은 WHEEL_GAIN 0.12 를 곱해 속도 12px/스텝이 되고, 이후 매 스텝 FRICTION 0.92 가 곱해져 1056ms 동안 149.4px 를 미끄러진 뒤 멎는다. 트랙 양 끝에서는 나간 거리만큼 입력을 d/(1+o/RUBBER_PX) 로 줄여 고무줄처럼 버티고, 키보드는 speedFor 로 목표 거리에서 정확히 멈추는 처음 속도를 거꾸로 푼다. 렌더 측정은 누적면적 41.4%·강도 109.7 에 움직인 프레임 16/23 — 정지 머무름 구간이 있어 프레임이 쉬어 간다.

세로 휠 값이 가로 이동으로 바뀌고 입력이 멈춘 뒤에도 마찰로 서서히 멈추는 카드 트랙
새 창에서 열기

01 · 세로 휠 값이 가로 이동으로 바뀌고 입력이 멈춘 뒤에도 마찰로 서서히 멈추는 카드 트랙 — 측정 누적면적 41.419% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FRICTIONWHEEL_GAINEDGE_KRUBBER_PX
    // 휠 한 번의 이동량을 px 로 — 줄 단위(deltaMode 1)·쪽 단위(deltaMode 2) 장치도 같은 눈금으로 맞춘다.
    // 가로·세로 중 큰 쪽을 쓴다: 트랙패드의 가로 쓸기와 마우스 휠의 세로 굴림이 같은 방향으로 들어온다
    function wheelPx(ev) {
        const unit = ev.deltaMode === 1 ? 16 : (ev.deltaMode === 2 ? W : 1);
        const d = Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY;
        return d * unit;
    }
    // 끝 밖으로 미는 입력은 나간 거리만큼 줄인다 — 고무줄처럼 버틴다
    function rubber(d) {
        const out = (x <= 0 && d < 0) || (x >= MAX_X && d > 0);
        return out ? d / (1 + Math.abs(overshoot(x)) / RUBBER_PX) : d;
    }
    function push(d) {
        v += rubber(d) * WHEEL_GAIN;
        moving = true;
        say(TXT_GLIDE);
    }
    // 지금 속도로 놓아두면 얼마나 더 가나 — 스텝마다 FRICTION 을 곱하므로 등비급수 v·f/(1−f)
    function glideDistance(v0, f) { return v0 * f / (1 - f); }
    // 거꾸로, distance 만큼 가서 멈추려면 처음 속도가 얼마여야 하나
    function speedFor(distance, f) { return distance * (1 - f) / f; }
    function canMove(d) { return d > 0 ? x < MAX_X - 0.5 : x > 0.5; }

02섹션이 화면에 고정된 채 세로 진행률이 패널별 가로 이동으로 나뉘어 바뀌는 고정 패널 장면

세로 진행률 0 부터 1 까지를 패널 세 개 구간으로 나누고, 구간 안에서도 앞뒤 18%(HOLD) 는 머문 뒤 가운데 64% 를 smoothstep 으로 건너서 화면은 한 패널씩 계단처럼 옮겨 간다. 휠 100px 은 진행률 0.09 를 더하고 화면은 PIN_LERP 0.12 로 따라가므로 128ms 의 추종 지연이 붙는다. 측정은 누적면적 50.4%·강도 94.1·움직인 프레임 23/23 이다.

섹션이 화면에 고정된 채 세로 진행률이 패널별 가로 이동으로 나뉘어 바뀌는 고정 패널 장면
새 창에서 열기

02 · 섹션이 화면에 고정된 채 세로 진행률이 패널별 가로 이동으로 나뉘어 바뀌는 고정 패널 장면 — 측정 누적면적 50.447% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PANEL_NHOLDP_GAINPIN_LERP
    // 진행률 p(0~1) → 패널 위치(0~PANEL_N−1). 구간마다 앞뒤 HOLD 만큼 머물고 가운데를 smoothstep 으로 건넌다
    function panelPos(p) {
        const segs = PANEL_N - 1;
        const s = clamp(p, 0, 1) * segs;
        const k = Math.min(Math.floor(s), segs - 1);
        const u = s - k;
        return k + smooth((u - HOLD) / (1 - 2 * HOLD));
    }
    function canMove(d) { return d > 0 ? target < 1 : target > 0; }

03끌었다 놓으면 놓는 순간의 속도로 도착 지점을 미리 계산해 가장 가까운 칸에 스프링으로 붙는 드래그 스냅

끌다 놓는 순간 predictSlot 이 round((x + v·0.2)/232) 로 도착 칸을 먼저 정하고, 그 칸을 향해 a = 120·(목표−x) − 14·v 스프링이 붙는다 — 감쇠비 0.639 라 넘침은 5.5%, 수렴은 912ms. 휠은 속도를 남기지 않아 마지막 굴림 140ms 뒤 지금 자리에서 가장 가까운 칸으로 붙고, 플링은 드래그에서만 생긴다. 측정은 누적면적 46.5%·강도 83.3·움직인 프레임 21/23.

끌었다 놓으면 놓는 순간의 속도로 도착 지점을 미리 계산해 가장 가까운 칸에 스프링으로 붙는 드래그 스냅
새 창에서 열기

03 · 끌었다 놓으면 놓는 순간의 속도로 도착 지점을 미리 계산해 가장 가까운 칸에 스프링으로 붙는 드래그 스냅 — 측정 누적면적 46.493% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TAUSNAP_KSNAP_CPITCH
    // 끝 밖으로 끈 거리는 줄여서 보여 준다 — 고무줄
    function rubberClamp(p) {
        if (p < 0) {
            return p / (1 - p / RUBBER_PX);
        }
        if (p > MAX_X) {
            const o = p - MAX_X;
            return MAX_X + o / (1 + o / RUBBER_PX);
        }
        return p;
    }
    // 놓는 순간의 속도로 τ 초 뒤 위치를 미리 내다보고 가장 가까운 칸을 고른다
    function predictSlot(x0, v0) {
        return clamp(Math.round((x0 + v0 * TAU) / PITCH), 0, CARD_N - 1);
    }
    // 스프링·댐퍼 한 스텝 — 가속 = K·(목표−위치) − C·속도 (반암시 오일러)
    function springStep(dt) {
        const a = SNAP_K * (target - x) - SNAP_C * v;
        v += a * dt;
        x += v * dt;
    }
    function canMove(d) { return d > 0 ? x < MAX_X - 0.5 : x > 0.5; }

04가로로 늘어선 패널이 진행에 따라 병풍처럼 접혔다 펴지고 접힌 면마다 빛을 받는 각도로 밝기가 달라지는 병풍 패널

여섯 패널의 접힘 각 θ 는 진행률 하나로 정해지고, 꼭짓점은 persp(z) = 420/(420+z) 로 원근 투영되어 사다리꼴 면이 된다. 면의 밝기는 법선과 광원 방향 (−0.6, −0.3, −0.75) 의 내적으로 매겨져 다 접혔을 때 짝수 면 0.3·홀수 면 0.961, 다 펼치면 여섯 면이 전부 0.686 으로 같아진다. 렌더 값은 누적면적 41.9%·강도 59.5·움직인 프레임 21/23 이다.

가로로 늘어선 패널이 진행에 따라 병풍처럼 접혔다 펴지고 접힌 면마다 빛을 받는 각도로 밝기가 달라지는 병풍 패널
새 창에서 열기

04 · 가로로 늘어선 패널이 진행에 따라 병풍처럼 접혔다 펴지고 접힌 면마다 빛을 받는 각도로 밝기가 달라지는 병풍 패널 — 측정 누적면적 41.942% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MAX_FOLDFOCALYAWAMBIENT
    // 면 i 의 법선과 빛의 내적 → 밝기. 짝수 면은 오른쪽 안으로, 홀수 면은 오른쪽 앞으로 꺾인다
    function panelLight(i, th) {
        const n = yawRot((i % 2 === 0 ? 1 : -1) * Math.sin(th), -Math.cos(th));
        const dot = n[0] * LIGHT[0] + n[1] * LIGHT[2];
        return AMBIENT + DIFFUSE * Math.max(0, dot);
    }
    // 면 i 위의 점(u: 0 왼쪽~1 오른쪽) → 돌리기 전 가로 위치와 깊이
    function panelLocal(i, u, th) {
        const run = PANEL_W * Math.cos(th);
        const rise = PANEL_W * Math.sin(th);
        const total = PANEL_N * run;
        const X = W / 2 - total / 2 + (i + u) * run;
        const Z = i % 2 === 0 ? u * rise : (1 - u) * rise;
        return yawRot(X - W / 2, Z);
    }

05능선을 노이즈로 그린 산·언덕·나무 층이 깊이에 따라 다른 속도로 옆으로 흐르는 가로 시차 풍경

능선 높이는 정수 격자 해시(hash1)로 만든 값노이즈를 네 옥타브 겹쳐 0 부터 1 로 정규화한 fBm 이라, scroll 이 어떤 실수여도 0.25px 걸음의 최대 점프가 0.337px 인 이음매 없는 곡선이 된다. 다섯 층은 깊이 [8, 5, 3.2, 2, 1.2] 로 이동량을 나눠 가져, scroll 1000px 에 층별 이동은 125px 부터 833.3px 까지 6.67배 차이로 흐른다. 측정은 누적면적 37.1%·강도 39.3·움직인 프레임 23/23.

능선을 노이즈로 그린 산·언덕·나무 층이 깊이에 따라 다른 속도로 옆으로 흐르는 가로 시차 풍경
새 창에서 열기

05 · 능선을 노이즈로 그린 산·언덕·나무 층이 깊이에 따라 다른 속도로 옆으로 흐르는 가로 시차 풍경 — 측정 누적면적 37.122% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SEEDOCTAVESDEPTHSFREQS
    // 정수 격자 한 칸의 값 — 시드가 같으면 언제나 같은 수 (정수 섞기 해시)
    function hash1(n, seed) {
        let h = (Math.imul(n | 0, 374761393) + Math.imul(seed, 668265263)) | 0;
        h = Math.imul(h ^ (h >>> 13), 1274126177);
        h ^= h >>> 16;
        return (h >>> 0) / 4294967296;
    }
    // 1D 값 노이즈 — 격자값 두 개를 smoothstep 으로 잇는다. 격자가 끝없이 이어지므로 이음매가 없다
    function vnoise(x, seed) {
        const i = Math.floor(x);
        const f = x - i;
        const u = f * f * (3 - 2 * f);
        return lerp(hash1(i, seed), hash1(i + 1, seed), u);
    }
    // fBm — 주파수를 2배, 세기를 절반씩 OCTAVES 겹 쌓아 0~1 로 맞춘다
    function fbm(x, seed) {
        let sum = 0;
        let amp = 0.5;
        let freq = 1;
        let norm = 0;
        for (let o = 0; o < OCTAVES; o += 1) {
            sum += amp * vnoise(x * freq, seed + o * 101);
            norm += amp;
            amp *= 0.5;
            freq *= 2;
        }
        return sum / norm;
    }

06카드들이 원통 둘레에 놓여 가로 이동량만큼 돌고 원근 투영으로 크기·밝기가 바뀌며 앞뒤 순서가 다시 정해지는 원통 갤러리

카드마다 원통 위 각도를 두고 s = 270/(520 − 250·cosφ) 로 화면 위치와 배율을 구하니, 앞카드 1.000·옆 0.519·뒤 0.351 로 크기가 저절로 바뀐다. facing 이 카메라를 향하는 한계는 cosφ 가 250/520 를 넘는 ±61.3° 이고(그 너머는 등을 보인 면으로 그린다), 밝기는 (cosφ+1)/2 로 0.35(바로 뒤)와 1(맨 앞) 사이를 섞어 뒤로 갈수록 어두워지며, 매 프레임 cosφ 오름차순(먼 것부터)으로 그리기 순서가 다시 정해진다. 측정은 누적면적 28.8%·강도 87.3·움직인 프레임 17/23.

카드들이 원통 둘레에 놓여 가로 이동량만큼 돌고 원근 투영으로 크기·밝기가 바뀌며 앞뒤 순서가 다시 정해지는 원통 갤러리
새 창에서 열기

06 · 카드들이 원통 둘레에 놓여 가로 이동량만큼 돌고 원근 투영으로 크기·밝기가 바뀌며 앞뒤 순서가 다시 정해지는 원통 갤러리 — 측정 누적면적 28.848% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RADIUSCAM_DSTEP_ANGBACK_DIM
    // 원통 위 각도 φ 의 점 → 화면 x 와 원근 배율. 카메라는 축에서 CAM_D 떨어져 있고 맨 앞 카드가 배율 1
    function project(phi) {
        const X = RADIUS * Math.sin(phi);
        const Z = CAM_D - RADIUS * Math.cos(phi);
        const s = (CAM_D - RADIUS) / Z;
        return [W / 2 + X * s, s];
    }
    // 높이 h(카드 가운데 0, 아래 +) 의 화면 y — 카메라가 LIFT 만큼 위에 있어 먼 카드일수록 지평선(HORIZON) 쪽으로 올라간다
    function screenY(h, s) { return HORIZON + (h + LIFT) * s; }
    // 카메라 쪽을 보고 있는가 — 바깥 법선과 카메라 방향의 내적 = CAM_D·cosφ − R
    function facing(phi) { return CAM_D * Math.cos(phi) - RADIUS; }
    function canMove(d) { return d > 0 ? rot < MAX_ROT - 0.001 : rot > 0.001; }

07줄로 이어진 카드들이 앞 카드를 따라 옆으로 끌려가며 출렁이다 가라앉는 줄 매달린 카드 행렬

줄의 마디 49개는 속도 배열 없이 (현재−이전) 위치 차를 속도로 쓰는 Verlet 적분이고, 이웃 거리가 REST 보다 길어지면 되돌리는 제약을 스텝마다 10번 돌려 줄 길이를 지킨다. 시작할 때 rest(240) 으로 240스텝을 미리 풀어 최대 처짐 57.9px 의 안정된 모양으로 시작하며, 카드는 제 자리 마디의 기울기 atan2 만큼 돌아간다. 측정은 누적면적 50.2%·강도 119.4 — 아홉 장면 중 가장 세고, 움직인 프레임 23/23.

줄로 이어진 카드들이 앞 카드를 따라 옆으로 끌려가며 출렁이다 가라앉는 줄 매달린 카드 행렬
새 창에서 열기

07 · 줄로 이어진 카드들이 앞 카드를 따라 옆으로 끌려가며 출렁이다 가라앉는 줄 매달린 카드 행렬 — 측정 누적면적 50.2% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SLACKGRAVITERCARD_WEIGHT
    function relax() {
        for (let it = 0; it < ITER; it += 1) {
            for (let i = 0; i < NODE_N; i += 1) {
                if (isPost(i)) {
                    px[i] = postX(i);
                    py[i] = CORD_Y;
                }
            }
            for (let i = 0; i < NODE_N - 1; i += 1) {
                const dx = px[i + 1] - px[i];
                const dy = py[i + 1] - py[i];
                const d = Math.sqrt(dx * dx + dy * dy) || 1;
                if (d <= REST) {
                    continue;
                }
                const diff = (d - REST) / d;
                const a = isPost(i) ? 0 : (isPost(i + 1) ? 1 : 0.5);
                const b = isPost(i + 1) ? 0 : (isPost(i) ? 1 : 0.5);
                px[i] += dx * diff * a;
                py[i] += dy * diff * a;
                px[i + 1] -= dx * diff * b;
                py[i + 1] -= dy * diff * b;
            }
        }
    }
    // 집게 자리의 줄 기울기(라디안) — 양옆 이웃 점을 잇는 선의 각도
    function slopeAt(i) {
        const a = Math.max(0, i - 1);
        const b = Math.min(NODE_N - 1, i + 1);
        return Math.atan2(py[b] - py[a], px[b] - px[a]);
    }

08트랙 양 끝에 다가간 카드가 어안 렌즈처럼 휘어 작아지고 가운데에서는 평평하게 펴지는 가장자리 렌즈

원본 아이콘 줄(1730px)을 시작할 때 한 번 그려 getImageData 로 읽어 두고(이후 판독 0회), 장면을 다시 그릴 때는 화면 14.4만 픽셀마다 lensSample 의 렌즈 곡선으로 원본 위치를 거꾸로 찾아 색을 가져온다. 세로는 확대율 lensScale 로 나누고 BEND·u² 처짐을 더해 볼록면을 흉내 내며, 가장자리 배율은 0.488 로 중앙의 평평한 띠 156px 와 대비된다. 측정은 누적면적 42.5%·강도 72.6·움직인 프레임 22/23.

트랙 양 끝에 다가간 카드가 어안 렌즈처럼 휘어 작아지고 가운데에서는 평평하게 펴지는 가장자리 렌즈
새 창에서 열기

08 · 트랙 양 끝에 다가간 카드가 어안 렌즈처럼 휘어 작아지고 가운데에서는 평평하게 펴지는 가장자리 렌즈 — 측정 누적면적 42.474% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LENS_KLENS_PBENDPITCH
    // 화면 가로 위치 u(−1~1) → 원본 표본 위치 s. s = u·(1 + K·|u|^P) — 가장자리일수록 원본을 더 많이 눌러 담는다
    function lensSample(u, k) { return u * (1 + k * Math.pow(Math.abs(u), LENS_P)); }
    // 그 자리의 확대율 du/ds — 가운데 1, 가장자리 1/(1 + K·(P+1))
    function lensScale(u, k) { return 1 / (1 + k * (LENS_P + 1) * Math.pow(Math.abs(u), LENS_P)); }
    // 렌즈 세기 k 일 때 화면 반쪽(240px)이 담는 원본 폭의 반
    function halfSpan(k) { return (W / 2) * (1 + k); }
        for (let x = 0; x < W; x += 1) {
            const u = (x + 0.5 - W / 2) / (W / 2);
            const sx = Math.round(center + lensSample(u, kNow) * (W / 2));
            const sc = lensScale(u, kNow);
            const sag = BEND * u * u * (kNow / LENS_K);
            for (let y = 0; y < H; y += 1) {
                const sy = Math.round(CY + (y - CY - sag) / sc);
                const o = (y * W + x) * 4;
                if (sx < 0 || sx >= SRC_W || sy < 0 || sy >= H) {
                    dst[o] = bgR;
                    dst[o + 1] = bgG;
                    dst[o + 2] = bgB;
                    dst[o + 3] = 255;
                    continue;
                }
                const i = (sy * SRC_W + sx) * 4;
                dst[o] = src[i];
                dst[o + 1] = src[i + 1];
                dst[o + 2] = src[i + 2];
                dst[o + 3] = 255;
            }
        }

09글자 띠가 사인 물결 곡선 위를 옆으로 흐르고 글자마다 곡선의 기울기만큼 돌아가며 속도가 붙으면 물결이 높아지는 글자 리본

글자는 사인 곡선 위를 호의 길이로 걸어 배치되어 ds 를 나아갈 때 화면 이동은 ds/√(1+기울기²) 이고, 글자 회전은 그 자리 기울기의 atan 이다. 물결 높이는 속도를 따라가는 저역 통과로 clamp(10 + 0.09|속도|, 10, 58) 목표를 320ms 지연으로 좇으며, 포화에 닿는 속도는 533px/s 이다. 측정은 누적면적 30.3%·강도 84.8·움직인 프레임 23/23.

글자 띠가 사인 물결 곡선 위를 옆으로 흐르고 글자마다 곡선의 기울기만큼 돌아가며 속도가 붙으면 물결이 높아지는 글자 리본
새 창에서 열기

09 · 글자 띠가 사인 물결 곡선 위를 옆으로 흐르고 글자마다 곡선의 기울기만큼 돌아가며 속도가 붙으면 물결이 높아지는 글자 리본 — 측정 누적면적 30.333% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WAVE_KAMP_LERPAMP_GAINSPEED_FRICTION
    // 물결 곡선과 그 도함수(기울기) — 글자 회전각은 atan(기울기)
    function waveY(x) { return CY + amp * Math.sin(WAVE_K * x + phase); }
    function waveSlope(x) { return amp * WAVE_K * Math.cos(WAVE_K * x + phase); }
    // 흐름 속도 → 물결 높이 목표. 실제 높이는 이 값을 저역 통과(AMP_LERP)로 천천히 따라간다
    function ampTarget(speed) { return clamp(AMP_MIN + AMP_GAIN * Math.abs(speed), AMP_MIN, AMP_MAX); }
    function canMove(d) {
        const s = speedNow();
        return d > 0 ? s < MAX_SPEED : s > -MAX_SPEED;
    }
                while (arc < want) {
                    const sl = waveSlope(x);
                    const ds = Math.min(4, want - arc);
                    x += ds / Math.sqrt(1 + sl * sl);
                    arc += ds;
                }
                if (x > W + 40) {
                    done = true;
                    break;
                }
                ctx.save();
                ctx.translate(x, waveY(x));
                ctx.rotate(Math.atan(waveSlope(x)));

마찰·스프링 세기·렌즈 세기 — 숫자 하나가 미끄러짐을 바꾸는 자리

이번 렌더에서 2초 동안 24장을 찍어 잰 세 숫자를 항목 번호로 묶었다. 누적면적은 화면에서 움직임이 지나간 자리의 비중, 강도는 프레임 사이 픽셀 차의 평균, 움직인 프레임은 23개 프레임 간격 가운데 변화가 잡힌 수다. 누적면적 30% 이상이 여덟(06 만 28.8%), 10% 미만은 없다.

항목 누적면적 강도 평균 움직인 프레임
01 휠 관성 트랙 41.4% 109.7 16/23
02 고정 패널 50.4% 94.1 23/23
03 플링 스냅 46.5% 83.3 21/23
04 병풍 패널 41.9% 59.5 21/23
05 시차 능선 37.1% 39.3 23/23
06 원통 갤러리 28.8% 87.3 17/23
07 줄 카드 50.2% 119.4 23/23
08 가장자리 렌즈 42.5% 72.6 22/23
09 물결 리본 30.3% 84.8 23/23

마찰 계수는 등비급수의 공비다

01 의 glideDistance 는 매 스텝 속도에 FRICTION f 를 곱하므로 총 미끄러짐이 v₀·f/(1−f) 인 등비급수다. 휠 한 칸이 만드는 v₀ 12px/스텝에 f 0.92 를 대입하면 연속 계산으로 138px, 16ms 스텝 시뮬레이션으로는 149.4px(1056ms) 로 이산 스텝이 조금 더 길다. 같은 식을 거꾸로 푼 speedFor 가 키보드 한 칸을 정확히 132px 채우게 만든다.

스프링 세기를 올리면 넘침이 커진다

03 의 스프링은 a = SNAP_K·(목표−x) − SNAP_C·v 두 항뿐이다. K 120·C 14 의 감쇠비는 0.639 로 넘침 5.5%·수렴 912ms 인데, K 만 320 으로 올리면 고유 진동수가 √(320/120) ≈ 1.63배가 되는 대신 감쇠비가 0.391 로 내려앉아 넘침이 24.1% 로 커진다 — 세게 당기는 맛과 튕기는 맛은 이 한 수로 갈린다.

렌즈 곡선은 가장자리를 눌러 담는다

08 의 표본 위치 s = u·(1+K·|u|²) 는 화면 가운데(u 0)에서 원본을 그대로 읽고 가장자리(u ±1)에서 1+K 배를 눌러 담는다. 확대율은 그 도함수의 역수인 1/(1+3K·|u|²) 라 K 0.35 에서 가장자리 배율 0.488·중앙 평평한 띠 156px·화면이 담는 원본 648px 로 계산되고, 원본 픽셀은 getImageData로 시작할 때 한 번만 읽는다.

트랙패드와 휠이 섞일 때·카드가 수십 장일 때·탭이 숨었을 때 깨지는 곳

아홉 장면을 만지며 소스와 프로브로 확인한 무너짐 지점을 정리한다.

트랙패드와 휠이 같은 배율로 만나지 않는다

wheelPx 가 가로·세로 큰 쪽을 고르는 것과 별개로, 트랙패드의 가로 쓸기는 deltaX 가 한 이벤트에 수십에서 수백 px 로 들어와 마우스 휠(한 칸 100 안팎)보다 같은 gain 에서 훨씬 빠르다. 정석은 gain 을 낮추거나 스텝당 이동에 상한을 두는 것이고, WHEEL_GAIN 을 크게 잡으면 휠 한 칸에 트랙 절반을 건너뛴다.

끝에서 세로 스크롤로 돌려주는 조건

canMove(d) 가 끝 방향에서 false 를 내면 리스너는 그냥 돌아가 preventDefault 를 부르지 않고, wheel 이벤트는 페이지로 흘러 세로 스크롤이 이어진다 — 프로브로 잰 pass/blocked 결과가 이 설계의 증거다. 09 만 예외가 하나 더 있어 속도가 이미 ±720(±MAX_SPEED) 에 닿아 있으면 가로채지 않고, 02 는 진행률이 0 이나 1 에 닿은 순간 고정이 풀리며 wheel 이 페이지로 넘어간다.

카드가 수십 장이 되면 먼저 무너지는 셋

01 은 MAX_X 가 카드 수만큼 커져 진행 막대 폭 trackW·W/(MAX_X+W) 이 12장의 128px 에서 40장이면 39px 로 줄고, 수백 장이면 그리기 하한 10px 에 붙는다. 06 은 dpr 3 이상 화면에서 다각형 채우기 카드 12장의 그리기 비용이 커지고, 08 은 다시 그릴 때마다 14.4만 픽셀을 리맵하므로 무대를 키우면 비용이 그만큼 선형으로 는다 — 원본 폭 SRC_W 1730 도 고정이라 그보다 넓은 무대에서는 오른쪽 끝이 배경색으로 채워진다.

탭이 숨으면 숨은 시간은 버려진다

프레임당 흘려보내는 시간이 100ms 로 막히고 한 번에 최대 6스텝만 진행하므로, 탭을 오래 숨겨 둔 뒤 돌아와도 장면이 폭주하지 않는다. 대신 숨겨 둔 시간은 따라잡지 않고 버려진다.

값이 한계를 넘으면 곧바로 망가지는 자리

FRICTION 을 1 이상으로 올리면 등비급수가 발산해 트랙이 멈추지 않고, 04 의 MAX_FOLD 를 90도 넘기면 접힌 면이 서로 겹치며, 06 은 RADIUS 가 CAM_D 에 닿으면 투영 분모가 0 이하가 되는 각도가 생긴다. 05 의 깊이 값에 0 이 들어가면 이동량 나눗셈이 발산하고, 07 은 SLACK 을 1.0 이하로 내리면 줄이 처지지 않아 카드 무게를 못 받는다. 09 는 높이 상한 58 에서 글자 기울기가 54.5도 까지 세워져 가독성이 무너진다. 반대로 03 은 포인터가 취소되어도(pointercancel) release 로 이어지므로 스프링이 제자리를 잡아 망가지지 않는다.

여기까지의 상수를 직접 바꿔 어디가 무너지는지 보려면 zip 을 열어야 하는데, 여는 데 필요한 값은 gfvx7uu4 이다.

세 가지 값 바꿔 보기, 그리고 옆으로 흐르는 화면의 접근성

이름 값 느낌
휠 관성 마찰 바꾸기 01 FRICTION 0.92 → 0.97 미끄러짐이 149.4px/1056ms 에서 398.3px/2880ms 로 약 2.7배 늘고 등비 배수는 11.5 에서 32.7 로 간다.
스냅 스프링 세기 바꾸기 03 SNAP_K 120 → 320 감쇠비 0.639 → 0.391, 넘침 5.5% → 24.1%, 수렴은 912ms → 880ms 로 거의 그대로다.
가장자리 렌즈 세기 바꾸기 08 LENS_K 0.35 → 0.8 가장자리 배율 0.488 → 0.294(타일 폭 44.9 → 27.1px), 평평한 띠 156 → 104px, 담는 원본 648 → 864px.

표의 숫자는 렌더 측정이 아니라 소스의 함수를 노드에서 직접 불러 시뮬레이션한 값이라 위 측정 표와 따로 읽어야 한다.

키보드만으로 아홉 장면을 전부 굴릴 수 있다 — <canvas> 마다 tabindex="0" 과 항목 이름의 aria-label 이 있고, ←/→ 로 굴리며 02 는 ↑/↓ 를, 01 부터 08 까지 여덟 장면은 Home/End 도 받는다(09 는 ←/→ 만). 버튼은 장면마다 한두 개이고 08 의 렌즈 토글과 09 의 멈춤 토글은 aria-pressed 로 켜짐을 알린다. 상태 알약의 aria-live 는 자동 시연 중 off 다가 사람이 처음 만지는 순간 polite 로 바뀐다(프로브 실측 9/9). prefers-reduced-motion을 켠 사람에게는 requestAnimationFrame 루프를 아예 시작하지 않고 정지 장면을 한 번 그리는데, 이때도 휠·키·드래그·버튼 입력은 즉시 반영된다(프로브 실측 9/9, 07 은 흔들기만 무시). CSS 쪽 reduce 블록은 버튼 transition 을 지우는 한 줄이 전부다.

css 마우스 휠 가로 스크롤은 자바스크립트 없이 되는가?

정직한 답은 이 장면들은 안 된다는 쪽이다. 휠의 세로 값을 가로 이동으로 돌리는 순간부터 wheel 이벤트를 듣고 조건부로 preventDefault 하는 스크립트가 필요하고, 관성·스냅·투영·렌즈는 매 프레임 계산이라 CSS 선언으로 적을 수가 없다. 세로 진행률을 가로 위치로 옮기기만 하는 02 형태만 부분적으로 가능한 축에 속하는데, 슬라이더와 캐러셀을 CSS 만으로 만든 css only 슬라이더 편이 그 이웃이고 나머지 여덟 장면은 그대로 캔버스 스크립트의 영역이다. 스크롤 물리를 더 파고 싶으면 스크롤 관성 물리 패턴으로, 시차 배경은 css 패럴랙스 스크롤로 이어서 보면 된다.

가로 스크롤 웹 사이트에 옮겨 쓰기 전에 자주 묻는 질문

css 가로 스크롤 드래그는 어느 장면이 맡고 있나요?

03 이 드래그 플링 전용이다 — 놓는 순간의 속도로 착지 칸을 예측해 스프링으로 붙는다. 02 를 뺀 여덟 장면도 포인터 끌기를 받고 01 은 놓을 때 끌던 속도(±60 으로 자름)를 관성으로 잇지만, 그 속도로 착지 칸을 미리 고르는 것은 03 뿐이다.

카드 수를 크게 늘려도 그대로 돌아가나요?

그대로는 안 되고 상수를 다시 잡아야 한다. 01 은 트랙 길이가 늘며 진행 막대가 줄어 수백 장이면 하한 10px 에 붙고, 08 은 원본 폭 SRC_W 가 타일 수로 계산돼(16장이면 1730px) 장 수와 함께 늘지만, 무대가 그보다 넓으면 오른쪽이 배경색으로 남는다. 03 처럼 칸 폭이 고정된 장면은 카드 수만 늘리면 된다.

모바일 터치에서는 세로 스크롤과 어떻게 나뉘나요?

캔버스에 touch-action: pan-y 가 걸려 있어 손가락 가로 끌기는 장면이 받고 세로 쓸기는 브라우저로 넘어간다(9/9). 페이지의 세로 스크롤을 잡아먹지 않으니 본문 흐름 위에 얹어 쓸 수 있다.

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

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