01縦スプリッター
中央のハンドルを左右に引くと左パネルの幅がポインターにそのまま追従し、どちらかの最小幅に達するとそれ以上は動きません。
使いどころ一覧と詳細を並べて見せるメールボックスや管理画面で、読む側を広げたいとき
setPointerCapturegrid-template-columnsaria-valuenow

<main class="stage">
<div class="vs is-demo" id="vs-root">
<div class="vs__box" id="vs-box">
<section class="vs__pane vs__pane--list" aria-label="メール一覧">
<span class="vs__row"></span>
<span class="vs__row"></span>
<span class="vs__row"></span>
<span class="vs__row"></span>
</section>
<div class="vs__bar" id="vs-bar" role="separator" tabindex="0"
aria-orientation="vertical" aria-label="一覧の幅" aria-valuemin="20" aria-valuemax="80" aria-valuenow="50">
<i class="vs__grip" aria-hidden="true"></i>
</div>
<section class="vs__pane vs__pane--read" aria-label="メール本文">
<b class="vs__title"></b>
<span class="vs__line"></span>
<span class="vs__line vs__line--short"></span>
<span class="vs__line"></span>
</section>
</div>
<p class="vs__cap">ハンドルを左右に引いて幅を調節</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 등록해야 키프레임에서 보간된다 — 등록 안 하면 --split 은 문자열이라 27% 에서 툭 끊긴다
@property --split {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
.vs {
--split: 50%;
width: min(312px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.vs__box {
display: grid;
grid-template-columns: var(--split) 10px 1fr;
height: 128px;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .06);
}
.vs__pane {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-3;
overflow: hidden;
}
.vs__pane--list { background: rgba(47, 109, 246, .18); }
.vs__pane--read { background: rgba(255, 247, 230, .07); }
.vs__row {
flex: 0 0 auto;
height: 12px;
border-radius: $r-xs;
background: rgba(255, 247, 230, .34);
}
.vs__row:nth-child(2) { background: $color; }
.vs__title {
flex: 0 0 auto;
height: 14px;
width: 60%;
border-radius: $r-xs;
background: $subject-cream;
}
.vs__line {
flex: 0 0 auto;
height: 8px;
border-radius: $r-xs;
background: rgba(255, 247, 230, .26);
}
.vs__line--short { width: 64%; }
.vs__bar {
display: grid;
place-items: center;
background: rgba(255, 247, 230, .14);
cursor: col-resize;
touch-action: none;
transition: background $duration $easing;
}
.vs__grip {
display: block;
width: 2px;
height: 28px;
border-radius: $r-pill;
background: $subject-cream;
transition: transform $duration $easing;
}
.vs__bar:hover,
.vs.is-drag .vs__bar { background: $color; }
.vs__bar:hover .vs__grip,
.vs.is-drag .vs__grip { transform: scaleY(1.5); }
.vs__bar:focus-visible { outline: 2px solid $subject-cream; outline-offset: -2px; }
.vs__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(255, 247, 230, .58);
text-align: center;
}
// ── 자동 루프: 50% → 좁게 → 넓게 → 50%. 복귀는 88% 안에서 끝난다 ──
.vs.is-demo { animation: vs-split $dur-loop $easing infinite; }
@keyframes vs-split {
0%, 8% { --split: 50%; }
36% { --split: 26%; }
64% { --split: 72%; }
88%, 100% { --split: 50%; }
}
.vs.is-demo .vs__bar { animation: vs-hot $dur-loop $easing infinite; }
@keyframes vs-hot {
0%, 8% { background: rgba(255, 247, 230, .14); }
20%, 76% { background: $subject-blue; }
88%, 100% { background: rgba(255, 247, 230, .14); }
}
@media (prefers-reduced-motion: reduce) {
.vs.is-demo,
.vs.is-demo .vs__bar { animation: none !important; }
.vs__bar, .vs__grip { transition: none; }
}
@media (max-width: 340px) {
.vs__box { height: 112px; }
.vs__pane { padding: $sp-2; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --split {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
.vs {
--split: 50%;
width: min(312px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.vs__box {
display: grid;
grid-template-columns: var(--split) 10px 1fr;
height: 128px;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.06);
}
.vs__pane {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
overflow: hidden;
}
.vs__pane--list {
background: rgba(47, 109, 246, 0.18);
}
.vs__pane--read {
background: rgba(255, 247, 230, 0.07);
}
.vs__row {
flex: 0 0 auto;
height: 12px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.34);
}
.vs__row:nth-child(2) {
background: #2f6df6;
}
.vs__title {
flex: 0 0 auto;
height: 14px;
width: 60%;
border-radius: 4px;
background: #fff7e6;
}
.vs__line {
flex: 0 0 auto;
height: 8px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.26);
}
.vs__line--short {
width: 64%;
}
.vs__bar {
display: grid;
place-items: center;
background: rgba(255, 247, 230, 0.14);
cursor: col-resize;
touch-action: none;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vs__grip {
display: block;
width: 2px;
height: 28px;
border-radius: 999px;
background: #fff7e6;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vs__bar:hover,
.vs.is-drag .vs__bar {
background: #2f6df6;
}
.vs__bar:hover .vs__grip,
.vs.is-drag .vs__grip {
transform: scaleY(1.5);
}
.vs__bar:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: -2px;
}
.vs__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(255, 247, 230, 0.58);
text-align: center;
}
.vs.is-demo {
animation: vs-split 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes vs-split {
0%, 8% {
--split: 50%;
}
36% {
--split: 26%;
}
64% {
--split: 72%;
}
88%, 100% {
--split: 50%;
}
}
.vs.is-demo .vs__bar {
animation: vs-hot 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes vs-hot {
0%, 8% {
background: rgba(255, 247, 230, 0.14);
}
20%, 76% {
background: #2f6df6;
}
88%, 100% {
background: rgba(255, 247, 230, 0.14);
}
}
@media (prefers-reduced-motion: reduce) {
.vs.is-demo,
.vs.is-demo .vs__bar {
animation: none !important;
}
.vs__bar, .vs__grip {
transition: none;
}
}
@media (max-width: 340px) {
.vs__box {
height: 112px;
}
.vs__pane {
padding: 8px;
}
}// 실제 동작: 포인터 델타 → 왼쪽 트랙 폭(--split). 최소 폭 64px 에서 클램프. 화살표 키는 4%씩.
var root = document.getElementById('vs-root');
var box = document.getElementById('vs-box');
var bar = document.getElementById('vs-bar');
var MIN = 64, BAR = 10, dragging = false;
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)));
}
function stop() { root.classList.remove('is-demo'); }
bar.addEventListener('pointerdown', function (e) {
stop();
dragging = true;
bar.setPointerCapture(e.pointerId);
root.classList.add('is-drag');
e.preventDefault();
});
bar.addEventListener('pointermove', function (e) {
if (!dragging) return;
set((e.clientX - box.getBoundingClientRect().left) / box.clientWidth * 100);
});
function drop() { dragging = false; root.classList.remove('is-drag'); }
bar.addEventListener('pointerup', drop);
bar.addEventListener('pointercancel', drop);
bar.addEventListener('keydown', function (e) {
var now = Number(bar.getAttribute('aria-valuenow'));
var d = e.key === 'ArrowLeft' ? -4 : e.key === 'ArrowRight' ? 4 : 0;
if (!d) return;
stop();
set(now + d);
bar.focus({ preventScroll: true });
e.preventDefault();
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./VerticalSplitter.module.scss";
export type VerticalSplitterProps = {
/** 시작 비율 (%) */
start?: number;
/** 양쪽 최소 폭 (px) */
minWidth?: number;
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Vars = CSSProperties & { "--split"?: string; "--duration"?: string; "--easing"?: string; "--color"?: string };
const BAR = 10;
export default function VerticalSplitter({
start = 50,
minWidth = 64,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: VerticalSplitterProps) {
const [split, setSplit] = useState(start);
const [drag, setDrag] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
function set(pct: number): void {
const box = boxRef.current;
if (!box) return;
const w = box.clientWidth;
const lo = (minWidth / w) * 100;
const hi = ((w - minWidth - BAR) / w) * 100;
setSplit(Math.max(lo, Math.min(pct, hi)));
}
function onDown(e: PEvt): void {
setDrag(true);
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onMove(e: PEvt): void {
const box = boxRef.current;
if (!drag || !box) return;
set(((e.clientX - box.getBoundingClientRect().left) / box.clientWidth) * 100);
}
function onUp(): void { setDrag(false); }
function onKey(e: KeyEvt): void {
const d = e.key === "ArrowLeft" ? -4 : e.key === "ArrowRight" ? 4 : 0;
if (!d) return;
set(split + d);
e.preventDefault();
}
const vars: Vars = {
"--split": `${split.toFixed(1)}%`,
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={drag ? `${styles.vs} ${styles.isDrag}` : styles.vs} style={vars}>
<div className={styles.box} ref={boxRef}>
<section className={`${styles.pane} ${styles.paneList}`} aria-label="Mail list">
<span className={styles.row} />
<span className={`${styles.row} ${styles.rowOn}`} />
<span className={styles.row} />
<span className={styles.row} />
</section>
<div
className={styles.bar}
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-label="List width"
aria-valuemin={20}
aria-valuemax={80}
aria-valuenow={Math.round(split)}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={onKey}
>
<i className={styles.grip} aria-hidden="true" />
</div>
<section className={`${styles.pane} ${styles.paneRead}`} aria-label="Mail body">
<b className={styles.title} />
<span className={styles.line} />
<span className={`${styles.line} ${styles.lineShort}`} />
<span className={styles.line} />
</section>
</div>
<p className={styles.cap}>Drag the handle sideways to set the width</p>
</div>
);
}01 縦スプリッター — 計測 累積面積 14.942% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · 画面分割 css レイアウト9選 — ドラッグで幅が変わる
このデモが属する記事
画面分割 css レイアウト9選 — ドラッグで幅が変わる画面分割 css とは、1つの画面を2つ以上の枠に区切り、その境界線をつかんで引くと各枠の大きさが変わる組み方です。9つのうち8つは境界線1本を動かすだけで隣の枠が縮み、1つは箱が狭くなった瞬間に左右が上下へ入れ替わります。 この記事のデモをすべて見る


