GODRICH

CSS 화면 분할 패널 9종 — 드래그로 폭이 따라오는

css 화면 분할은 한 화면을 두세 칸으로 나누고 그 경계선을 잡아 끌어 각 칸의 크기를 바꾸는 배치 방식입니다. 아홉 개 중 여덟은 경계선 하나만 옮겨도 옆 칸이 같이 줄고, 하나는 상자가 좁아지는 순간 좌우가 위아래로 뒤집힙니다.

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

아홉 개는 인기순이 아니라 경계선이 할 줄 아는 일이 늘어나는 순서로 놓았습니다. 먼저 좌우로만 움직이는 경계선 하나(01), 같은 원리를 세로축으로 돌린 것(02), 그 둘을 한 화면에 붙여 사이드바·본문·실행창을 만든 것(03)까지가 기본기입니다. 네 번째부터는 경계선이 제 상태를 갖기 시작합니다. 두 번 누르면 접혔다 펴지고(04), 특정 비율 근처에서 자석처럼 붙고(05), 손이 다가오면 제 모습을 바꾸고(06), 창을 닫았다 열어도 마지막 비율을 그대로 들고 옵니다(07). 여덟 번째(08)는 경계선이 선이 아니라 모서리의 한 점이고, 마지막 하나(09)는 경계선의 방향 자체가 상자 폭에 따라 바뀝니다. 아홉 개 모두 라이브러리 없이 pointerdown·pointermove·pointerup 한 벌과 CSS 사용자 지정 속성만으로 돌아가고, 아홉 개 전부 키보드로도 같은 값을 옮길 수 있습니다.

01세로 스플리터

가운데 손잡이를 좌우로 끌면 왼쪽 칸 폭이 손끝을 그대로 따라옵니다. 폭 값은 grid-template-columns가 읽는 사용자 지정 속성 --split 한 곳에만 들어가고, 양쪽 최소 폭 64px에 닿으면 더 밀어도 값이 멈춥니다.

setPointerCapturegrid-template-columnsaria-valuenow
function set(pct) {
  var w = box.clientWidth;
  var lo = MIN / w * 100, hi = (w - MIN - BAR) / w * 100;
  var v = Math.max(lo, Math.min(pct, hi));
  root.style.setProperty('--split', v.toFixed(1) + '%');
  bar.setAttribute('aria-valuenow', String(Math.round(v)));
}
bar.addEventListener('pointermove', function (e) {
  if (!dragging) return;
  set((e.clientX - box.getBoundingClientRect().left) / box.clientWidth * 100);
});

02가로 스플리터

손잡이를 위아래로 끌면 위쪽 편집 칸과 아래쪽 결과 칸이 높이를 서로 주고받습니다. 01번과 같은 계산을 Y축으로 돌린 것이고, 위아래 화살표 키는 한 번에 5%씩 같은 값을 옮깁니다.

pointermoveclamparia-orientation
function clamp(v, lo, hi) { return Math.max(lo, Math.min(v, hi)); }
function set(pct) {
  var h = box.clientHeight;
  var v = clamp(pct, MIN / h * 100, (h - MIN - BAR) / h * 100);
  root.style.setProperty('--splitY', v.toFixed(1) + '%');
  bar.setAttribute('aria-valuenow', String(Math.round(v)));
}
bar.addEventListener('keydown', function (e) {
  var d = e.key === 'ArrowUp' ? -5 : e.key === 'ArrowDown' ? 5 : 0;
  if (!d) return;
  stop();
  set(Number(bar.getAttribute('aria-valuenow')) + d);
  bar.focus({ preventScroll: true });
  e.preventDefault();
});

033패널 에디터 레이아웃

파일 목록·편집기·실행창이 손잡이 두 개로 이어져 있어, 하나를 움직이면 그 옆 칸만 줄어듭니다. 바깥 격자가 열을 나누고 안쪽 격자가 행을 나누기 때문에 두 손잡이가 서로의 값을 건드리지 않습니다.

grid-template-columnsgrid-template-rowssetPointerCapture
.tp__box {
  display: grid;
  grid-template-columns: var(--col) 8px 1fr;
  height: 128px;
  overflow: hidden;
}
.tp__main {
  display: grid;
  grid-template-rows: var(--row) 8px 1fr;
  overflow: hidden;
}

04더블클릭 접기

손잡이를 두 번 연속 누르면 옆 칸이 얇은 띠로 접히고, 다시 두 번 누르면 접기 직전 폭으로 돌아옵니다. 접기 전 값을 변수 하나에 담아 두기 때문에, 펼칠 때 기본값이 아니라 그 사람이 마지막으로 맞춘 폭이 나옵니다.

dblclickaria-expandedtransition
bar.addEventListener('dblclick', function () {
  stop();
  folded = !folded;
  if (folded) set(RAIL, true);
  else set(last, false);
});
function set(pct, fold) {
  root.style.setProperty('--split', pct.toFixed(1) + '%');
  bar.setAttribute('aria-valuenow', String(Math.round(pct)));
  bar.setAttribute('aria-expanded', String(!fold));
  root.classList.toggle('is-folded', fold);
}

05스냅 가이드라인

끄는 동안 25·50·75% 자리에 안내선이 떠 있다가, 손잡이가 안내선에서 6% 이내로 다가오면 값이 그 자리에 딱 붙습니다. 붙일지 말지는 거리만 보면 되므로 Math.abs 한 줄로 끝납니다.

snapopacityMath.abs
var STOPS = [25, 50, 75], NEAR = 6;
function snap(pct) {
  var best = pct;
  for (var i = 0; i < STOPS.length; i++) {
    if (Math.abs(pct - STOPS[i]) < NEAR) best = STOPS[i];
  }
  return Math.max(10, Math.min(best, 90));
}
function set(pct) {
  var v = snap(pct);
  root.style.setProperty('--split', v + '%');
  root.classList.toggle('is-snapped', STOPS.indexOf(v) >= 0);
}

06손잡이 시각 피드백

평소엔 머리카락 한 올 같은 경계선이 마우스를 올리면 넓어지며 점 세 개를 드러내고, 끄는 동안에는 색이 바뀐 채로 남습니다. 너비를 실제로 바꾸는 대신 scaleX로 늘리기 때문에 옆 칸 크기는 그대로입니다.

scaleXcubic-beziercursor
.hg__hair {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 20px;
  border-radius: $r-pill;
  background: $subject-ink;
  transform: translateX(-10px) scaleX(.1);
  transition: transform $duration $easing, background $duration $easing;
}
.hg__bar:hover .hg__hair { transform: translateX(-10px) scaleX(1); }
.hg.is-drag .hg__hair { background: $subject-blue; }

07비율 저장과 프리셋

끌어서 맞춘 비율이 곧바로 브라우저에 저장되고, 다음에 열면 저장된 값에서 시작합니다. 1:2·1:1·2:1 프리셋 단추는 그 비율을 한 번에 지정하고, aria-pressed로 지금 어느 비율인지 알려 줍니다.

localStorageJSON.parsearia-pressed
function set(pct, mark) {
  var v = Math.max(20, Math.min(pct, 80));
  root.style.setProperty('--split', v.toFixed(1) + '%');
  bar.setAttribute('aria-valuenow', String(Math.round(v)));
  btns.forEach(function (b) {
    var on = mark && b.dataset.v === String(Math.round(v));
    b.classList.toggle('is-on', !!on);
    b.setAttribute('aria-pressed', String(!!on));
  });
  try { localStorage.setItem(KEY, JSON.stringify({ split: Math.round(v) })); } catch (err) { return; }
}

08카드 코너 그립

카드 오른쪽 아래 모서리를 잡아 끌면 가로와 세로가 함께 자라고, 잠금 단추가 켜져 있으면 높이는 언제나 폭 × 처음 비율로 따라옵니다. 카드 안에 적히는 숫자도 같은 속성을 읽으므로 실제 크기와 어긋나지 않습니다.

pointerdownaspectMath.round
var RATIO = 96 / 176, locked = true;
function set(w, h) {
  var W = Math.max(120, Math.min(Math.round(w), 220));
  var H = locked ? Math.round(W * RATIO) : Math.max(64, Math.min(Math.round(h), 132));
  root.style.setProperty('--w', W + 'px');
  root.style.setProperty('--h', H + 'px');
  root.style.setProperty('--wn', String(W));
  root.style.setProperty('--hn', String(H));
  grip.setAttribute('aria-valuenow', String(W));
}

09반응형 자동 스택

상자 폭이 200px 아래로 내려가는 순간 좌우로 나뉘어 있던 두 칸이 위아래로 바뀌고, 손잡이도 세로에서 가로로 같이 돌아눕습니다. 화면 크기를 재는 미디어 쿼리가 아니라 ResizeObserver로 상자 자신의 폭을 재기 때문에, 같은 부품을 좁은 사이드바에 넣어도 알아서 뒤집힙니다.

ResizeObserveraria-orientationgrid-template-rows
var ro = new ResizeObserver(function (entries) {
  var w = entries[0].contentRect.width;
  var next = w < FLIP;
  if (next === stacked) return;
  stacked = next;
  root.classList.toggle('is-stack', stacked);
  bar.setAttribute('aria-orientation', stacked ? 'horizontal' : 'vertical');
});
ro.observe(box);

어디서 깨지는가 — 함정

이 아홉 개의 공통 함정은 분할 비율을 담은 사용자 지정 속성이 등록돼 있지 않으면 중간값이 아예 만들어지지 않는다는 것입니다. --split 같은 이름은 등록해 두기 전까지 브라우저에게 그냥 글자일 뿐이라, 50%와 26% 사이에 무엇이 있는지 모릅니다. 01번 데모에서 @property --split { syntax: "<percentage>" } 선언만 지우고 같은 2초 루프를 다시 재어 봤더니, 움직임이 잡힌 프레임이 24장 중 19장에서 7장으로, 변한 픽셀의 평균 차이가 121.9에서 64.6으로 내려갔습니다. 폭이 부드럽게 흐르지 않고 키프레임 지점마다 툭툭 끊겨 뛰었다는 뜻입니다. 선언을 되돌리자 같은 코드가 다시 19장으로 올라왔습니다. 마우스로 끌 때는 매 프레임 값을 직접 넣으니 이 차이가 안 보이고, 접기 애니메이션이나 프리셋 이동처럼 CSS가 스스로 사이를 메워야 하는 순간에만 드러납니다.

두 번째는 숫자와 그림이 어긋나는 문제였습니다. 08번은 카드 크기를 글자로도 보여 주는데, 그 글자를 자바스크립트로만 갱신했더니 자동 재생 중에는 카드가 커져도 숫자가 처음 값에 멈춰 있었습니다. 07번은 더 얄궂었습니다. 프리셋 단추의 눌린 표시를 "누른 순간"에 켜 두었더니, 비율이 옮겨 가는 도중 장면이 대표 이미지로 뽑혀 단추는 2:1이 눌린 채 화면은 1:1인 그림이 나왔습니다. 08번은 숫자를 counter()가 읽는 정수 속성으로 바꿔 카드 크기와 한 값에 묶었고, 07번은 눌린 표시를 비율이 도착한 뒤로 미뤘습니다. 둘 다 사람 눈에는 "가끔 이상한 장면"으로만 보이고 어떤 검사에도 걸리지 않는 종류였습니다.

증상 진짜 원인 고치는 자리
접기·프리셋 이동이 툭툭 끊긴다 비율 속성이 등록되지 않아 중간값이 없다 @propertysyntax: "<percentage>"
손잡이를 놓쳐 드래그가 끊긴다 포인터가 손잡이에 묶여 있지 않다 pointerdown에서 setPointerCapture
좁은 화면에서만 배치가 안 바뀐다 창 크기를 재고 있다 ResizeObserver로 상자 폭을 잰다

표의 세 줄을 다 반영한 아홉 벌 소스는 압축 비밀번호 34ze99gp를 넣으면 열리는 zip 안에 있고, 바닐라와 React 판이 같은 값을 씁니다.

접근성

손잡이가 마우스 전용이 되지 않게 만든 것이 아홉 개의 공통 조건이었습니다. 경계선에는 role="separator"aria-orientation이 붙어 그것이 무엇이고 어느 방향으로 움직이는지 알려 주고, aria-valuemin·aria-valuemax·aria-valuenow가 지금 값을 숫자로 읽어 줍니다. 01·02·03·05·06·07·09번은 좌우 또는 위아래 화살표 키로 비율을 옮기고, 04번은 Enter와 스페이스로 접었다 펴며, 08번은 좌우 화살표로 카드 폭을 8px씩 옮깁니다. 07번은 손잡이의 화살표 키와 별개로 프리셋 단추 세 개가 진짜 button이라 그것만으로도 비율을 고를 수 있습니다. 04번 손잡이에는 aria-expanded가 붙어 지금 접혀 있는지도 함께 전해지고, 09번은 배치가 뒤집힐 때 aria-orientation 값이 같이 바뀝니다. 키보드로 값을 옮길 때 focus({ preventScroll: true })를 쓰는 이유는 이 데모들이 목록 안에서 iframe으로 들어가기 때문이고, 이 옵션이 없으면 부모 화면이 그때마다 아래로 끌려갑니다. prefers-reduced-motion: reduce에서는 자동 재생 루프와 전환만 끄고 드래그·키보드 조작과 값은 그대로 남습니다. 분할선의 역할 이름은 MDN의 separator 역할 문서에 정리돼 있습니다.

비슷한 계열의 부품을 더 찾는다면 대시보드 카테고리클릭 카테고리에 같이 쓰기 좋은 것들이 모여 있습니다.

FAQ

분할 비율을 width 대신 grid로 잡는 이유가 있나요?

칸이 세 개 이상으로 늘어나는 순간 계산이 달라지기 때문입니다. 각 칸에 width를 따로 주면 손잡이 두께까지 사람이 직접 빼야 하고, 한 칸을 줄일 때 어느 칸이 그만큼 늘어날지도 코드로 정해야 합니다. grid-template-columns: var(--split) 8px 1fr처럼 적으면 손잡이 폭은 고정 트랙으로 빠지고 남은 자리는 1fr이 알아서 채우므로, 움직이는 값은 언제나 --split 하나뿐입니다. 03번은 이 방식을 열과 행에 한 번씩 써서 손잡이 두 개를 서로 독립시켰습니다.

손잡이에서 손이 벗어나면 왜 드래그가 멈추나요?

그 포인터를 손잡이에 묶어 두지 않아서입니다. 기본 동작에서는 포인터가 요소 밖으로 나가는 순간 그 요소에 이벤트가 더 오지 않기 때문에, 빠르게 끌면 손잡이가 도중에 멈춥니다. pointerdown 안에서 setPointerCapture(e.pointerId)를 한 번 부르면 손을 뗄 때까지 이벤트가 계속 그 손잡이로 오고, 칸 바깥이나 창 밖까지 끌어도 값이 이어집니다. 아홉 개가 전부 이 한 줄을 씁니다.

저장한 비율이 어떤 브라우저에서는 안 돌아옵니다

사생활 보호 모드처럼 저장이 막힌 환경에서는 localStorage에 쓰는 순간 예외가 납니다. 07번이 저장과 복원을 모두 try로 감싼 이유가 그것이고, 예외가 나도 값을 못 읽었을 뿐이므로 기본 비율로 조용히 시작합니다. 이 처리를 빼면 저장이 막힌 브라우저에서 스크립트가 거기서 끊겨 손잡이 자체가 움직이지 않습니다.

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

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