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

css 구분선 9가지 — 양 끝이 사라지고 글자 폭에 맞춰 선이 나뉘는 구분선 장면

css 구분선은 선 한 줄이면 될 것 같지만, 양 끝이 뚝 잘려 어색하고 가운데 글자 폭에 선 길이가 안 맞고 섹션 경계가 직선이라 밋밋하다 — 01번·02번·04번이 하나씩 푼다. 이 편의 아홉 구분선은 선의 모양을 값으로 계산해 그린다.

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

css 구분선 9가지 — 선 모양을 계산해야 끝이 어색하지 않다

아홉 장면은 계산이 단순한 것부터 놓았다. 01 은 양 끝 알파 곡선 하나, 02 는 글자 폭을 뺀 길이 나누기, 03 은 스크롤 거리 비율이고, 04 와 05 는 사인과 시드 노이즈로 섹션 경계를 그린다. 06 은 점 크기의 종 곡선, 07 은 줄바꿈 판정, 08 은 포인터 거리, 09 는 스크롤 하나로 파도·선·번짐을 함께 움직이는 장면이다.

이런 화면에서 쓴다

랜딩 페이지의 섹션 사이는 01·04·09 가, 로그인 화면의 「또는」 줄처럼 글자가 끼는 자리는 02 가 맡는다. 메뉴나 태그 목록 사이의 css 세로 구분선은 07 이 다룬다. css after 구분선 처럼 가상 요소에 선을 맡기면 줄이 바뀐 항목 앞에도 선이 남는데, 07 은 ::before 선을 JS 판정으로 숨겨서 푼다. 글 읽기 화면은 03, 장식이 있는 소개 페이지는 06·08 이 어울린다.

이웃 편과 무엇이 다른가

멤피스 디자인 패턴 랜딩 키트, 점·물결 섹션 9개는 점과 물결 무늬를 한 장의 디자인으로 얹는 편이고, 이 편은 선과 경계의 모양을 숫자로 정한다. 화면이 바뀌는 순간을 다루는 CSS 화면 전환 효과 9종 — 화면이 바뀌는 순간 모음과 스크롤로 장면을 잇는 스크롤 스토리텔링 섹션 9종 — 복붙하는 CSS 장면 구성은 구분선을 한 요소로 쓰지 않는다.

기법별 코드로 보는 구분선

코드는 라이브러리 없이 일반 JS 와 CSS 로만 쓰였고, 03·09 의 스크롤 반응은 scroll 이벤트로 받는다. 03 은 getBoundingClientRect 로, 09 는 scrollTop 으로 진행도를 구하고 어느 장면도 IntersectionObserver 는 쓰지 않는다. 아래에는 각 장면에서 모양을 정하는 계산 구간만 소스 그대로 옮겼다.

01양 끝이 투명하게 사라지는 기본 구분선

양 끝 30% 구간에서만 알파가 0 에서 1 로 오르고, 직선 보간 대신 smoothstep 곡선을 쓰므로 선이 시작하는 자리에 띠가 생기지 않게 한다. 정지점을 위아래로 아홉 개씩 문자열로 만들어 같은 그라디언트를 선과 그 뒤의 번짐에 함께 준다. 시연 중 FADE_PCT 가 6 에서 50 사이를 오가며 움직인 프레임은 23개 중 23개다.

양 끝이 투명하게 사라지는 기본 구분선
새 창에서 열기

01 · 양 끝이 투명하게 사라지는 기본 구분선 — 측정 누적면적 20.444% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FADE_PCTLINE_H_PX
  function gradientOf(pct) {
    var stops = [];
    var i;
    for (i = 0; i <= STEPS; i += 1) {
      var t = i / STEPS;
      var a = t * t * (3 - 2 * t);
      stops.push('rgba(' + RGB + ',' + a.toFixed(3) + ') ' + (pct * t).toFixed(2) + '%');
    }
    for (i = STEPS; i >= 0; i -= 1) {
      var u = i / STEPS;
      var b = u * u * (3 - 2 * u);
      stops.push('rgba(' + RGB + ',' + b.toFixed(3) + ') ' + (100 - pct * u).toFixed(2) + '%');
    }
    return 'linear-gradient(90deg,' + stops.join(',') + ')';
  }

02가운데 글자 폭에 맞춰 양쪽 선이 나뉘는 구분선

글자 폭을 getBoundingClientRect 로 재고, 줄 폭에서 글자 폭과 글자 양옆 간격 12px 씩을 뺀 나머지를 반으로 갈라 두 선의 width 로 준다. 글자가 바뀌어도 선이 한쪽으로 쏠리지 않고 카드 폭이 180 에서 420 사이를 오가도 매 프레임 다시 계산한다. 움직인 프레임은 23/23 이다.

가운데 글자 폭에 맞춰 양쪽 선이 나뉘는 구분선
새 창에서 열기

02 · 가운데 글자 폭에 맞춰 양쪽 선이 나뉘는 구분선 — 측정 누적면적 15.208% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LABEL_GAPLINE_H_PX
  function layout() {
    var rowW = row.getBoundingClientRect().width;
    var labelW = label.getBoundingClientRect().width;
    var side = Math.max(0, (rowW - labelW - LABEL_GAP * 2) / 2);
    left.style.width = side.toFixed(1) + 'px';
    right.style.width = side.toFixed(1) + 'px';
    pxEl.textContent = String(Math.round(side));
  }

03스크롤 위치에 따라 가운데에서 양쪽으로 그려지는 구분선

스크롤 이벤트가 올 때마다 선이 영역 바닥에서 올라온 거리를 재서 80px 로 나누고, 그 비율만큼 clip-path 를 양쪽에서 연다. 선이 가운데에서 양옆으로 그려지고 위로 되돌리면 다시 줄어든다. 시연에서는 코드가 scrollTop 을 움직였고 움직인 프레임은 23/23 이다.

스크롤 위치에 따라 가운데에서 양쪽으로 그려지는 구분선
새 창에서 열기

03 · 스크롤 위치에 따라 가운데에서 양쪽으로 그려지는 구분선 — 측정 누적면적 29.984% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DRAW_RANGE_PXDRAW_MIN_PCT
  function update() {
    var box = scroller.getBoundingClientRect();
    var lineBox = line.getBoundingClientRect();
    var risen = box.bottom - lineBox.top;
    var p = Math.min(1, Math.max(0, risen / DRAW_RANGE_PX));
    var pct = DRAW_MIN_PCT + (100 - DRAW_MIN_PCT) * p;
    var edge = (100 - pct) / 2;
    line.style.clipPath = 'inset(0 ' + edge.toFixed(2) + '% 0 ' + edge.toFixed(2) + '% round 6px)';
    pctEl.textContent = String(Math.round(pct));
  }

04사인 두 겹을 더해 만든 파도 섹션 경계

주기 190 과 83 인 사인 두 개를 더해 경계 높이를 정하고, 4px 간격으로 찍은 점을 이어 SVG path 의 d 를 직접 만든다. 두 주기가 약분되지 않아 합친 곡선은 화면 폭 안에서 같은 모양이 되풀이되지 않고, 버튼으로 파도 겹이 1 에서 3 까지 바뀐다. 움직인 프레임은 23/23 이다.

사인 두 겹을 더해 만든 파도 섹션 경계
새 창에서 열기

04 · 사인 두 겹을 더해 만든 파도 섹션 경계 — 측정 누적면적 11.55% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WAVE_LAYERSWAVE_AMPWAVE_PERIOD
  function edgeY(x) {
    var y = BASE_Y;
    for (var i = 0; i < WAVE_LAYERS; i += 1) {
      y += WAVE_AMP[i] * Math.sin(2 * Math.PI * x / WAVE_PERIOD[i] + phase * WAVE_SPEED[i]);
    }
    return y;
  }
  function draw() {
    var d = 'M0 0H' + VIEW_W + 'V' + edgeY(VIEW_W).toFixed(1);
    for (var x = VIEW_W - STEP_PX; x >= 0; x -= STEP_PX) d += 'L' + x + ' ' + edgeY(x).toFixed(1);
    d += 'L0 ' + edgeY(0).toFixed(1) + 'Z';
    pathEl.setAttribute('d', d);
    layersEl.textContent = String(WAVE_LAYERS);
  }

05고정 시드 노이즈로 들쭉날쭉하게 자른 찢긴 종이 경계

고정 시드 20261008 을 직접 만든 난수 함수에 넣어 256칸 값 표를 한 번 만들고, 큰 굴곡과 칸별 잔떨림을 섞어 꼭짓점 높이를 정한 뒤 clip-path polygon 으로 시트를 자른다. 같은 폭에서는 새로고침해도 같은 모양이 나온다. 찢김이 200 에서 1600ms 사이에 끝나고 그 뒤 정지해서 움직인 프레임은 19/23 이다.

고정 시드 노이즈로 들쭉날쭉하게 자른 찢긴 종이 경계
새 창에서 열기

05 · 고정 시드 노이즈로 들쭉날쭉하게 자른 찢긴 종이 경계 — 측정 누적면적 0.949% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TORN_SEEDTORN_STEPTORN_AMP
  function mulberry32(seed) {
    var a = seed >>> 0;
    return function () {
      a = (a + 0x6D2B79F5) >>> 0;
      var t = a;
      t = Math.imul(t ^ (t >>> 15), t | 1);
      t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
  }
  var rand = mulberry32(TORN_SEED);
  function noiseAt(k) {
    var big = k / 4;
    var i0 = Math.floor(big);
    var f = smooth(big - i0);
    var coarse = table[i0 % 256] * (1 - f) + table[(i0 + 1) % 256] * f;
    var fine = table[(k * 7 + 3) % 256];
    return 0.65 * coarse + 0.35 * fine;
  }

06가운데가 가장 큰 점 구분선과 한 번 지나가는 반짝임

점 지름을 종 모양 가우스 곡선으로 정해 가운데 점이 32px 로 가장 크고 양 끝은 4px 로 줄어든다. 그 위로 밝은 점 하나가 한 번 지나가며 점마다 흰색을 섞고 최대 30% 키운다. 반짝임이 200 에서 1600ms 에만 있어 움직인 프레임은 19/23 이다.

가운데가 가장 큰 점 구분선과 한 번 지나가는 반짝임
새 창에서 열기

06 · 가운데가 가장 큰 점 구분선과 한 번 지나가는 반짝임 — 측정 누적면적 4.761% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DOT_COUNTDOT_MAX_PXGLINT_MS
  for (i = 0; i < DOT_COUNT; i += 1) {
    var d = document.createElement('span');
    d.className = 'dv__dot';
    host.appendChild(d);
    dots.push(d);
    var gauss = Math.exp(-Math.pow(i - center, 2) / (2 * sigma * sigma));
    sizes.push(Math.max(DOT_MIN_PX, DOT_MAX_PX * gauss));
  }
  function paint(g) {
    for (var k = 0; k < DOT_COUNT; k += 1) {
      var b = g === null ? 0 : Math.exp(-Math.pow(k - g, 2) / (2 * GLINT_WIDTH * GLINT_WIDTH));
      var px = sizes[k] * (1 + 0.3 * b);
      var c = [0, 1, 2].map(function (n) { return Math.round(BASE[n] + (LIGHT[n] - BASE[n]) * b); });
      var el = dots[k];
      el.style.width = px.toFixed(1) + 'px';
      el.style.height = px.toFixed(1) + 'px';
      el.style.background = 'rgb(' + c.join(',') + ')';
    }
  }

07줄이 바뀌어도 맨 앞에 남지 않는 세로 구분선

각 항목 앞에 세로 선을 ::before 로 달고, JS 가 offsetTop 을 앞 항목과 비교해 줄이 바뀐 항목에 is-first 를 붙여 선을 숨긴다. 줄 맨 앞인 항목은 CSS 가 아니라 JS 가 가려내므로 폭이 바뀔 때마다 다시 판정한다. 카드 폭이 150 에서 336 사이를 오가며 움직인 프레임은 23/23 이다.

줄이 바뀌어도 맨 앞에 남지 않는 세로 구분선
새 창에서 열기

07 · 줄이 바뀌어도 맨 앞에 남지 않는 세로 구분선 — 측정 누적면적 29.527% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SEP_GAPSEP_H_PX
  function mark() {
    var rows = 0;
    var lastTop = null;
    items.forEach(function (li) {
      var top = li.offsetTop;
      var first = lastTop === null || top !== lastTop;
      if (first) rows += 1;
      li.classList.toggle('is-first', first);
      lastTop = top;
    });
    rowsEl.textContent = String(rows);
  }

08마우스가 가까워질수록 장식이 열리며 선이 벌어지는 구분선

포인터와 장식 중심 사이 거리를 120px 로 나눠 목표 열림을 만들고, 현재 열림은 매 프레임 남은 거리의 일부씩만 따라가 포인터보다 조금 늦게 열린다. 열림에 따라 선 두 쪽이 양옆으로 벌어지고 장식은 돌면서 커진다. 가상 포인터가 좌우로 오가는 시연에서 움직인 프레임은 23/23 이다.

마우스가 가까워질수록 장식이 열리며 선이 벌어지는 구분선
새 창에서 열기

08 · 마우스가 가까워질수록 장식이 열리며 선이 벌어지는 구분선 — 측정 누적면적 2.326% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

NEAR_RANGEOPEN_RATEOPEN_MAX_PX
  function smooth(t) { return t * t * (3 - 2 * t); }
  function paint() {
    var half = OPEN_MAX_PX * open / 2;
    left.style.transform = 'translateX(' + (-half).toFixed(2) + 'px)';
    right.style.transform = 'translateX(' + half.toFixed(2) + 'px)';
    orn.style.transform = 'rotate(' + (open * 135).toFixed(1) + 'deg) scale(' + (0.5 + open * 0.9).toFixed(3) + ')';
    openEl.textContent = String(Math.round(open * 100));
  }
  function goalOf() {
    if (pinned) return 1;
    if (!pointer) return 0;
    var box = orn.getBoundingClientRect();
    var dx = pointer.x - (box.left + box.width / 2);
    var dy = pointer.y - (box.top + box.height / 2);
    var dist = Math.sqrt(dx * dx + dy * dy);
    return smooth(Math.min(1, Math.max(0, 1 - dist / NEAR_RANGE)));
  }

09두 섹션이 파도 경계로 넘겨지는 구분선 장면

스크롤 위치 하나가 파도 위상을 밀고, 같은 값의 비율이 경계선을 그려 나가며, 위 섹션 색이 아래로 번지는 높이도 정한다. 03 처럼 scroll 이벤트로 움직이고, 시연에서는 코드가 scrollTop 을 스크롤 가능 길이의 62% 까지만 움직인다. 움직인 프레임은 23/23 이다.

두 섹션이 파도 경계로 넘겨지는 구분선 장면
새 창에서 열기

09 · 두 섹션이 파도 경계로 넘겨지는 구분선 장면 — 측정 누적면적 25.993% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WAVE_PHASE_PER_PXBLEED_PCTDRAW_RANGE_PX
  function edgeY(x, phase) {
    var y = BASE_Y;
    for (var i = 0; i < WAVE_AMP.length; i += 1) {
      y += WAVE_AMP[i] * Math.sin(2 * Math.PI * x / WAVE_PERIOD[i] + phase * WAVE_SPEED[i]);
    }
    return y;
  }
  function update() {
    var top = scroller.scrollTop;
    var phase = top * WAVE_PHASE_PER_PX;
    var pts = pathOf(phase);
    fill.setAttribute('d', 'M0 0H' + VIEW_W + 'V' + pts[pts.length - 1].split(' ')[1] + 'L' + pts.slice().reverse().join('L') + 'Z');
    var line = 'M' + pts.join('L');
    edge.setAttribute('d', line);
    var drawn = Math.min(1, Math.max(0, top / DRAW_RANGE_PX));
    edge.style.strokeDasharray = '1';
    edge.style.strokeDashoffset = String(1 - drawn);
    bleed.style.height = (BLEED_PCT * drawn).toFixed(1) + '%';
    phaseEl.textContent = phase.toFixed(1);
  }

값이 장면을 바꾸는 곳 — 사라지는 구간·파도 겹 수·반응 범위

세 상수가 선이 사라지는 폭과 경계의 높낮이, 장식이 열리는 거리를 거의 다 정한다. 아래 표의 수치는 아홉 장면을 각각 렌더해 23개 프레임 간격마다 잰 값이다.

사라지는 구간이 선의 진한 폭을 정한다

FADE_PCT 가 30 이면 smoothstep 은 구간 중간인 15% 지점에서 알파가 절반이 된다. 10 으로 줄이면 선이 끝에서 10% 안쪽부터 거의 진해지고, 50 으로 올리면 두 구간이 가운데에서 만나 한 점만 가장 진하다.

파도 겹 수가 경계의 높낮이를 정한다

WAVE_LAYERS 가 2 이면 진폭 합이 25 라 경계가 y 37 에서 87 사이를 오가고, 3 이면 30 이라 32 에서 92 사이로 넓어진다. 세 번째 겹은 주기가 47 이라 4px 표본 간격에서 한 주기에 표본이 열한 개 남짓 놓인다.

반응 범위가 장식이 열리는 거리를 정한다

NEAR_RANGE 가 120 이면 포인터가 장식에서 60px 일 때 목표 열림이 절반이고 90px 일 때 16% 쯤이며, 120px 이상에서는 닫힌 채다. 200 으로 넓히면 같은 90px 에서 훨씬 더 열린다.

항목 누적면적 % 강도 평균 움직인 프레임
01 양 끝 사라지는 선 20.4% 54.5 23/23
02 글자 폭 맞춤 15.2% 34.0 23/23
03 스크롤 그리기 30.0% 75.1 23/23
04 사인 파도 경계 11.6% 113.9 23/23
05 찢긴 종이 0.9% 138.8 19/23
06 점 종 곡선 4.8% 94.3 19/23
07 세로 구분선 29.5% 104.8 23/23
08 장식 열림 2.3% 159.9 23/23
09 파도 넘김 26.0% 82.1 23/23

누적면적이 10% 아래인 것은 05·06·08 셋이고, 선과 점이 얇아 면적이 작게 나온다. 05 와 06 이 19/23 인 것은 시연이 200 에서 1600ms 안에 끝나고 쉬기 때문이다.

어디서 깨지는가 — 줄바꿈·좁은 화면·읽히지 않는 구분 글자

슬라이더가 50 을 넘으면 사라지는 구간이 꼬인다

왼쪽 구간 끝과 오른쪽 구간 시작이 50% 에서 만나는 것이 한계다. 50 을 넘기면 정지점 순서가 거꾸로 되어 중간이 어색해지므로 슬라이더 최대를 50 으로 막아 두었다.

글자가 길면 양쪽 선이 0px 이 된다

02 에서 글자 폭에 양옆 간격 24px 를 더한 값이 줄 폭을 넘으면 두 선의 폭이 0 이 되고 글자가 줄 밖으로 넘친다. 글자에 white-space: nowrap 을 준 탓이라, 좁은 화면에서는 글자를 줄이거나 줄바꿈을 허용해야 한다.

표본 간격보다 촘촘한 파는 계단이 된다

04 는 점을 4px 간격으로 찍으므로 주기가 8 이하인 파를 더하면 표본이 파를 놓쳐 계단이 생긴다. 겹을 더할 때는 가장 짧은 주기를 8 이상으로 둔다.

같은 시드라도 폭이 다르면 찢김 모양이 다르다

05 는 점 개수가 시트 폭을 9 로 나눈 값이라 폭이 다르면 같은 시드의 노이즈가 늘거나 줄어 다른 모양이 나온다. 같은 폭에서만 새로고침해도 같은 모양이다.

폭이 바뀌었는데 판정을 안 부르면 옛 선이 남는다

07 은 폭이 바뀔 때마다 mark() 를 불러 줄 맨 앞을 다시 가린다. 창 크기만 바뀌고 이 함수가 안 불리면 옛 판정이 남고, 항목 높이가 서로 다르면 같은 줄에서도 offsetTop 이 달라 판정이 틀린다.

아홉 장면의 HTML 과 SCSS 를 통째로 받아 값을 바꿔 보고 싶다면 아래 압축 파일을 비밀번호 ntadr22m 으로 열면 된다.

변형 세 가지와 접근성

항목 상수 값 느낌
01 FADE_PCT 30 → 10 선이 끝에 가까운 곳까지 진하게 보이고, 50 이면 가운데 한 점만 가장 진하다
04 WAVE_LAYERS 2 → 3 주기 47 의 잔물결이 얹혀 반복이 더 줄고 진폭 합이 25 에서 30 으로 커진다
08 NEAR_RANGE 120 → 200 장식이 중심에서 200px 떨어진 곳부터 열려 같은 위치에서도 더 많이 열린다

버튼이 있는 장면은 02·04·05·06·08 다섯이고 01·07 은 슬라이더, 03·09 는 tabindex 를 준 스크롤 영역이라 화살표 키와 PageDown 으로 닿는다. prefers-reduced-motion 이 reduce 이면 시연 루프를 돌리지 않는다. 그래서 01 은 30 에, 04 는 위상 0 에 멈추고 05 는 찢긴 모양을 바로 그리며, 03 은 스크롤 가능 길이의 중간, 09 는 31% 지점에 놓고 한 번만 그린다. getBoundingClientRect 로 재는 값은 입력이 오는 즉시 반영된다.

css 구분선은 border 한 줄이면 충분한가?

색과 두께가 일정한 직선이면 border 한 줄로 충분하다. 다만 그 한 줄은 끝을 지우지 못하고 글자 폭에도 맞추지 못하며 섹션 경계를 곡선으로 만들지도 못해서, 01·02·04 처럼 그 틈이 보이는 자리에서만 계산을 얹으면 된다.

css 구분선을 쓰기 전에 자주 묻는 질문

css 구분선 넣기는 글자가 들어가도 같은 방법인가요?

글자가 끼면 선 두 개가 글자 폭을 뺀 나머지를 나눠 가져야 해서 02 처럼 폭을 재서 계산하는 쪽이 안정적이다. 글자가 항상 한 가지로 정해져 있다면 flex 로 선 두 개가 남는 폭을 같게 받도록 둬도 된다.

구분선이 섹션 경계가 되려면 무엇이 달라지나요?

선이 아니라 섹션의 색 면 가장자리를 곡선으로 그려야 해서 04 처럼 SVG path 를 만들거나 05 처럼 clip-path 로 자른다. 09 는 거기에 스크롤 값을 얹어 위 섹션 색이 아래로 번지게 한다.

구분선 장면을 스크롤이나 포인터에 반응하게 해도 되나요?

된다. 03·09 는 scroll 이벤트로, 08 은 포인터 거리로 움직이고, 어느 쪽이든 입력이 없을 때는 모양이 바뀌지 않게 두면 된다.

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

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