04더블클릭 접기
손잡이를 두 번 연속 누르면 옆 패널이 한 번에 얇은 띠로 접히고, 다시 두 번 누르면 접기 전 폭 그대로 펼쳐집니다.
쓰임사이드바를 자주 숨겼다 꺼내는 화면에서 버튼을 따로 두지 않고 손잡이 하나로 끝내고 싶을 때
dblclickaria-expandedtransition

<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 포함 · CSS 화면 분할 패널 9종 — 드래그로 폭이 따라오는
이 데모가 속한 글
CSS 화면 분할 패널 9종 — 드래그로 폭이 따라오는css 화면 분할은 한 화면을 두세 칸으로 나누고 그 경계선을 잡아 끌어 각 칸의 크기를 바꾸는 배치 방식입니다. 아홉 개 중 여덟은 경계선 하나만 옮겨도 옆 칸이 같이 줄고, 하나는 상자가 좁아지는 순간 좌우가 위 이 글의 데모 전부 보기

