CSS 화면 분할 패널 9종 — 드래그로 폭이 따라오는
css 화면 분할은 한 화면을 두세 칸으로 나누고 그 경계선을 잡아 끌어 각 칸의 크기를 바꾸는 배치 방식입니다. 아홉 개 중 여덟은 경계선 하나만 옮겨도 옆 칸이 같이 줄고, 하나는 상자가 좁아지는 순간 좌우가 위아래로 뒤집힙니다.
01 panel-vertical-splitter
02 panel-horizontal-splitter
03 panel-three-pane-ide
04 panel-double-click-collapse
05 panel-snap-guides
06 panel-handle-grip-feedback
07 panel-ratio-preset-restore
08 panel-corner-resize-grip
09 panel-responsive-auto-stack
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 세로 스플리터
- 02 가로 스플리터
- 03 3패널 에디터 레이아웃
- 04 더블클릭 접기
- 05 스냅 가이드라인
- 06 손잡이 시각 피드백
- 07 비율 저장과 프리셋
- 08 카드 코너 그립
- 09 반응형 자동 스택
아홉 개는 인기순이 아니라 경계선이 할 줄 아는 일이 늘어나는 순서로 놓았습니다. 먼저 좌우로만 움직이는 경계선 하나(01), 같은 원리를 세로축으로 돌린 것(02), 그 둘을 한 화면에 붙여 사이드바·본문·실행창을 만든 것(03)까지가 기본기입니다. 네 번째부터는 경계선이 제 상태를 갖기 시작합니다. 두 번 누르면 접혔다 펴지고(04), 특정 비율 근처에서 자석처럼 붙고(05), 손이 다가오면 제 모습을 바꾸고(06), 창을 닫았다 열어도 마지막 비율을 그대로 들고 옵니다(07). 여덟 번째(08)는 경계선이 선이 아니라 모서리의 한 점이고, 마지막 하나(09)는 경계선의 방향 자체가 상자 폭에 따라 바뀝니다. 아홉 개 모두 css resize 속성이나 라이브러리 없이 pointerdown·pointermove·pointerup 한 벌과 CSS 사용자 지정 속성만으로 돌아가고, 아홉 개 전부 키보드로도 같은 값을 옮길 수 있습니다. 경계선을 손가락으로 움직이는 쪽은 모바일 터치 제스처에 기초 동작이 있고, 나눈 화면 사이를 오가는 내비게이션은 브레드크럼 UI가 맡습니다.
01세로 스플리터
css 크기 조절에서 무엇이 보이나
가운데 손잡이를 좌우로 끌면 왼쪽 칸 폭이 손끝을 그대로 따라옵니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
폭 값은 grid-template-columns가 읽는 사용자 지정 속성 --split 한 곳에만 들어가고, 양쪽 최소 폭 64px에 닿으면 더 밀어도 값이 멈춥니다.
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가로 스플리터
화면에서 일어나는 일
손잡이를 위아래로 끌면 위쪽 편집 칸과 아래쪽 결과 칸이 높이를 서로 주고받습니다.

<main class="stage">
<div class="hs is-demo" id="hs-root">
<div class="hs__box" id="hs-box">
<section class="hs__pane hs__pane--code" aria-label="편집 영역">
<span class="hs__tok hs__tok--a"></span>
<span class="hs__tok hs__tok--b"></span>
<span class="hs__tok hs__tok--c"></span>
<span class="hs__tok hs__tok--b"></span>
</section>
<div class="hs__bar" id="hs-bar" role="separator" tabindex="0"
aria-orientation="horizontal" aria-label="편집 영역 높이" aria-valuemin="20" aria-valuemax="80" aria-valuenow="50">
<i class="hs__grip" aria-hidden="true"></i>
</div>
<section class="hs__pane hs__pane--out" aria-label="결과 영역">
<span class="hs__chip">결과</span>
<span class="hs__bars"><i></i><i></i><i></i><i></i><i></i></span>
</section>
</div>
<p class="hs__cap">위아래로 끌거나 화살표 키로 높이 조절</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
@property --splitY {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
.hs {
--splitY: 50%;
width: min(288px, 86vw);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.hs__box {
display: grid;
grid-template-rows: var(--splitY) 10px 1fr;
height: clamp(112px, 40vh, 132px);
border-radius: $r-card;
overflow: hidden;
border: 1px solid rgba(23, 20, 26, .12);
background: #fbf9f5;
}
.hs__pane {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
overflow: hidden;
}
.hs__pane--code { background: rgba(47, 109, 246, .08); }
.hs__pane--out {
align-items: flex-start;
justify-content: center;
background: rgba(23, 20, 26, .05);
}
.hs__tok {
flex: 0 0 auto;
height: 7px;
border-radius: $r-xs;
background: rgba(23, 20, 26, .2);
}
.hs__tok--a { width: 72%; background: $color; }
.hs__tok--b { width: 52%; }
.hs__tok--c { width: 84%; }
.hs__chip {
flex: 0 0 auto;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.hs__bars {
display: flex;
align-items: flex-end;
gap: $sp-1;
height: 24px;
}
.hs__bars i {
display: block;
width: 12px;
border-radius: $r-xs $r-xs 0 0;
background: $color;
}
.hs__bars i:nth-child(1) { height: 40%; }
.hs__bars i:nth-child(2) { height: 76%; }
.hs__bars i:nth-child(3) { height: 56%; }
.hs__bars i:nth-child(4) { height: 96%; background: $subject-ink; }
.hs__bars i:nth-child(5) { height: 64%; }
.hs__bar {
display: grid;
place-items: center;
background: rgba(23, 20, 26, .1);
cursor: row-resize;
touch-action: none;
transition: background $duration $easing;
}
.hs__grip {
display: block;
width: 32px;
height: 2px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .45);
transition: transform $duration $easing;
}
.hs__bar:hover,
.hs.is-drag .hs__bar { background: $color; }
.hs__bar:hover .hs__grip,
.hs.is-drag .hs__grip { transform: scaleX(1.4); background: #ffffff; }
.hs__bar:focus-visible { outline: 2px solid $subject-ink; outline-offset: -2px; }
.hs__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(23, 20, 26, .72); // AA 4.5 (blog-design)
text-align: center;
}
// ── 자동 루프: 위 칸이 커졌다 작아졌다. 86% 에 제자리 ──
.hs.is-demo { animation: hs-split $dur-loop $easing infinite; }
@keyframes hs-split {
0%, 8% { --splitY: 50%; }
34% { --splitY: 76%; }
62% { --splitY: 24%; }
86%, 100% { --splitY: 50%; }
}
.hs.is-demo .hs__bar { animation: hs-hot $dur-loop $easing infinite; }
@keyframes hs-hot {
0%, 8% { background: rgba(23, 20, 26, .1); }
20%, 74% { background: $subject-blue; }
86%, 100% { background: rgba(23, 20, 26, .1); }
}
@media (prefers-reduced-motion: reduce) {
.hs.is-demo,
.hs.is-demo .hs__bar { animation: none !important; }
.hs__bar, .hs__grip { transition: none; }
}
@media (max-width: 340px) {
.hs__box { height: 108px; }
.hs__pane { padding: $sp-1 $sp-2; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --splitY {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
.hs {
--splitY: 50%;
width: min(288px, 86vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.hs__box {
display: grid;
grid-template-rows: var(--splitY) 10px 1fr;
height: clamp(112px, 40vh, 132px);
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(23, 20, 26, 0.12);
background: #fbf9f5;
}
.hs__pane {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 8px 12px;
overflow: hidden;
}
.hs__pane--code {
background: rgba(47, 109, 246, 0.08);
}
.hs__pane--out {
align-items: flex-start;
justify-content: center;
background: rgba(23, 20, 26, 0.05);
}
.hs__tok {
flex: 0 0 auto;
height: 7px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.2);
}
.hs__tok--a {
width: 72%;
background: #2f6df6;
}
.hs__tok--b {
width: 52%;
}
.hs__tok--c {
width: 84%;
}
.hs__chip {
flex: 0 0 auto;
padding: 0 8px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.hs__bars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 24px;
}
.hs__bars i {
display: block;
width: 12px;
border-radius: 4px 4px 0 0;
background: #2f6df6;
}
.hs__bars i:nth-child(1) {
height: 40%;
}
.hs__bars i:nth-child(2) {
height: 76%;
}
.hs__bars i:nth-child(3) {
height: 56%;
}
.hs__bars i:nth-child(4) {
height: 96%;
background: #17141a;
}
.hs__bars i:nth-child(5) {
height: 64%;
}
.hs__bar {
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.1);
cursor: row-resize;
touch-action: none;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.hs__grip {
display: block;
width: 32px;
height: 2px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.45);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.hs__bar:hover,
.hs.is-drag .hs__bar {
background: #2f6df6;
}
.hs__bar:hover .hs__grip,
.hs.is-drag .hs__grip {
transform: scaleX(1.4);
background: #ffffff;
}
.hs__bar:focus-visible {
outline: 2px solid #17141a;
outline-offset: -2px;
}
.hs__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
text-align: center;
}
.hs.is-demo {
animation: hs-split 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes hs-split {
0%, 8% {
--splitY: 50%;
}
34% {
--splitY: 76%;
}
62% {
--splitY: 24%;
}
86%, 100% {
--splitY: 50%;
}
}
.hs.is-demo .hs__bar {
animation: hs-hot 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes hs-hot {
0%, 8% {
background: rgba(23, 20, 26, 0.1);
}
20%, 74% {
background: #2f6df6;
}
86%, 100% {
background: rgba(23, 20, 26, 0.1);
}
}
@media (prefers-reduced-motion: reduce) {
.hs.is-demo,
.hs.is-demo .hs__bar {
animation: none !important;
}
.hs__bar, .hs__grip {
transition: none;
}
}
@media (max-width: 340px) {
.hs__box {
height: 108px;
}
.hs__pane {
padding: 4px 8px;
}
}// 실제 동작: 포인터 Y 델타 → 위 트랙 높이(--splitY). 최소 40px 을 clamp 로 지킨다.
var root = document.getElementById('hs-root');
var box = document.getElementById('hs-box');
var bar = document.getElementById('hs-bar');
var MIN = 40, BAR = 10, dragging = false;
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)));
}
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.clientY - box.getBoundingClientRect().top) / box.clientHeight * 100);
});
function drop() { dragging = false; root.classList.remove('is-drag'); }
bar.addEventListener('pointerup', drop);
bar.addEventListener('pointercancel', drop);
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();
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HorizontalSplitter.module.scss";
export type HorizontalSplitterProps = {
start?: number;
minHeight?: number;
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Vars = CSSProperties & { "--splitY"?: string; "--duration"?: string; "--easing"?: string; "--color"?: string };
const BAR = 10;
function clamp(v: number, lo: number, hi: number): number { return Math.max(lo, Math.min(v, hi)); }
export default function HorizontalSplitter({
start = 50,
minHeight = 40,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: HorizontalSplitterProps) {
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 h = box.clientHeight;
setSplit(clamp(pct, (minHeight / h) * 100, ((h - minHeight - BAR) / h) * 100));
}
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.clientY - box.getBoundingClientRect().top) / box.clientHeight) * 100);
}
function onUp(): void { setDrag(false); }
function onKey(e: KeyEvt): void {
const d = e.key === "ArrowUp" ? -5 : e.key === "ArrowDown" ? 5 : 0;
if (!d) return;
set(split + d);
e.preventDefault();
}
const vars: Vars = {
"--splitY": `${split.toFixed(1)}%`,
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={drag ? `${styles.hs} ${styles.isDrag}` : styles.hs} style={vars}>
<div className={styles.box} ref={boxRef}>
<section className={`${styles.pane} ${styles.paneCode}`} aria-label="Editor area">
<span className={`${styles.tok} ${styles.tokA}`} />
<span className={`${styles.tok} ${styles.tokB}`} />
<span className={`${styles.tok} ${styles.tokC}`} />
<span className={`${styles.tok} ${styles.tokB}`} />
</section>
<div
className={styles.bar}
role="separator"
tabIndex={0}
aria-orientation="horizontal"
aria-label="Editor area height"
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.paneOut}`} aria-label="Result area">
<span className={styles.chip}>Result</span>
<span className={styles.bars}>
<i /><i /><i /><i /><i />
</span>
</section>
</div>
<p className={styles.cap}>Drag up and down, or use the arrow keys</p>
</div>
);
}02 · 가로 스플리터 — 측정 누적면적 17.602% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
01번과 같은 계산을 Y축으로 돌린 것이고, 위아래 화살표 키는 한 번에 5%씩 같은 값을 옮깁니다.
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패널 에디터 레이아웃
어떤 움직임인가
파일 목록·편집기·실행창이 손잡이 두 개로 이어져 있어, 하나를 움직이면 그 옆 칸만 줄어듭니다.

<main class="stage">
<div class="tp is-demo" id="tp-root">
<div class="tp__box" id="tp-box">
<section class="tp__side" aria-label="파일 목록">
<span class="tp__ico"><svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="tp__row"></span>
<span class="tp__row tp__row--on"></span>
<span class="tp__row"></span>
</section>
<div class="tp__bar tp__bar--v" id="tp-barv" role="separator" tabindex="0"
aria-orientation="vertical" aria-label="사이드바 너비" aria-valuemin="14" aria-valuemax="60" aria-valuenow="30"></div>
<div class="tp__main">
<section class="tp__edit" aria-label="편집기">
<span class="tp__ico"><svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m240 128l-48 40H64l-48-40l48-40h128Z" opacity=".2"/><path d="M69.12 94.15L28.5 128l40.62 33.85a8 8 0 1 1-10.24 12.29l-48-40a8 8 0 0 1 0-12.29l48-40a8 8 0 0 1 10.24 12.3m176 27.7l-48-40a8 8 0 1 0-10.24 12.3L227.5 128l-40.62 33.85a8 8 0 1 0 10.24 12.29l48-40a8 8 0 0 0 0-12.29m-82.39-89.37a8 8 0 0 0-10.25 4.79l-64 176a8 8 0 0 0 4.79 10.26A8.1 8.1 0 0 0 96 224a8 8 0 0 0 7.52-5.27l64-176a8 8 0 0 0-4.79-10.25"/></g></svg></span>
<span class="tp__tok tp__tok--a"></span>
<span class="tp__tok tp__tok--b"></span>
<span class="tp__tok tp__tok--c"></span>
</section>
<div class="tp__bar tp__bar--h" id="tp-barh" role="separator" tabindex="0"
aria-orientation="horizontal" aria-label="실행창 높이" aria-valuemin="16" aria-valuemax="70" aria-valuenow="34"></div>
<section class="tp__term" aria-label="실행창">
<span class="tp__ico"><svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z"/></g></svg></span>
<span class="tp__tok tp__tok--d"></span>
</section>
</div>
</div>
<p class="tp__cap">손잡이 두 개가 각자 옆 칸만 줄인다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
@property --col {
syntax: "<percentage>";
inherits: true;
initial-value: 30%;
}
@property --row {
syntax: "<percentage>";
inherits: true;
initial-value: 66%;
}
.tp {
--col: 30%;
--row: 66%;
width: min(320px, 90vw);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.tp__box {
display: grid;
grid-template-columns: var(--col) 8px 1fr;
height: 128px;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .05);
}
.tp__main {
display: grid;
grid-template-rows: var(--row) 8px 1fr;
overflow: hidden;
}
.tp__side,
.tp__edit,
.tp__term {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-2;
overflow: hidden;
}
.tp__side { background: rgba(47, 109, 246, .2); }
.tp__edit { background: rgba(255, 247, 230, .06); }
.tp__term { background: rgba(23, 20, 26, .55); }
.tp__ico {
flex: 0 0 auto;
width: 14px;
height: 14px;
color: $subject-cream;
}
.tp__ico svg { display: block; width: 14px; height: 14px; }
.tp__term .tp__ico { color: $subject-mint; }
.tp__row,
.tp__tok {
flex: 0 0 auto;
height: 7px;
border-radius: $r-xs;
background: rgba(255, 247, 230, .3);
}
.tp__row--on { background: $subject-cream; }
.tp__tok--a { width: 78%; background: $color; }
.tp__tok--b { width: 54%; }
.tp__tok--c { width: 90%; }
.tp__tok--d { width: 62%; background: rgba(76, 212, 166, .7); }
.tp__bar {
background: rgba(255, 247, 230, .16);
touch-action: none;
transition: background $duration $easing;
}
.tp__bar--v { cursor: col-resize; }
.tp__bar--h { cursor: row-resize; }
.tp__bar:hover { background: $color; }
.tp__bar:focus-visible { outline: 2px solid $subject-cream; outline-offset: -2px; }
.tp__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(255, 247, 230, .55);
text-align: center;
}
// ── 자동 루프: 세로 손잡이가 먼저, 가로 손잡이가 그다음. 둘이 겹치지 않는다 ──
.tp.is-demo { animation: tp-move $dur-loop $easing infinite; }
@keyframes tp-move {
0%, 6% { --col: 30%; --row: 66%; }
26% { --col: 56%; --row: 66%; }
44% { --col: 16%; --row: 66%; }
60% { --col: 30%; --row: 30%; }
76% { --col: 30%; --row: 78%; }
88%, 100% { --col: 30%; --row: 66%; }
}
.tp.is-demo .tp__bar--v { animation: tp-hot-a $dur-loop steps(1, end) infinite; }
.tp.is-demo .tp__bar--h { animation: tp-hot-b $dur-loop steps(1, end) infinite; }
@keyframes tp-hot-a {
0%, 5% { background: rgba(255, 247, 230, .16); }
6%, 45% { background: $subject-blue; }
46%, 100% { background: rgba(255, 247, 230, .16); }
}
@keyframes tp-hot-b {
0%, 45% { background: rgba(255, 247, 230, .16); }
46%, 87% { background: $subject-blue; }
88%, 100% { background: rgba(255, 247, 230, .16); }
}
@media (prefers-reduced-motion: reduce) {
.tp.is-demo,
.tp.is-demo .tp__bar--v,
.tp.is-demo .tp__bar--h { animation: none !important; }
.tp__bar { transition: none; }
}
@media (max-width: 340px) {
.tp__box { height: 112px; }
.tp__side, .tp__edit, .tp__term { padding: $sp-1; }
}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 --col {
syntax: "<percentage>";
inherits: true;
initial-value: 30%;
}
@property --row {
syntax: "<percentage>";
inherits: true;
initial-value: 66%;
}
.tp {
--col: 30%;
--row: 66%;
width: min(320px, 90vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.tp__box {
display: grid;
grid-template-columns: var(--col) 8px 1fr;
height: 128px;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
}
.tp__main {
display: grid;
grid-template-rows: var(--row) 8px 1fr;
overflow: hidden;
}
.tp__side,
.tp__edit,
.tp__term {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
overflow: hidden;
}
.tp__side {
background: rgba(47, 109, 246, 0.2);
}
.tp__edit {
background: rgba(255, 247, 230, 0.06);
}
.tp__term {
background: rgba(23, 20, 26, 0.55);
}
.tp__ico {
flex: 0 0 auto;
width: 14px;
height: 14px;
color: #fff7e6;
}
.tp__ico svg {
display: block;
width: 14px;
height: 14px;
}
.tp__term .tp__ico {
color: #4cd4a6;
}
.tp__row,
.tp__tok {
flex: 0 0 auto;
height: 7px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.3);
}
.tp__row--on {
background: #fff7e6;
}
.tp__tok--a {
width: 78%;
background: #2f6df6;
}
.tp__tok--b {
width: 54%;
}
.tp__tok--c {
width: 90%;
}
.tp__tok--d {
width: 62%;
background: rgba(76, 212, 166, 0.7);
}
.tp__bar {
background: rgba(255, 247, 230, 0.16);
touch-action: none;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tp__bar--v {
cursor: col-resize;
}
.tp__bar--h {
cursor: row-resize;
}
.tp__bar:hover {
background: #2f6df6;
}
.tp__bar:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: -2px;
}
.tp__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(255, 247, 230, 0.55);
text-align: center;
}
.tp.is-demo {
animation: tp-move 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tp-move {
0%, 6% {
--col: 30%;
--row: 66%;
}
26% {
--col: 56%;
--row: 66%;
}
44% {
--col: 16%;
--row: 66%;
}
60% {
--col: 30%;
--row: 30%;
}
76% {
--col: 30%;
--row: 78%;
}
88%, 100% {
--col: 30%;
--row: 66%;
}
}
.tp.is-demo .tp__bar--v {
animation: tp-hot-a 2s steps(1, end) infinite;
}
.tp.is-demo .tp__bar--h {
animation: tp-hot-b 2s steps(1, end) infinite;
}
@keyframes tp-hot-a {
0%, 5% {
background: rgba(255, 247, 230, 0.16);
}
6%, 45% {
background: #2f6df6;
}
46%, 100% {
background: rgba(255, 247, 230, 0.16);
}
}
@keyframes tp-hot-b {
0%, 45% {
background: rgba(255, 247, 230, 0.16);
}
46%, 87% {
background: #2f6df6;
}
88%, 100% {
background: rgba(255, 247, 230, 0.16);
}
}
@media (prefers-reduced-motion: reduce) {
.tp.is-demo,
.tp.is-demo .tp__bar--v,
.tp.is-demo .tp__bar--h {
animation: none !important;
}
.tp__bar {
transition: none;
}
}
@media (max-width: 340px) {
.tp__box {
height: 112px;
}
.tp__side, .tp__edit, .tp__term {
padding: 4px;
}
}// 실제 동작: 세로 손잡이는 grid-template-columns, 가로 손잡이는 grid-template-rows 를 각자 바꾼다.
var root = document.getElementById('tp-root');
var box = document.getElementById('tp-box');
var pair = [
{ bar: document.getElementById('tp-barv'), key: '--col', lo: 14, hi: 60, axis: 'x' },
{ bar: document.getElementById('tp-barh'), key: '--row', lo: 16, hi: 70, axis: 'y' }
];
var live = null;
function set(p, pct) {
var v = Math.max(p.lo, Math.min(pct, p.hi));
root.style.setProperty(p.key, v.toFixed(1) + '%');
p.bar.setAttribute('aria-valuenow', String(Math.round(v)));
}
pair.forEach(function (p) {
p.bar.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
live = p;
p.bar.setPointerCapture(e.pointerId);
e.preventDefault();
});
p.bar.addEventListener('pointermove', function (e) {
if (live !== p) return;
var r = box.getBoundingClientRect();
set(p, p.axis === 'x' ? (e.clientX - r.left) / r.width * 100 : (e.clientY - r.top) / r.height * 100);
});
p.bar.addEventListener('pointerup', function () { live = null; });
p.bar.addEventListener('keydown', function (e) {
var d = (e.key === 'ArrowLeft' || e.key === 'ArrowUp') ? -5 : (e.key === 'ArrowRight' || e.key === 'ArrowDown') ? 5 : 0;
if (!d) return;
root.classList.remove('is-demo');
set(p, Number(p.bar.getAttribute('aria-valuenow')) + d);
p.bar.focus({ preventScroll: true });
e.preventDefault();
});
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ThreePaneLayout.module.scss";
export type ThreePaneLayoutProps = {
startCol?: number;
startRow?: number;
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Vars = CSSProperties & { "--col"?: string; "--row"?: string; "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ThreePaneLayout({
startCol = 30,
startRow = 66,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ThreePaneLayoutProps) {
const [col, setCol] = useState(startCol);
const [row, setRow] = useState(startRow);
const live = useRef<"col" | "row" | null>(null);
const boxRef = useRef<HTMLDivElement>(null);
function onDown(axis: "col" | "row", e: PEvt): void {
live.current = axis;
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onMove(axis: "col" | "row", e: PEvt): void {
const box = boxRef.current;
if (live.current !== axis || !box) return;
const r = box.getBoundingClientRect();
if (axis === "col") setCol(Math.max(14, Math.min(((e.clientX - r.left) / r.width) * 100, 60)));
else setRow(Math.max(16, Math.min(((e.clientY - r.top) / r.height) * 100, 70)));
}
function onUp(): void { live.current = null; }
function onKey(axis: "col" | "row", e: KeyEvt): void {
const d = e.key === "ArrowLeft" || e.key === "ArrowUp" ? -5 : e.key === "ArrowRight" || e.key === "ArrowDown" ? 5 : 0;
if (!d) return;
if (axis === "col") setCol(Math.max(14, Math.min(col + d, 60)));
else setRow(Math.max(16, Math.min(row + d, 70)));
e.preventDefault();
}
const vars: Vars = {
"--col": `${col.toFixed(1)}%`,
"--row": `${row.toFixed(1)}%`,
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.tp} style={vars}>
<div className={styles.box} ref={boxRef}>
<section className={styles.side} aria-label="File list">
<span className={styles.ico}>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" /><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" /></g></svg>
</span>
<span className={styles.row} />
<span className={`${styles.row} ${styles.rowOn}`} />
<span className={styles.row} />
</section>
<div
className={`${styles.bar} ${styles.barV}`}
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-label="Sidebar width"
aria-valuemin={14}
aria-valuemax={60}
aria-valuenow={Math.round(col)}
onPointerDown={(e: PEvt): void => onDown("col", e)}
onPointerMove={(e: PEvt): void => onMove("col", e)}
onPointerUp={onUp}
onKeyDown={(e: KeyEvt): void => onKey("col", e)}
/>
<div className={styles.main}>
<section className={styles.edit} aria-label="Editor">
<span className={styles.ico}>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m240 128l-48 40H64l-48-40l48-40h128Z" opacity=".2" /><path d="M69.12 94.15L28.5 128l40.62 33.85a8 8 0 1 1-10.24 12.29l-48-40a8 8 0 0 1 0-12.29l48-40a8 8 0 0 1 10.24 12.3m176 27.7l-48-40a8 8 0 1 0-10.24 12.3L227.5 128l-40.62 33.85a8 8 0 1 0 10.24 12.29l48-40a8 8 0 0 0 0-12.29m-82.39-89.37a8 8 0 0 0-10.25 4.79l-64 176a8 8 0 0 0 4.79 10.26A8.1 8.1 0 0 0 96 224a8 8 0 0 0 7.52-5.27l64-176a8 8 0 0 0-4.79-10.25" /></g></svg>
</span>
<span className={`${styles.tok} ${styles.tokA}`} />
<span className={`${styles.tok} ${styles.tokB}`} />
<span className={`${styles.tok} ${styles.tokC}`} />
</section>
<div
className={`${styles.bar} ${styles.barH}`}
role="separator"
tabIndex={0}
aria-orientation="horizontal"
aria-label="Console height"
aria-valuemin={16}
aria-valuemax={70}
aria-valuenow={Math.round(row)}
onPointerDown={(e: PEvt): void => onDown("row", e)}
onPointerMove={(e: PEvt): void => onMove("row", e)}
onPointerUp={onUp}
onKeyDown={(e: KeyEvt): void => onKey("row", e)}
/>
<section className={styles.term} aria-label="Console">
<span className={styles.ico}>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2" /><path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z" /></g></svg>
</span>
<span className={`${styles.tok} ${styles.tokD}`} />
</section>
</div>
</div>
<p className={styles.cap}>Each handle only shrinks the pane next to it</p>
</div>
);
}03 · 3패널 에디터 레이아웃 — 측정 누적면적 19.616% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
바깥 격자가 열을 나누고 안쪽 격자가 행을 나누기 때문에 두 손잡이가 서로의 값을 건드리지 않습니다.
.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더블클릭 접기
무엇이 보이나
손잡이를 두 번 연속 누르면 옆 칸이 얇은 띠로 접히고, 다시 두 번 누르면 접기 직전 폭으로 돌아옵니다.

<main class="stage">
<div class="dc is-demo" id="dc-root">
<div class="dc__box" id="dc-box">
<section class="dc__side" id="dc-side" aria-label="사이드바">
<span class="dc__ico"><svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M88 48v160H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16M40 56h40v144H40Zm176 144H96V56h120z"/></g></svg></span>
<span class="dc__row"></span>
<span class="dc__row"></span>
<span class="dc__row"></span>
</section>
<div class="dc__bar" id="dc-bar" role="separator" tabindex="0"
aria-orientation="vertical" aria-label="사이드바 너비" aria-expanded="true" aria-valuemin="4" aria-valuemax="60" aria-valuenow="34">
<i class="dc__grip" aria-hidden="true"></i>
</div>
<section class="dc__main" aria-label="본문">
<b class="dc__head"></b>
<span class="dc__line"></span>
<span class="dc__line dc__line--short"></span>
</section>
</div>
<p class="dc__cap">손잡이를 두 번 눌러 접기</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
@property --split {
syntax: "<percentage>";
inherits: true;
initial-value: 34%;
}
.dc {
--split: 34%;
width: min(300px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.dc__box {
display: grid;
grid-template-columns: var(--split) 10px 1fr;
height: 124px;
border-radius: $r-card;
overflow: hidden;
background: $subject-ink;
}
// 손이 끌고 있을 때만 transition 을 끈다 — 접기는 부드럽게, 드래그는 즉시
// @property 로 등록된 --split 이라 transition 대상이 될 수 있다
.dc:not(.is-live) { transition: --split $duration $easing; }
.dc__side,
.dc__main {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-3;
overflow: hidden;
}
.dc__side { background: rgba(47, 109, 246, .34); }
.dc__main { background: rgba(255, 247, 230, .06); }
.dc__ico {
flex: 0 0 auto;
width: 16px;
height: 16px;
color: $subject-cream;
transition: opacity $dur-quick $easing;
}
.dc__ico svg { display: block; width: 16px; height: 16px; }
.dc.is-folded .dc__ico { opacity: 0; }
.dc__row {
flex: 0 0 auto;
height: 8px;
border-radius: $r-xs;
background: rgba(255, 247, 230, .42);
}
.dc__head {
flex: 0 0 auto;
height: 14px;
width: 56%;
border-radius: $r-xs;
background: $subject-cream;
}
.dc__line {
flex: 0 0 auto;
height: 8px;
border-radius: $r-xs;
background: rgba(255, 247, 230, .26);
}
.dc__line--short { width: 62%; }
.dc__bar {
display: grid;
place-items: center;
background: rgba(255, 247, 230, .2);
cursor: col-resize;
touch-action: none;
transition: background $dur-quick $easing;
}
.dc__grip {
display: block;
width: 2px;
height: 24px;
border-radius: $r-pill;
background: $subject-cream;
}
.dc__bar:hover { background: $color; }
.dc__bar:focus-visible { outline: 2px solid $subject-cream; outline-offset: -2px; }
.dc__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 700;
color: rgba(23, 20, 26, .68);
text-align: center;
}
// ── 자동 루프: 펼침 → 접힘 → 펼침. 접기 전 폭 34% 로 정확히 돌아온다 ──
.dc.is-demo { animation: dc-fold $dur-loop $easing infinite; }
@keyframes dc-fold {
0%, 14% { --split: 34%; }
38%, 56% { --split: 4%; }
82%, 100% { --split: 34%; }
}
.dc.is-demo .dc__ico { animation: dc-ico $dur-loop steps(1, end) infinite; }
@keyframes dc-ico {
0%, 29% { opacity: 1; }
30%, 63% { opacity: 0; }
64%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.dc.is-demo,
.dc.is-demo .dc__ico { animation: none !important; }
.dc, .dc__bar, .dc__ico { transition: none; }
}
@media (max-width: 340px) {
.dc__box { height: 108px; }
.dc__side, .dc__main { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --split {
syntax: "<percentage>";
inherits: true;
initial-value: 34%;
}
.dc {
--split: 34%;
width: min(300px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.dc__box {
display: grid;
grid-template-columns: var(--split) 10px 1fr;
height: 124px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
}
.dc:not(.is-live) {
transition: --split 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dc__side,
.dc__main {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
overflow: hidden;
}
.dc__side {
background: rgba(47, 109, 246, 0.34);
}
.dc__main {
background: rgba(255, 247, 230, 0.06);
}
.dc__ico {
flex: 0 0 auto;
width: 16px;
height: 16px;
color: #fff7e6;
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dc__ico svg {
display: block;
width: 16px;
height: 16px;
}
.dc.is-folded .dc__ico {
opacity: 0;
}
.dc__row {
flex: 0 0 auto;
height: 8px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.42);
}
.dc__head {
flex: 0 0 auto;
height: 14px;
width: 56%;
border-radius: 4px;
background: #fff7e6;
}
.dc__line {
flex: 0 0 auto;
height: 8px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.26);
}
.dc__line--short {
width: 62%;
}
.dc__bar {
display: grid;
place-items: center;
background: rgba(255, 247, 230, 0.2);
cursor: col-resize;
touch-action: none;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dc__grip {
display: block;
width: 2px;
height: 24px;
border-radius: 999px;
background: #fff7e6;
}
.dc__bar:hover {
background: #2f6df6;
}
.dc__bar:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: -2px;
}
.dc__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 700;
color: rgba(23, 20, 26, 0.68);
text-align: center;
}
.dc.is-demo {
animation: dc-fold 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes dc-fold {
0%, 14% {
--split: 34%;
}
38%, 56% {
--split: 4%;
}
82%, 100% {
--split: 34%;
}
}
.dc.is-demo .dc__ico {
animation: dc-ico 2s steps(1, end) infinite;
}
@keyframes dc-ico {
0%, 29% {
opacity: 1;
}
30%, 63% {
opacity: 0;
}
64%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.dc.is-demo,
.dc.is-demo .dc__ico {
animation: none !important;
}
.dc, .dc__bar, .dc__ico {
transition: none;
}
}
@media (max-width: 340px) {
.dc__box {
height: 108px;
}
.dc__side, .dc__main {
padding: 8px;
}
}// 실제 동작: dblclick 한 번에 접힘 ↔ 접기 직전 폭. 폭 자체는 CSS transition 이 옮긴다.
var root = document.getElementById('dc-root');
var box = document.getElementById('dc-box');
var bar = document.getElementById('dc-bar');
var RAIL = 4, last = 34, folded = false, dragging = 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);
}
function stop() { root.classList.remove('is-demo'); }
bar.addEventListener('dblclick', function () {
stop();
folded = !folded;
if (folded) set(RAIL, true);
else set(last, false);
});
bar.addEventListener('pointerdown', function (e) {
stop();
dragging = true;
bar.setPointerCapture(e.pointerId);
e.preventDefault();
});
bar.addEventListener('pointermove', function (e) {
if (!dragging) return;
root.classList.add('is-live');
var pct = (e.clientX - box.getBoundingClientRect().left) / box.clientWidth * 100;
last = Math.max(14, Math.min(pct, 60));
folded = false;
set(last, false);
});
function drop() { dragging = false; root.classList.remove('is-live'); }
bar.addEventListener('pointerup', drop);
bar.addEventListener('pointercancel', drop);
bar.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
stop();
folded = !folded;
set(folded ? RAIL : last, folded);
bar.focus({ preventScroll: true });
e.preventDefault();
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CollapsiblePanel.module.scss";
export type CollapsiblePanelProps = {
start?: number;
/** 접혔을 때 남는 띠 폭 (%) */
rail?: 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 Box<T> = { current: T };
type Vars = CSSProperties & { "--split"?: string; "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CollapsiblePanel({
start = 34,
rail = 4,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CollapsiblePanelProps) {
const [split, setSplit] = useState(start);
const [folded, setFolded] = useState(false);
const [live, setLive] = useState(false);
const last = useRef(start) as Box<number>;
const boxRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false) as Box<boolean>;
function toggle(): void {
const next = !folded;
setFolded(next);
setSplit(next ? rail : last.current);
}
function onDown(e: PEvt): void {
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onMove(e: PEvt): void {
const box = boxRef.current;
if (!dragging.current || !box) return;
setLive(true);
const pct = ((e.clientX - box.getBoundingClientRect().left) / box.clientWidth) * 100;
last.current = Math.max(14, Math.min(pct, 60));
setFolded(false);
setSplit(last.current);
}
function onUp(): void { dragging.current = false; setLive(false); }
function onKey(e: KeyEvt): void {
if (e.key !== "Enter" && e.key !== " ") return;
toggle();
e.preventDefault();
}
const vars: Vars = {
"--split": `${split.toFixed(1)}%`,
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
const cls = [styles.dc, folded ? styles.isFolded : "", live ? styles.isLive : ""].join(" ").trim();
return (
<div className={cls} style={vars}>
<div className={styles.box} ref={boxRef}>
<section className={styles.side} aria-label="Sidebar">
<span className={styles.ico}>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M88 48v160H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8Z" opacity=".2" /><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16M40 56h40v144H40Zm176 144H96V56h120z" /></g></svg>
</span>
<span className={styles.row} />
<span className={styles.row} />
<span className={styles.row} />
</section>
<div
className={styles.bar}
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-label="Sidebar width"
aria-expanded={!folded}
aria-valuemin={4}
aria-valuemax={60}
aria-valuenow={Math.round(split)}
onDoubleClick={toggle}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={onKey}
>
<i className={styles.grip} aria-hidden="true" />
</div>
<section className={styles.main} aria-label="Main">
<b className={styles.head} />
<span className={styles.line} />
<span className={`${styles.line} ${styles.lineShort}`} />
</section>
</div>
<p className={styles.cap}>Double-click the handle to fold</p>
</div>
);
}04 · 더블클릭 접기 — 측정 누적면적 9.474% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
접기 전 값을 변수 하나에 담아 두기 때문에, 펼칠 때 기본값이 아니라 그 사람이 마지막으로 맞춘 폭이 나옵니다.
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% 이내로 다가오면 값이 그 자리에 딱 붙습니다.

<main class="stage">
<div class="sg is-demo" id="sg-root">
<div class="sg__box" id="sg-box">
<div class="sg__guides" aria-hidden="true">
<i class="sg__g sg__g--a"></i>
<i class="sg__g sg__g--b"></i>
<i class="sg__g sg__g--c"></i>
</div>
<section class="sg__pane sg__pane--l" aria-label="왼쪽 화면">
<span class="sg__ln"></span>
<span class="sg__ln sg__ln--s"></span>
<span class="sg__ln"></span>
</section>
<div class="sg__bar" id="sg-bar" role="separator" tabindex="0"
aria-orientation="vertical" aria-label="분할 비율" aria-valuemin="10" aria-valuemax="90" aria-valuenow="50">
<i class="sg__grip" aria-hidden="true"></i>
</div>
<section class="sg__pane sg__pane--r" aria-label="오른쪽 화면">
<span class="sg__ln"></span>
<span class="sg__ln sg__ln--s"></span>
</section>
</div>
<p class="sg__cap">25 · 50 · 75% 근처에서 자석처럼 붙는다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
@property --split {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
.sg {
--split: 50%;
width: min(304px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.sg__box {
position: relative;
display: grid;
grid-template-columns: var(--split) 10px 1fr;
height: 124px;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .05);
}
.sg__pane {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-2;
padding: $sp-3;
overflow: hidden;
}
.sg__ln {
flex: 0 0 auto;
height: 8px;
border-radius: $r-xs;
background: rgba(255, 247, 230, .38);
}
.sg__ln--s { width: 56%; }
.sg__pane--l { background: rgba(76, 212, 166, .2); }
.sg__pane--r { background: rgba(185, 160, 255, .18); }
.sg__guides {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
opacity: 0;
transition: opacity $dur-quick $easing;
}
.sg.is-drag .sg__guides { opacity: 1; }
.sg__g {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
transform: translateX(-1px);
background: rgba(255, 247, 230, .34);
}
.sg__g--a { left: 25%; }
.sg__g--b { left: 50%; }
.sg__g--c { left: 75%; }
.sg__bar {
position: relative;
z-index: 3;
display: grid;
place-items: center;
background: rgba(255, 247, 230, .18);
cursor: col-resize;
touch-action: none;
transition: background $duration $easing;
}
.sg__grip {
display: block;
width: 2px;
height: 28px;
border-radius: $r-pill;
background: $subject-cream;
}
.sg__bar:hover,
.sg.is-drag .sg__bar { background: $color; }
.sg.is-snapped .sg__bar { background: $color; }
.sg__bar:focus-visible { outline: 2px solid $subject-cream; outline-offset: -2px; }
.sg__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(255, 247, 230, .58);
text-align: center;
}
// ── 자동 루프: 안내선이 켜지고, 손잡이가 25 → 75 로 가며 두 번 달라붙는다 ──
.sg.is-demo { animation: sg-move $dur-loop $ease-out infinite; }
@keyframes sg-move {
0%, 6% { --split: 50%; }
22% { --split: 30%; }
26%, 40% { --split: 25%; }
60% { --split: 70%; }
64%, 78% { --split: 75%; }
88%, 100% { --split: 50%; }
}
.sg.is-demo .sg__guides { animation: sg-guides $dur-loop steps(1, end) infinite; }
@keyframes sg-guides {
0%, 7% { opacity: 0; }
8%, 84% { opacity: 1; }
85%, 100% { opacity: 0; }
}
// 달라붙은 구간에만 굵어지는 안내선 — 컷으로 바뀐다
.sg.is-demo .sg__g--a { animation: sg-hit-a $dur-loop steps(1, end) infinite; }
.sg.is-demo .sg__g--c { animation: sg-hit-c $dur-loop steps(1, end) infinite; }
@keyframes sg-hit-a {
0%, 25% { width: 2px; background: rgba(255, 247, 230, .34); }
26%, 40% { width: 4px; background: $subject-mint; }
41%, 100% { width: 2px; background: rgba(255, 247, 230, .34); }
}
@keyframes sg-hit-c {
0%, 63% { width: 2px; background: rgba(255, 247, 230, .34); }
64%, 78% { width: 4px; background: $subject-mint; }
79%, 100% { width: 2px; background: rgba(255, 247, 230, .34); }
}
.sg.is-demo .sg__bar { animation: sg-hot $dur-loop steps(1, end) infinite; }
@keyframes sg-hot {
0%, 25% { background: rgba(255, 247, 230, .18); }
26%, 40% { background: $subject-mint; }
41%, 63% { background: rgba(255, 247, 230, .18); }
64%, 78% { background: $subject-mint; }
79%, 100% { background: rgba(255, 247, 230, .18); }
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo,
.sg.is-demo .sg__guides,
.sg.is-demo .sg__g--a,
.sg.is-demo .sg__g--c,
.sg.is-demo .sg__bar { animation: none !important; }
.sg__guides, .sg__bar { transition: none; }
}
@media (max-width: 340px) {
.sg__box { height: 108px; }
.sg__grip { height: 24px; }
}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%;
}
.sg {
--split: 50%;
width: min(304px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.sg__box {
position: relative;
display: grid;
grid-template-columns: var(--split) 10px 1fr;
height: 124px;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
}
.sg__pane {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 12px;
overflow: hidden;
}
.sg__ln {
flex: 0 0 auto;
height: 8px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.38);
}
.sg__ln--s {
width: 56%;
}
.sg__pane--l {
background: rgba(76, 212, 166, 0.2);
}
.sg__pane--r {
background: rgba(185, 160, 255, 0.18);
}
.sg__guides {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
opacity: 0;
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sg.is-drag .sg__guides {
opacity: 1;
}
.sg__g {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
transform: translateX(-1px);
background: rgba(255, 247, 230, 0.34);
}
.sg__g--a {
left: 25%;
}
.sg__g--b {
left: 50%;
}
.sg__g--c {
left: 75%;
}
.sg__bar {
position: relative;
z-index: 3;
display: grid;
place-items: center;
background: rgba(255, 247, 230, 0.18);
cursor: col-resize;
touch-action: none;
transition: background 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sg__grip {
display: block;
width: 2px;
height: 28px;
border-radius: 999px;
background: #fff7e6;
}
.sg__bar:hover,
.sg.is-drag .sg__bar {
background: #4cd4a6;
}
.sg.is-snapped .sg__bar {
background: #4cd4a6;
}
.sg__bar:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: -2px;
}
.sg__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(255, 247, 230, 0.58);
text-align: center;
}
.sg.is-demo {
animation: sg-move 2s ease-out infinite;
}
@keyframes sg-move {
0%, 6% {
--split: 50%;
}
22% {
--split: 30%;
}
26%, 40% {
--split: 25%;
}
60% {
--split: 70%;
}
64%, 78% {
--split: 75%;
}
88%, 100% {
--split: 50%;
}
}
.sg.is-demo .sg__guides {
animation: sg-guides 2s steps(1, end) infinite;
}
@keyframes sg-guides {
0%, 7% {
opacity: 0;
}
8%, 84% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
.sg.is-demo .sg__g--a {
animation: sg-hit-a 2s steps(1, end) infinite;
}
.sg.is-demo .sg__g--c {
animation: sg-hit-c 2s steps(1, end) infinite;
}
@keyframes sg-hit-a {
0%, 25% {
width: 2px;
background: rgba(255, 247, 230, 0.34);
}
26%, 40% {
width: 4px;
background: #4cd4a6;
}
41%, 100% {
width: 2px;
background: rgba(255, 247, 230, 0.34);
}
}
@keyframes sg-hit-c {
0%, 63% {
width: 2px;
background: rgba(255, 247, 230, 0.34);
}
64%, 78% {
width: 4px;
background: #4cd4a6;
}
79%, 100% {
width: 2px;
background: rgba(255, 247, 230, 0.34);
}
}
.sg.is-demo .sg__bar {
animation: sg-hot 2s steps(1, end) infinite;
}
@keyframes sg-hot {
0%, 25% {
background: rgba(255, 247, 230, 0.18);
}
26%, 40% {
background: #4cd4a6;
}
41%, 63% {
background: rgba(255, 247, 230, 0.18);
}
64%, 78% {
background: #4cd4a6;
}
79%, 100% {
background: rgba(255, 247, 230, 0.18);
}
}
@media (prefers-reduced-motion: reduce) {
.sg.is-demo,
.sg.is-demo .sg__guides,
.sg.is-demo .sg__g--a,
.sg.is-demo .sg__g--c,
.sg.is-demo .sg__bar {
animation: none !important;
}
.sg__guides, .sg__bar {
transition: none;
}
}
@media (max-width: 340px) {
.sg__box {
height: 108px;
}
.sg__grip {
height: 24px;
}
}// 실제 동작: 안내선 세 곳에서 6% 안으로 들어오면 그 비율로 스냅. Math.abs 로 거리만 본다.
var root = document.getElementById('sg-root');
var box = document.getElementById('sg-box');
var bar = document.getElementById('sg-bar');
var STOPS = [25, 50, 75], NEAR = 6, dragging = false;
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 + '%');
bar.setAttribute('aria-valuenow', String(Math.round(v)));
root.classList.toggle('is-snapped', STOPS.indexOf(v) >= 0);
}
bar.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragging = true;
root.classList.add('is-drag');
bar.setPointerCapture(e.pointerId);
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 d = e.key === 'ArrowLeft' ? -5 : e.key === 'ArrowRight' ? 5 : 0;
if (!d) return;
root.classList.remove('is-demo');
set(Number(bar.getAttribute('aria-valuenow')) + d);
bar.focus({ preventScroll: true });
e.preventDefault();
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SnapGuideSplitter.module.scss";
export type SnapGuideSplitterProps = {
/** 자석이 되는 비율들 */
stops?: number[];
/** 몇 % 안에 들어오면 붙는가 */
near?: 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 };
export default function SnapGuideSplitter({
stops = [25, 50, 75],
near = 6,
duration = 140,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: SnapGuideSplitterProps) {
const [split, setSplit] = useState(50);
const [drag, setDrag] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
function snap(pct: number): number {
let best = pct;
for (let i = 0; i < stops.length; i += 1) {
if (Math.abs(pct - stops[i]) < near) best = stops[i];
}
return Math.max(10, Math.min(best, 90));
}
function set(pct: number): void { setSplit(snap(pct)); }
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" ? -5 : e.key === "ArrowRight" ? 5 : 0;
if (!d) return;
set(split + d);
e.preventDefault();
}
const snapped = stops.indexOf(split) >= 0;
const vars: Vars = {
"--split": `${split}%`,
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
const cls = [styles.sg, drag ? styles.isDrag : "", snapped ? styles.isSnapped : ""].join(" ").trim();
return (
<div className={cls} style={vars}>
<div className={styles.box} ref={boxRef}>
<div className={styles.guides} aria-hidden="true">
{stops.map((s) => (
<i key={s} className={styles.g} style={{ left: `${s}%` }} />
))}
</div>
<section className={`${styles.pane} ${styles.paneL}`} aria-label="Left pane">
<span className={styles.ln} />
<span className={`${styles.ln} ${styles.lnShort}`} />
<span className={styles.ln} />
</section>
<div
className={styles.bar}
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-label="Split ratio"
aria-valuemin={10}
aria-valuemax={90}
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.paneR}`} aria-label="Right pane">
<span className={styles.ln} />
<span className={`${styles.ln} ${styles.lnShort}`} />
</section>
</div>
<p className={styles.cap}>It snaps at 25, 50 and 75 percent</p>
</div>
);
}05 · 스냅 가이드라인 — 측정 누적면적 14.64% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
붙일지 말지는 거리만 보면 되므로 Math.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손잡이 시각 피드백
어떤 움직임인가
평소엔 머리카락 한 올 같은 경계선이 마우스를 올리면 넓어지며 점 세 개를 드러내고, 끄는 동안에는 색이 바뀐 채로 남습니다.

<main class="stage">
<div class="hg is-demo" id="hg-root">
<div class="hg__box" id="hg-box">
<section class="hg__pane hg__pane--l" aria-label="왼쪽 화면">
<span class="hg__ln"></span>
<span class="hg__ln hg__ln--s"></span>
<span class="hg__ln"></span>
</section>
<div class="hg__bar" id="hg-bar" role="separator" tabindex="0"
aria-orientation="vertical" aria-label="분할 너비" aria-valuemin="20" aria-valuemax="80" aria-valuenow="46">
<i class="hg__hair" aria-hidden="true"></i>
<i class="hg__dots" aria-hidden="true"><s></s><s></s><s></s></i>
</div>
<section class="hg__pane hg__pane--r" aria-label="오른쪽 화면">
<span class="hg__ln hg__ln--d"></span>
<span class="hg__ln hg__ln--d hg__ln--s"></span>
</section>
</div>
<p class="hg__cap">올리면 넓어지고, 끄는 동안 켜져 있다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: cubic-bezier(.2, .8, .2, 1);
$color: $subject-cream;
@include stage($stage-orange);
@property --split {
syntax: "<percentage>";
inherits: true;
initial-value: 46%;
}
.hg {
--split: 46%;
width: min(300px, 88vw);
display: flex;
flex-direction: column;
gap: $sp-2;
}
.hg__box {
display: grid;
grid-template-columns: var(--split) 20px 1fr;
height: 120px;
border-radius: $r-card;
overflow: hidden;
background: rgba(23, 20, 26, .2);
}
.hg__pane {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-2;
padding: $sp-3;
overflow: hidden;
}
.hg__ln {
flex: 0 0 auto;
height: 8px;
border-radius: $r-xs;
background: rgba(23, 20, 26, .2);
}
.hg__ln--d { background: rgba(255, 247, 230, .32); }
.hg__ln--s { width: 58%; }
.hg__pane--l { background: rgba(255, 247, 230, .9); }
.hg__pane--r { background: rgba(23, 20, 26, .78); }
.hg__bar {
position: relative;
display: grid;
place-items: center;
background: transparent;
cursor: col-resize;
touch-action: none;
}
// 평소엔 머리카락 한 줄. 호버·드래그에서 넓어진다
.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__dots {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-1;
opacity: 0;
transform: scale(.5);
transition: opacity $duration $easing, transform $duration $easing;
}
.hg__dots s {
display: block;
width: 4px;
height: 4px;
border-radius: 50%;
background: $color;
}
.hg.is-near .hg__hair,
.hg__bar:hover .hg__hair { transform: translateX(-10px) scaleX(1); }
.hg.is-near .hg__dots,
.hg__bar:hover .hg__dots { opacity: 1; transform: scale(1); }
.hg.is-drag .hg__hair { background: $subject-blue; }
.hg__bar:focus-visible { outline: 2px solid $subject-ink; outline-offset: -2px; }
.hg__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 700;
color: $subject-ink; // AA 4.5: cream on orange 2.76 -> ink 5.50
text-align: center;
}
// ── 자동 루프: 가는 선 → 넓어짐 + 점 등장 → 끌림(파랑) → 되돌아옴 ──
.hg.is-demo .hg__hair {
animation-name: hg-hair;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes hg-hair {
0%, 10% { transform: translateX(-10px) scaleX(.1); background: $subject-ink; }
26%, 34% { transform: translateX(-10px) scaleX(1); background: $subject-ink; }
44%, 72% { transform: translateX(-10px) scaleX(1); background: $subject-blue; }
86%, 100% { transform: translateX(-10px) scaleX(.1); background: $subject-ink; }
}
.hg.is-demo .hg__dots {
animation-name: hg-dots;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes hg-dots {
0%, 12% { opacity: 0; transform: scale(.5); }
28%, 74% { opacity: 1; transform: scale(1); }
86%, 100% { opacity: 0; transform: scale(.5); }
}
.hg.is-demo {
animation-name: hg-split;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes hg-split {
0%, 40% { --split: 46%; }
70% { --split: 66%; }
86%, 100% { --split: 46%; }
}
@media (prefers-reduced-motion: reduce) {
.hg.is-demo,
.hg.is-demo .hg__hair,
.hg.is-demo .hg__dots { animation: none !important; }
.hg__hair, .hg__dots { transition: none; }
}
@media (max-width: 340px) {
.hg__box { height: 104px; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --split {
syntax: "<percentage>";
inherits: true;
initial-value: 46%;
}
.hg {
--split: 46%;
width: min(300px, 88vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.hg__box {
display: grid;
grid-template-columns: var(--split) 20px 1fr;
height: 120px;
border-radius: 16px;
overflow: hidden;
background: rgba(23, 20, 26, 0.2);
}
.hg__pane {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 12px;
overflow: hidden;
}
.hg__ln {
flex: 0 0 auto;
height: 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.2);
}
.hg__ln--d {
background: rgba(255, 247, 230, 0.32);
}
.hg__ln--s {
width: 58%;
}
.hg__pane--l {
background: rgba(255, 247, 230, 0.9);
}
.hg__pane--r {
background: rgba(23, 20, 26, 0.78);
}
.hg__bar {
position: relative;
display: grid;
place-items: center;
background: transparent;
cursor: col-resize;
touch-action: none;
}
.hg__hair {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 20px;
border-radius: 999px;
background: #17141a;
transform: translateX(-10px) scaleX(0.1);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.hg__dots {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
opacity: 0;
transform: scale(0.5);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.hg__dots s {
display: block;
width: 4px;
height: 4px;
border-radius: 50%;
background: #fff7e6;
}
.hg.is-near .hg__hair,
.hg__bar:hover .hg__hair {
transform: translateX(-10px) scaleX(1);
}
.hg.is-near .hg__dots,
.hg__bar:hover .hg__dots {
opacity: 1;
transform: scale(1);
}
.hg.is-drag .hg__hair {
background: #2f6df6;
}
.hg__bar:focus-visible {
outline: 2px solid #17141a;
outline-offset: -2px;
}
.hg__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 700;
color: #17141a;
text-align: center;
}
.hg.is-demo .hg__hair {
animation-name: hg-hair;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes hg-hair {
0%, 10% {
transform: translateX(-10px) scaleX(0.1);
background: #17141a;
}
26%, 34% {
transform: translateX(-10px) scaleX(1);
background: #17141a;
}
44%, 72% {
transform: translateX(-10px) scaleX(1);
background: #2f6df6;
}
86%, 100% {
transform: translateX(-10px) scaleX(0.1);
background: #17141a;
}
}
.hg.is-demo .hg__dots {
animation-name: hg-dots;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes hg-dots {
0%, 12% {
opacity: 0;
transform: scale(0.5);
}
28%, 74% {
opacity: 1;
transform: scale(1);
}
86%, 100% {
opacity: 0;
transform: scale(0.5);
}
}
.hg.is-demo {
animation-name: hg-split;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes hg-split {
0%, 40% {
--split: 46%;
}
70% {
--split: 66%;
}
86%, 100% {
--split: 46%;
}
}
@media (prefers-reduced-motion: reduce) {
.hg.is-demo,
.hg.is-demo .hg__hair,
.hg.is-demo .hg__dots {
animation: none !important;
}
.hg__hair, .hg__dots {
transition: none;
}
}
@media (max-width: 340px) {
.hg__box {
height: 104px;
}
}// 실제 동작: 손잡이 상태는 CSS 가 그린다. JS 는 끌기 시작·끝만 클래스로 알려 준다.
var root = document.getElementById('hg-root');
var box = document.getElementById('hg-box');
var bar = document.getElementById('hg-bar');
var dragging = false;
function set(pct) {
var v = Math.max(20, Math.min(pct, 80));
root.style.setProperty('--split', v.toFixed(1) + '%');
bar.setAttribute('aria-valuenow', String(Math.round(v)));
}
bar.addEventListener('pointerenter', function () { root.classList.add('is-near'); });
bar.addEventListener('pointerleave', function () { if (!dragging) root.classList.remove('is-near'); });
bar.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragging = true;
root.classList.add('is-drag');
bar.setPointerCapture(e.pointerId);
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');
root.classList.remove('is-near');
}
bar.addEventListener('pointerup', drop);
bar.addEventListener('pointercancel', drop);
bar.addEventListener('keydown', function (e) {
var d = e.key === 'ArrowLeft' ? -4 : e.key === 'ArrowRight' ? 4 : 0;
if (!d) return;
root.classList.remove('is-demo');
set(Number(bar.getAttribute('aria-valuenow')) + d);
bar.focus({ preventScroll: true });
e.preventDefault();
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GripHandle.module.scss";
export type GripHandleProps = {
start?: 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 };
export default function GripHandle({
start = 46,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: GripHandleProps) {
const [split, setSplit] = useState(start);
const [near, setNear] = useState(false);
const [drag, setDrag] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
function set(pct: number): void { setSplit(Math.max(20, Math.min(pct, 80))); }
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); setNear(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,
};
const cls = [styles.hg, near ? styles.isNear : "", drag ? styles.isDrag : ""].join(" ").trim();
return (
<div className={cls} style={vars}>
<div className={styles.box} ref={boxRef}>
<section className={`${styles.pane} ${styles.paneL}`} aria-label="Left pane">
<span className={styles.ln} />
<span className={`${styles.ln} ${styles.lnShort}`} />
<span className={styles.ln} />
</section>
<div
className={styles.bar}
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-label="Split width"
aria-valuemin={20}
aria-valuemax={80}
aria-valuenow={Math.round(split)}
onPointerEnter={(): void => setNear(true)}
onPointerLeave={(): void => { if (!drag) setNear(false); }}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={onKey}
>
<i className={styles.hair} aria-hidden="true" />
<i className={styles.dots} aria-hidden="true"><s /><s /><s /></i>
</div>
<section className={`${styles.pane} ${styles.paneR}`} aria-label="Right pane">
<span className={`${styles.ln} ${styles.lnDark}`} />
<span className={`${styles.ln} ${styles.lnDark} ${styles.lnShort}`} />
</section>
</div>
<p className={styles.cap}>It widens on hover and stays lit while you drag</p>
</div>
);
}06 · 손잡이 시각 피드백 — 측정 누적면적 7.308% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
너비를 실제로 바꾸는 대신 scaleX로 늘리기 때문에 옆 칸 크기는 그대로입니다.
.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비율 저장과 프리셋
무엇이 보이나
끌어서 맞춘 비율이 곧바로 브라우저에 저장되고, 다음에 열면 저장된 값에서 시작합니다.

<main class="stage">
<div class="rp is-demo" id="rp-root">
<div class="rp__box" id="rp-box">
<section class="rp__pane rp__pane--l" aria-label="왼쪽 화면">
<span class="rp__ln"></span>
<span class="rp__ln rp__ln--s"></span>
</section>
<div class="rp__bar" id="rp-bar" role="separator" tabindex="0"
aria-orientation="vertical" aria-label="분할 비율" aria-valuemin="20" aria-valuemax="80" aria-valuenow="50">
<i class="rp__grip" aria-hidden="true"></i>
</div>
<section class="rp__pane rp__pane--r" aria-label="오른쪽 화면">
<span class="rp__ln"></span>
<span class="rp__ln rp__ln--s"></span>
</section>
</div>
<div class="rp__row">
<span class="rp__save" aria-hidden="true"><svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 83.31V208a8 8 0 0 1-8 8h-32v-64a8 8 0 0 0-8-8H88a8 8 0 0 0-8 8v64H48a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h124.69a8 8 0 0 1 5.65 2.34l35.32 35.32a8 8 0 0 1 2.34 5.65" opacity=".2"/><path d="M219.31 72L184 36.69A15.86 15.86 0 0 0 172.69 32H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V83.31A15.86 15.86 0 0 0 219.31 72M168 208H88v-56h80Zm40 0h-24v-56a16 16 0 0 0-16-16H88a16 16 0 0 0-16 16v56H48V48h124.69L208 83.31ZM160 72a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h56a8 8 0 0 1 8 8"/></g></svg></span>
<button class="rp__btn" type="button" data-v="34" aria-pressed="false">1:2</button>
<button class="rp__btn is-on" type="button" data-v="50" aria-pressed="true">1:1</button>
<button class="rp__btn" type="button" data-v="66" aria-pressed="false">2:1</button>
</div>
<p class="rp__cap">비율은 브라우저에 저장돼 다음에도 그대로</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
@property --split {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
.rp {
--split: 50%;
width: min(276px, 86vw);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.rp.is-glide { transition: --split $duration $easing; }
.rp__box {
display: grid;
grid-template-columns: var(--split) 8px 1fr;
width: 100%;
height: 88px;
border-radius: $r-card;
overflow: hidden;
border: 1px solid rgba(23, 20, 26, .12);
}
.rp__pane {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-2;
padding: $sp-3;
overflow: hidden;
}
.rp__ln {
flex: 0 0 auto;
height: 8px;
border-radius: $r-xs;
background: rgba(23, 20, 26, .22);
}
.rp__ln--s { width: 58%; }
.rp__pane--l { background: rgba(47, 109, 246, .18); }
.rp__pane--r { background: rgba(23, 20, 26, .08); }
.rp__bar {
display: grid;
place-items: center;
background: rgba(23, 20, 26, .16);
cursor: col-resize;
touch-action: none;
transition: background $dur-quick $easing;
}
.rp__grip {
display: block;
width: 2px;
height: 20px;
border-radius: $r-pill;
background: #ffffff;
}
.rp__bar:hover { background: $color; }
.rp__bar:focus-visible { outline: 2px solid $subject-ink; outline-offset: -2px; }
.rp__row {
display: flex;
align-items: center;
gap: $sp-2;
}
.rp__save {
display: block;
width: 16px;
height: 16px;
color: rgba(23, 20, 26, .45);
}
.rp__save svg { display: block; width: 16px; height: 16px; }
.rp__btn {
box-sizing: border-box;
height: 24px;
padding: 0 $sp-3;
border: 1px solid rgba(23, 20, 26, .18);
border-radius: $r-pill;
background: #ffffff;
color: rgba(23, 20, 26, .7);
font-family: inherit;
font-size: 11px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
transition: background $dur-quick $easing, color $dur-quick $easing;
}
.rp__btn.is-on {
background: $subject-ink;
border-color: $subject-ink;
color: $subject-cream;
}
.rp__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.rp__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(23, 20, 26, .72); // AA 4.5 (blog-design)
text-align: center;
}
// ── 자동 루프: 1:1 → 1:2 → 2:1 → 1:1. 눌린 단추는 컷으로 바뀐다 ──
.rp.is-demo { animation: rp-move $dur-loop $easing infinite; }
@keyframes rp-move {
0%, 10% { --split: 50%; }
34%, 46% { --split: 34%; }
70%, 82% { --split: 66%; }
90%, 100% { --split: 50%; }
}
.rp.is-demo .rp__btn:nth-of-type(1) { animation: rp-b1 $dur-loop steps(1, end) infinite; }
.rp.is-demo .rp__btn:nth-of-type(2) { animation: rp-b2 $dur-loop steps(1, end) infinite; }
.rp.is-demo .rp__btn:nth-of-type(3) { animation: rp-b3 $dur-loop steps(1, end) infinite; }
// 눌린 표시는 비율이 **도착한 뒤**에만 켠다 — 옮겨 가는 중에 켜면
// poster 가 "2:1 눌림 + 화면은 1:1" 인 어긋난 장면으로 뽑힌다 (242-06 과 같은 계열)
@keyframes rp-b1 {
0%, 33% { background: #ffffff; color: rgba(23, 20, 26, .7); }
34%, 46% { background: $subject-ink; color: $subject-cream; }
47%, 100% { background: #ffffff; color: rgba(23, 20, 26, .7); }
}
@keyframes rp-b2 {
0%, 10% { background: $subject-ink; color: $subject-cream; }
11%, 89% { background: #ffffff; color: rgba(23, 20, 26, .7); }
90%, 100% { background: $subject-ink; color: $subject-cream; }
}
@keyframes rp-b3 {
0%, 69% { background: #ffffff; color: rgba(23, 20, 26, .7); }
70%, 82% { background: $subject-ink; color: $subject-cream; }
83%, 100% { background: #ffffff; color: rgba(23, 20, 26, .7); }
}
@media (prefers-reduced-motion: reduce) {
.rp.is-demo,
.rp.is-demo .rp__btn { animation: none !important; }
.rp, .rp__btn, .rp__bar { transition: none; }
}
@media (max-width: 340px) {
.rp__box { height: 76px; }
.rp__btn { padding: 0 $sp-2; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
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%;
}
.rp {
--split: 50%;
width: min(276px, 86vw);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.rp.is-glide {
transition: --split 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rp__box {
display: grid;
grid-template-columns: var(--split) 8px 1fr;
width: 100%;
height: 88px;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(23, 20, 26, 0.12);
}
.rp__pane {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 12px;
overflow: hidden;
}
.rp__ln {
flex: 0 0 auto;
height: 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.22);
}
.rp__ln--s {
width: 58%;
}
.rp__pane--l {
background: rgba(47, 109, 246, 0.18);
}
.rp__pane--r {
background: rgba(23, 20, 26, 0.08);
}
.rp__bar {
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.16);
cursor: col-resize;
touch-action: none;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rp__grip {
display: block;
width: 2px;
height: 20px;
border-radius: 999px;
background: #ffffff;
}
.rp__bar:hover {
background: #2f6df6;
}
.rp__bar:focus-visible {
outline: 2px solid #17141a;
outline-offset: -2px;
}
.rp__row {
display: flex;
align-items: center;
gap: 8px;
}
.rp__save {
display: block;
width: 16px;
height: 16px;
color: rgba(23, 20, 26, 0.45);
}
.rp__save svg {
display: block;
width: 16px;
height: 16px;
}
.rp__btn {
box-sizing: border-box;
height: 24px;
padding: 0 12px;
border: 1px solid rgba(23, 20, 26, 0.18);
border-radius: 999px;
background: #ffffff;
color: rgba(23, 20, 26, 0.7);
font-family: inherit;
font-size: 11px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rp__btn.is-on {
background: #17141a;
border-color: #17141a;
color: #fff7e6;
}
.rp__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.rp__cap {
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
text-align: center;
}
.rp.is-demo {
animation: rp-move 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes rp-move {
0%, 10% {
--split: 50%;
}
34%, 46% {
--split: 34%;
}
70%, 82% {
--split: 66%;
}
90%, 100% {
--split: 50%;
}
}
.rp.is-demo .rp__btn:nth-of-type(1) {
animation: rp-b1 2s steps(1, end) infinite;
}
.rp.is-demo .rp__btn:nth-of-type(2) {
animation: rp-b2 2s steps(1, end) infinite;
}
.rp.is-demo .rp__btn:nth-of-type(3) {
animation: rp-b3 2s steps(1, end) infinite;
}
@keyframes rp-b1 {
0%, 33% {
background: #ffffff;
color: rgba(23, 20, 26, 0.7);
}
34%, 46% {
background: #17141a;
color: #fff7e6;
}
47%, 100% {
background: #ffffff;
color: rgba(23, 20, 26, 0.7);
}
}
@keyframes rp-b2 {
0%, 10% {
background: #17141a;
color: #fff7e6;
}
11%, 89% {
background: #ffffff;
color: rgba(23, 20, 26, 0.7);
}
90%, 100% {
background: #17141a;
color: #fff7e6;
}
}
@keyframes rp-b3 {
0%, 69% {
background: #ffffff;
color: rgba(23, 20, 26, 0.7);
}
70%, 82% {
background: #17141a;
color: #fff7e6;
}
83%, 100% {
background: #ffffff;
color: rgba(23, 20, 26, 0.7);
}
}
@media (prefers-reduced-motion: reduce) {
.rp.is-demo,
.rp.is-demo .rp__btn {
animation: none !important;
}
.rp, .rp__btn, .rp__bar {
transition: none;
}
}
@media (max-width: 340px) {
.rp__box {
height: 76px;
}
.rp__btn {
padding: 0 8px;
}
}// 실제 동작: 끌 때마다 localStorage 에 비율을 쓰고, 열 때 JSON.parse 로 읽어 복원한다.
var root = document.getElementById('rp-root');
var box = document.getElementById('rp-box');
var bar = document.getElementById('rp-bar');
var btns = [].slice.call(document.querySelectorAll('.rp__btn'));
var KEY = 'godrich-split-250', dragging = false;
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; }
}
try {
var saved = JSON.parse(localStorage.getItem(KEY) || 'null');
if (saved && saved.split) { root.classList.remove('is-demo'); set(saved.split, true); }
} catch (err) { /* 저장값이 깨졌으면 기본값 */ }
btns.forEach(function (b) {
b.addEventListener('click', function () {
root.classList.remove('is-demo');
root.classList.add('is-glide');
set(Number(b.dataset.v), true);
});
});
bar.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
root.classList.remove('is-glide');
dragging = true;
bar.setPointerCapture(e.pointerId);
e.preventDefault();
});
bar.addEventListener('pointermove', function (e) {
if (!dragging) return;
set((e.clientX - box.getBoundingClientRect().left) / box.clientWidth * 100, true);
});
bar.addEventListener('pointerup', function () { dragging = false; });
bar.addEventListener('keydown', function (e) {
var d = e.key === 'ArrowLeft' ? -4 : e.key === 'ArrowRight' ? 4 : 0;
if (!d) return;
root.classList.remove('is-demo');
root.classList.remove('is-glide');
set(Number(bar.getAttribute('aria-valuenow')) + d, true);
bar.focus({ preventScroll: true });
e.preventDefault();
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RatioPresets.module.scss";
export type RatioPresetsProps = {
/** localStorage 키 */
storageKey?: string;
presets?: { label: string; value: 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 };
export default function RatioPresets({
storageKey = "godrich-split-250",
presets = [{ label: "1:2", value: 34 }, { label: "1:1", value: 50 }, { label: "2:1", value: 66 }],
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: RatioPresetsProps) {
const [split, setSplit] = useState(50);
const [glide, setGlide] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
// 열 때 한 번 복원한다 — 값이 깨졌으면 기본값 그대로
useEffect(() => {
try {
const raw = localStorage.getItem(storageKey);
const saved = raw ? JSON.parse(raw) : null;
if (saved && typeof saved.split === "number") setSplit(saved.split);
} catch (err) { /* 사생활 보호 모드 */ }
}, [storageKey]);
function save(v: number): void {
try { localStorage.setItem(storageKey, JSON.stringify({ split: Math.round(v) })); } catch (err) { /* 저장 불가 */ }
}
function set(pct: number): void {
const v = Math.max(20, Math.min(pct, 80));
setSplit(v);
save(v);
}
function onDown(e: PEvt): void {
setGlide(false);
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onMove(e: PEvt): void {
const box = boxRef.current;
if (!dragging.current || !box) return;
set(((e.clientX - box.getBoundingClientRect().left) / box.clientWidth) * 100);
}
function onUp(): void { dragging.current = false; }
function onKey(e: KeyEvt): void {
const d = e.key === "ArrowLeft" ? -4 : e.key === "ArrowRight" ? 4 : 0;
if (!d) return;
setGlide(false);
set(split + d);
e.preventDefault();
}
const vars: Vars = {
"--split": `${split.toFixed(1)}%`,
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={glide ? `${styles.rp} ${styles.isGlide}` : styles.rp} style={vars}>
<div className={styles.box} ref={boxRef}>
<section className={`${styles.pane} ${styles.paneL}`} aria-label="Left pane">
<span className={styles.ln} />
<span className={`${styles.ln} ${styles.lnShort}`} />
</section>
<div
className={styles.bar}
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-label="Split ratio"
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.paneR}`} aria-label="Right pane">
<span className={styles.ln} />
<span className={`${styles.ln} ${styles.lnShort}`} />
</section>
</div>
<div className={styles.row}>
<span className={styles.save} aria-hidden="true">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 83.31V208a8 8 0 0 1-8 8h-32v-64a8 8 0 0 0-8-8H88a8 8 0 0 0-8 8v64H48a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h124.69a8 8 0 0 1 5.65 2.34l35.32 35.32a8 8 0 0 1 2.34 5.65" opacity=".2" /><path d="M219.31 72L184 36.69A15.86 15.86 0 0 0 172.69 32H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V83.31A15.86 15.86 0 0 0 219.31 72M168 208H88v-56h80Zm40 0h-24v-56a16 16 0 0 0-16-16H88a16 16 0 0 0-16 16v56H48V48h124.69L208 83.31ZM160 72a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h56a8 8 0 0 1 8 8" /></g></svg>
</span>
{presets.map((p) => (
<button
key={p.label}
type="button"
className={Math.round(split) === p.value ? `${styles.btn} ${styles.isOn}` : styles.btn}
aria-pressed={Math.round(split) === p.value}
onClick={(): void => { setGlide(true); set(p.value); }}
>
{p.label}
</button>
))}
</div>
<p className={styles.cap}>The ratio is stored in the browser for next time</p>
</div>
);
}07 · 비율 저장과 프리셋 — 측정 누적면적 8.308% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
1:2·1:1·2:1 프리셋 단추는 그 비율을 한 번에 지정하고, aria-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카드 코너 그립
화면에서 일어나는 일
카드 오른쪽 아래 모서리를 잡아 끌면 가로와 세로가 함께 자라고, 잠금 단추가 켜져 있으면 높이는 언제나 폭 × 처음 비율로 따라옵니다.

<main class="stage">
<div class="cg is-demo" id="cg-root">
<div class="cg__field">
<div class="cg__card" id="cg-card">
<span class="cg__row"></span>
<span class="cg__row cg__row--short"></span>
<span class="cg__size"></span>
<i class="cg__grip" id="cg-grip" role="slider" tabindex="0"
aria-label="카드 크기" aria-valuemin="120" aria-valuemax="220" aria-valuenow="176"></i>
</div>
</div>
<button class="cg__lock is-on" id="cg-lock" type="button" aria-pressed="true">
<span class="cg__ico" aria-hidden="true"><svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg></span>
비율 잠금
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
@property --w {
syntax: "<length>";
inherits: true;
initial-value: 176px;
}
@property --h {
syntax: "<length>";
inherits: true;
initial-value: 96px;
}
// 숫자도 등록해 둬야 키프레임에서 정수로 보간된다 — counter 가 이 값을 읽는다
@property --wn {
syntax: "<integer>";
inherits: true;
initial-value: 176;
}
@property --hn {
syntax: "<integer>";
inherits: true;
initial-value: 96;
}
.cg {
--w: 176px;
--h: 96px;
--wn: 176;
--hn: 96;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
// 카드가 커져도 무대 높이가 안 흔들리게 자리를 미리 잡아 둔다
.cg__field {
display: grid;
place-items: start center;
width: 228px;
max-width: 92vw;
height: 124px;
}
.cg__card {
position: relative;
box-sizing: border-box;
width: min(var(--w), 100%);
height: var(--h);
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-3;
border-radius: $r-card;
background: $subject-ink;
box-shadow: $shadow-raised;
overflow: hidden;
}
.cg__row {
flex: 0 0 auto;
height: 8px;
border-radius: $r-xs;
background: rgba(255, 247, 230, .32);
}
.cg__row--short { width: 56%; background: $color; }
.cg__size {
margin-top: auto;
counter-reset: wn var(--wn) hn var(--hn);
color: $subject-cream;
font-size: 11px;
line-height: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.cg__size::after { content: counter(wn) " × " counter(hn); }
.cg__grip {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
touch-action: none;
// 코너 삼각형 — 두 줄이 같은 꼭짓점에서 갈라진다
background:
linear-gradient(135deg, transparent 48%, #{$subject-cream} 48%, #{$subject-cream} 58%, transparent 58%),
linear-gradient(135deg, transparent 70%, #{$subject-cream} 70%, #{$subject-cream} 80%, transparent 80%);
}
.cg__grip:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.cg__lock {
display: inline-flex;
align-items: center;
gap: $sp-2;
height: 24px;
padding: 0 $sp-3;
border: 1px solid rgba(23, 20, 26, .28);
border-radius: $r-pill;
background: transparent;
color: rgba(23, 20, 26, .8);
font-family: inherit;
font-size: 11px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
transition: background $duration $easing, color $duration $easing;
}
.cg__lock.is-on {
background: $subject-ink;
border-color: $subject-ink;
color: $subject-cream;
}
.cg__ico { display: block; width: 14px; height: 14px; }
.cg__ico svg { display: block; width: 14px; height: 14px; }
.cg__lock:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// ── 자동 루프: 비율을 지킨 채 커졌다 작아진다 ──
.cg.is-demo {
animation-name: cg-size;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cg-size {
0%, 8% { --w: 176px; --h: 96px; --wn: 176; --hn: 96; }
40% { --w: 220px; --h: 120px; --wn: 220; --hn: 120; }
68% { --w: 124px; --h: 68px; --wn: 124; --hn: 68; }
88%, 100% { --w: 176px; --h: 96px; --wn: 176; --hn: 96; }
}
@media (prefers-reduced-motion: reduce) {
.cg.is-demo { animation: none !important; }
.cg__lock { transition: none; }
}
@media (max-width: 340px) {
.cg__card { padding: $sp-2; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --w {
syntax: "<length>";
inherits: true;
initial-value: 176px;
}
@property --h {
syntax: "<length>";
inherits: true;
initial-value: 96px;
}
@property --wn {
syntax: "<integer>";
inherits: true;
initial-value: 176;
}
@property --hn {
syntax: "<integer>";
inherits: true;
initial-value: 96;
}
.cg {
--w: 176px;
--h: 96px;
--wn: 176;
--hn: 96;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.cg__field {
display: grid;
place-items: start center;
width: 228px;
max-width: 92vw;
height: 124px;
}
.cg__card {
position: relative;
box-sizing: border-box;
width: min(var(--w), 100%);
height: var(--h);
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.cg__row {
flex: 0 0 auto;
height: 8px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.32);
}
.cg__row--short {
width: 56%;
background: #2f6df6;
}
.cg__size {
margin-top: auto;
counter-reset: wn var(--wn) hn var(--hn);
color: #fff7e6;
font-size: 11px;
line-height: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.cg__size::after {
content: counter(wn) " × " counter(hn);
}
.cg__grip {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
touch-action: none;
background: linear-gradient(135deg, transparent 48%, #fff7e6 48%, #fff7e6 58%, transparent 58%), linear-gradient(135deg, transparent 70%, #fff7e6 70%, #fff7e6 80%, transparent 80%);
}
.cg__grip:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.cg__lock {
display: inline-flex;
align-items: center;
gap: 8px;
height: 24px;
padding: 0 12px;
border: 1px solid rgba(23, 20, 26, 0.28);
border-radius: 999px;
background: transparent;
color: rgba(23, 20, 26, 0.8);
font-family: inherit;
font-size: 11px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cg__lock.is-on {
background: #17141a;
border-color: #17141a;
color: #fff7e6;
}
.cg__ico {
display: block;
width: 14px;
height: 14px;
}
.cg__ico svg {
display: block;
width: 14px;
height: 14px;
}
.cg__lock:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.cg.is-demo {
animation-name: cg-size;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cg-size {
0%, 8% {
--w: 176px;
--h: 96px;
--wn: 176;
--hn: 96;
}
40% {
--w: 220px;
--h: 120px;
--wn: 220;
--hn: 120;
}
68% {
--w: 124px;
--h: 68px;
--wn: 124;
--hn: 68;
}
88%, 100% {
--w: 176px;
--h: 96px;
--wn: 176;
--hn: 96;
}
}
@media (prefers-reduced-motion: reduce) {
.cg.is-demo {
animation: none !important;
}
.cg__lock {
transition: none;
}
}
@media (max-width: 340px) {
.cg__card {
padding: 8px;
}
}// 실제 동작: 코너 그립의 포인터 델타로 폭·높이를 함께 바꾼다. 잠금이 켜져 있으면 높이는 폭 × 처음 비율.
var root = document.getElementById('cg-root');
var card = document.getElementById('cg-card');
var grip = document.getElementById('cg-grip');
var lock = document.getElementById('cg-lock');
var RATIO = 96 / 176, locked = true, dragging = false, w0 = 0, h0 = 0, x0 = 0, y0 = 0;
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');
// 숫자 표시는 counter 가 읽는 --wn/--hn 한 쌍이다. 자동 루프도 같은 값을 움직이므로
// 카드 크기와 글자가 어긋나는 프레임이 없다
root.style.setProperty('--wn', String(W));
root.style.setProperty('--hn', String(H));
grip.setAttribute('aria-valuenow', String(W));
}
lock.addEventListener('click', function () {
locked = !locked;
lock.classList.toggle('is-on', locked);
lock.setAttribute('aria-pressed', String(locked));
root.classList.toggle('is-aspect', locked);
});
grip.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragging = true;
var r = card.getBoundingClientRect();
w0 = r.width; h0 = r.height; x0 = e.clientX; y0 = e.clientY;
grip.setPointerCapture(e.pointerId);
e.preventDefault();
});
grip.addEventListener('pointermove', function (e) {
if (!dragging) return;
set(w0 + (e.clientX - x0), h0 + (e.clientY - y0));
});
grip.addEventListener('pointerup', function () { dragging = false; });
grip.addEventListener('keydown', function (e) {
var d = e.key === 'ArrowLeft' ? -8 : e.key === 'ArrowRight' ? 8 : 0;
if (!d) return;
root.classList.remove('is-demo');
var n = Number(grip.getAttribute('aria-valuenow')) + d;
set(n, n * RATIO);
grip.focus({ preventScroll: true });
e.preventDefault();
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CornerResizeCard.module.scss";
export type CornerResizeCardProps = {
startWidth?: number;
startHeight?: number;
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type Grab = { w: number; h: number; x: number; y: number; on: boolean };
type Vars = CSSProperties & {
"--w"?: string; "--h"?: string; "--wn"?: string; "--hn"?: string;
"--duration"?: string; "--easing"?: string; "--color"?: string;
};
export default function CornerResizeCard({
startWidth = 176,
startHeight = 96,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CornerResizeCardProps) {
const ratio = startHeight / startWidth;
const [size, setSize] = useState({ w: startWidth, h: startHeight });
const [locked, setLocked] = useState(true);
const cardRef = useRef<HTMLDivElement>(null);
const from = useRef({ w: 0, h: 0, x: 0, y: 0, on: false }) as Box<Grab>;
function set(w: number, h: number): void {
const W = Math.max(120, Math.min(Math.round(w), 220));
const H = locked ? Math.round(W * ratio) : Math.max(64, Math.min(Math.round(h), 132));
setSize({ w: W, h: H });
}
function onDown(e: PEvt): void {
const card = cardRef.current;
if (!card) return;
const r = card.getBoundingClientRect();
from.current = { w: r.width, h: r.height, x: e.clientX, y: e.clientY, on: true };
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onMove(e: PEvt): void {
if (!from.current.on) return;
set(from.current.w + (e.clientX - from.current.x), from.current.h + (e.clientY - from.current.y));
}
function onUp(): void { from.current.on = false; }
function onKey(e: KeyEvt): void {
const d = e.key === "ArrowLeft" ? -8 : e.key === "ArrowRight" ? 8 : 0;
if (!d) return;
set(size.w + d, (size.w + d) * ratio);
e.preventDefault();
}
const vars: Vars = {
"--w": `${size.w}px`,
"--h": `${size.h}px`,
"--wn": String(size.w),
"--hn": String(size.h),
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={locked ? `${styles.cg} ${styles.isAspect}` : styles.cg} style={vars}>
<div className={styles.field}>
<div className={styles.card} ref={cardRef}>
<span className={styles.row} />
<span className={`${styles.row} ${styles.rowShort}`} />
<span className={styles.size} />
<i
className={styles.grip}
role="slider"
tabIndex={0}
aria-label="Card size"
aria-valuemin={120}
aria-valuemax={220}
aria-valuenow={size.w}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={onKey}
/>
</div>
</div>
<button
type="button"
className={locked ? `${styles.lock} ${styles.isOn}` : styles.lock}
aria-pressed={locked}
onClick={(): void => setLocked(!locked)}
>
<span className={styles.ico} aria-hidden="true">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2" /><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z" /></g></svg>
</span>
Lock ratio
</button>
</div>
);
}08 · 카드 코너 그립 — 측정 누적면적 18.377% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
카드 안에 적히는 숫자도 같은 속성을 읽으므로 실제 크기와 어긋나지 않습니다.
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 아래로 내려가는 순간 좌우로 나뉘어 있던 두 칸이 위아래로 바뀌고, 손잡이도 세로에서 가로로 같이 돌아눕습니다.

<main class="stage">
<div class="as is-demo" id="as-root">
<div class="as__frame" id="as-frame">
<div class="as__box" id="as-box">
<section class="as__pane as__pane--a" aria-label="위쪽 화면">
<span class="as__ln"></span>
<span class="as__ln as__ln--s"></span>
</section>
<div class="as__bar" id="as-bar" role="separator" tabindex="0"
aria-orientation="vertical" aria-label="분할 위치" aria-valuemin="20" aria-valuemax="80" aria-valuenow="50">
<i class="as__grip" aria-hidden="true"></i>
</div>
<section class="as__pane as__pane--b" aria-label="아래쪽 화면">
<span class="as__ln as__ln--d"></span>
<span class="as__ln as__ln--d as__ln--s"></span>
</section>
</div>
</div>
<p class="as__cap">
<span class="as__ico as__ico--wide" aria-hidden="true"><svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 64v112a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16V64a16 16 0 0 1 16-16h160a16 16 0 0 1 16 16" opacity=".2"/><path d="M208 40H48a24 24 0 0 0-24 24v112a24 24 0 0 0 24 24h160a24 24 0 0 0 24-24V64a24 24 0 0 0-24-24m8 136a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V64a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8Zm-48 48a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8"/></g></svg></span>
<span class="as__ico as__ico--narrow" aria-hidden="true"><svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M192 56v144H64V56Z" opacity=".2"/><path d="M176 16H80a24 24 0 0 0-24 24v176a24 24 0 0 0 24 24h96a24 24 0 0 0 24-24V40a24 24 0 0 0-24-24M72 64h112v128H72Zm8-32h96a8 8 0 0 1 8 8v8H72v-8a8 8 0 0 1 8-8m96 192H80a8 8 0 0 1-8-8v-8h112v8a8 8 0 0 1-8 8"/></g></svg></span>
좁아지면 좌우가 위아래로 바뀐다
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
@property --w {
syntax: "<length>";
inherits: true;
initial-value: 280px;
}
.as {
--w: 280px;
--split: 50%;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
// 상자만 좁아진다 — 자리는 고정이라 무대 높이가 흔들리지 않는다
.as__frame {
display: grid;
place-items: center;
width: 288px;
max-width: 92vw;
height: 116px;
}
.as__box {
box-sizing: border-box;
width: min(var(--w), 100%);
height: 116px;
display: grid;
grid-template-columns: var(--split) 8px 1fr;
grid-template-rows: 1fr;
border-radius: $r-card;
overflow: hidden;
background: rgba(23, 20, 26, .24);
}
// 세로 배치에서는 열이 하나, 행이 셋
.as.is-stack .as__box {
grid-template-columns: 1fr;
grid-template-rows: var(--split) 8px 1fr;
}
.as__pane {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-2;
padding: $sp-3;
overflow: hidden;
}
.as__ln {
flex: 0 0 auto;
height: 8px;
border-radius: $r-xs;
background: rgba(23, 20, 26, .2);
}
.as__ln--d { background: rgba(255, 247, 230, .3); }
.as__ln--s { width: 56%; }
.as__pane--a { background: rgba(255, 247, 230, .88); }
.as__pane--b { background: rgba(23, 20, 26, .72); }
.as__bar {
display: grid;
place-items: center;
background: rgba(47, 109, 246, .9);
cursor: col-resize;
touch-action: none;
}
.as.is-stack .as__bar { cursor: row-resize; }
.as__grip {
display: block;
width: 2px;
height: 24px;
border-radius: $r-pill;
background: $color;
transition: transform $duration $easing;
}
.as.is-stack .as__grip { transform: rotate(90deg); }
.as__bar:focus-visible { outline: 2px solid $subject-cream; outline-offset: -2px; }
.as__cap {
display: flex;
align-items: center;
gap: $sp-2;
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 700;
color: $subject-ink; // AA 4.5: cream on orange 2.83 -> ink 5.50
text-align: center;
}
.as__ico { display: block; width: 14px; height: 14px; }
.as__ico svg { display: block; width: 14px; height: 14px; }
.as__ico--narrow { opacity: .35; }
// ── 자동 루프: 상자 폭이 줄었다 늘고, 배치는 기준선에서 한 프레임에 컷으로 뒤집힌다 ──
.as.is-demo .as__box {
animation-name: as-width, as-mode;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
}
@keyframes as-width {
0%, 10% { --w: 280px; }
44%, 58% { --w: 148px; }
88%, 100% { --w: 280px; }
}
@keyframes as-mode {
0%, 30% { grid-template-columns: var(--split) 8px 1fr; grid-template-rows: 1fr; }
31%, 71% { grid-template-columns: 1fr; grid-template-rows: var(--split) 8px 1fr; }
72%, 100% { grid-template-columns: var(--split) 8px 1fr; grid-template-rows: 1fr; }
}
.as.is-demo .as__grip { animation: as-grip $dur-loop steps(1, end) infinite; }
@keyframes as-grip {
0%, 30% { transform: rotate(0deg); }
31%, 71% { transform: rotate(90deg); }
72%, 100% { transform: rotate(0deg); }
}
.as.is-demo .as__ico--wide { animation: as-w $dur-loop steps(1, end) infinite; }
.as.is-demo .as__ico--narrow { animation: as-n $dur-loop steps(1, end) infinite; }
@keyframes as-w { 0%, 30% { opacity: 1; } 31%, 71% { opacity: .35; } 72%, 100% { opacity: 1; } }
@keyframes as-n { 0%, 30% { opacity: .35; } 31%, 71% { opacity: 1; } 72%, 100% { opacity: .35; } }
@media (prefers-reduced-motion: reduce) {
.as.is-demo .as__box,
.as.is-demo .as__grip,
.as.is-demo .as__ico--wide,
.as.is-demo .as__ico--narrow { animation: none !important; }
.as__grip { transition: none; }
}
@media (max-width: 340px) {
.as__frame { height: 104px; }
.as__box { height: 104px; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --w {
syntax: "<length>";
inherits: true;
initial-value: 280px;
}
.as {
--w: 280px;
--split: 50%;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.as__frame {
display: grid;
place-items: center;
width: 288px;
max-width: 92vw;
height: 116px;
}
.as__box {
box-sizing: border-box;
width: min(var(--w), 100%);
height: 116px;
display: grid;
grid-template-columns: var(--split) 8px 1fr;
grid-template-rows: 1fr;
border-radius: 16px;
overflow: hidden;
background: rgba(23, 20, 26, 0.24);
}
.as.is-stack .as__box {
grid-template-columns: 1fr;
grid-template-rows: var(--split) 8px 1fr;
}
.as__pane {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
padding: 12px;
overflow: hidden;
}
.as__ln {
flex: 0 0 auto;
height: 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.2);
}
.as__ln--d {
background: rgba(255, 247, 230, 0.3);
}
.as__ln--s {
width: 56%;
}
.as__pane--a {
background: rgba(255, 247, 230, 0.88);
}
.as__pane--b {
background: rgba(23, 20, 26, 0.72);
}
.as__bar {
display: grid;
place-items: center;
background: rgba(47, 109, 246, 0.9);
cursor: col-resize;
touch-action: none;
}
.as.is-stack .as__bar {
cursor: row-resize;
}
.as__grip {
display: block;
width: 2px;
height: 24px;
border-radius: 999px;
background: #fff7e6;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.as.is-stack .as__grip {
transform: rotate(90deg);
}
.as__bar:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: -2px;
}
.as__cap {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 11px;
line-height: 14px;
font-weight: 700;
color: #17141a;
text-align: center;
}
.as__ico {
display: block;
width: 14px;
height: 14px;
}
.as__ico svg {
display: block;
width: 14px;
height: 14px;
}
.as__ico--narrow {
opacity: 0.35;
}
.as.is-demo .as__box {
animation-name: as-width, as-mode;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
@keyframes as-width {
0%, 10% {
--w: 280px;
}
44%, 58% {
--w: 148px;
}
88%, 100% {
--w: 280px;
}
}
@keyframes as-mode {
0%, 30% {
grid-template-columns: var(--split) 8px 1fr;
grid-template-rows: 1fr;
}
31%, 71% {
grid-template-columns: 1fr;
grid-template-rows: var(--split) 8px 1fr;
}
72%, 100% {
grid-template-columns: var(--split) 8px 1fr;
grid-template-rows: 1fr;
}
}
.as.is-demo .as__grip {
animation: as-grip 2s steps(1, end) infinite;
}
@keyframes as-grip {
0%, 30% {
transform: rotate(0deg);
}
31%, 71% {
transform: rotate(90deg);
}
72%, 100% {
transform: rotate(0deg);
}
}
.as.is-demo .as__ico--wide {
animation: as-w 2s steps(1, end) infinite;
}
.as.is-demo .as__ico--narrow {
animation: as-n 2s steps(1, end) infinite;
}
@keyframes as-w {
0%, 30% {
opacity: 1;
}
31%, 71% {
opacity: 0.35;
}
72%, 100% {
opacity: 1;
}
}
@keyframes as-n {
0%, 30% {
opacity: 0.35;
}
31%, 71% {
opacity: 1;
}
72%, 100% {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
.as.is-demo .as__box,
.as.is-demo .as__grip,
.as.is-demo .as__ico--wide,
.as.is-demo .as__ico--narrow {
animation: none !important;
}
.as__grip {
transition: none;
}
}
@media (max-width: 340px) {
.as__frame {
height: 104px;
}
.as__box {
height: 104px;
}
}// 실제 동작: ResizeObserver 가 상자 폭을 재고 200px 아래로 내려가면 세로 배치로 뒤집는다.
var root = document.getElementById('as-root');
var frame = document.getElementById('as-frame');
var box = document.getElementById('as-box');
var bar = document.getElementById('as-bar');
var FLIP = 200, dragging = false, stacked = false;
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);
function set(pct) {
var v = Math.max(20, Math.min(pct, 80));
root.style.setProperty('--split', v.toFixed(1) + '%');
bar.setAttribute('aria-valuenow', String(Math.round(v)));
}
bar.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragging = true;
bar.setPointerCapture(e.pointerId);
e.preventDefault();
});
bar.addEventListener('pointermove', function (e) {
if (!dragging) return;
var r = box.getBoundingClientRect();
set(stacked ? (e.clientY - r.top) / r.height * 100 : (e.clientX - r.left) / r.width * 100);
});
bar.addEventListener('pointerup', function () { dragging = false; });
bar.addEventListener('keydown', function (e) {
var d = (e.key === 'ArrowLeft' || e.key === 'ArrowUp') ? -5 : (e.key === 'ArrowRight' || e.key === 'ArrowDown') ? 5 : 0;
if (!d) return;
root.classList.remove('is-demo');
set(Number(bar.getAttribute('aria-valuenow')) + d);
bar.focus({ preventScroll: true });
e.preventDefault();
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AutoStackSplit.module.scss";
export type AutoStackSplitProps = {
/** 이 폭 아래로 내려가면 위아래 배치로 뒤집는다 (px) */
flipAt?: number;
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: 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 };
export default function AutoStackSplit({
flipAt = 200,
start = 50,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: AutoStackSplitProps) {
const [split, setSplit] = useState(start);
const [stacked, setStacked] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
// 미디어쿼리가 아니라 **상자 폭**을 잰다 — 같은 컴포넌트가 어디에 놓여도 맞는다
useEffect(() => {
const box = boxRef.current;
if (!box) return;
const ro = new ResizeObserver((entries) => {
setStacked(entries[0].contentRect.width < flipAt);
});
ro.observe(box);
return () => ro.disconnect();
}, [flipAt]);
function set(pct: number): void { setSplit(Math.max(20, Math.min(pct, 80))); }
function onDown(e: PEvt): void {
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onMove(e: PEvt): void {
const box = boxRef.current;
if (!dragging.current || !box) return;
const r = box.getBoundingClientRect();
set(stacked ? ((e.clientY - r.top) / r.height) * 100 : ((e.clientX - r.left) / r.width) * 100);
}
function onUp(): void { dragging.current = false; }
function onKey(e: KeyEvt): void {
const d = e.key === "ArrowLeft" || e.key === "ArrowUp" ? -5 : e.key === "ArrowRight" || e.key === "ArrowDown" ? 5 : 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={stacked ? `${styles.as} ${styles.isStack}` : styles.as} style={vars}>
<div className={styles.frame}>
<div className={styles.box} ref={boxRef}>
<section className={`${styles.pane} ${styles.paneA}`} aria-label="Top pane">
<span className={styles.ln} />
<span className={`${styles.ln} ${styles.lnShort}`} />
</section>
<div
className={styles.bar}
role="separator"
tabIndex={0}
aria-orientation={stacked ? "horizontal" : "vertical"}
aria-label="Split position"
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.paneB}`} aria-label="Bottom pane">
<span className={`${styles.ln} ${styles.lnDark}`} />
<span className={`${styles.ln} ${styles.lnDark} ${styles.lnShort}`} />
</section>
</div>
</div>
<p className={styles.cap}>Narrow enough and the sides become stacked</p>
</div>
);
}09 · 반응형 자동 스택 — 측정 누적면적 19.089% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
화면 크기를 재는 미디어 쿼리가 아니라 ResizeObserver로 상자 자신의 폭을 재기 때문에, 같은 부품을 좁은 사이드바에 넣어도 알아서 뒤집힙니다.
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번은 눌린 표시를 비율이 도착한 뒤로 미뤘습니다. 둘 다 사람 눈에는 "가끔 이상한 장면"으로만 보이고 어떤 검사에도 걸리지 않는 종류였습니다.
| 증상 | 진짜 원인 | 고치는 자리 |
|---|---|---|
| 접기·프리셋 이동이 툭툭 끊긴다 | 비율 속성이 등록되지 않아 중간값이 없다 | @property에 syntax: "<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로 감싼 이유가 그것이고, 예외가 나도 값을 못 읽었을 뿐이므로 기본 비율로 조용히 시작합니다. 이 처리를 빼면 저장이 막힌 브라우저에서 스크립트가 거기서 끊겨 손잡이 자체가 움직이지 않습니다.








