UI Components Hard
Resizable Panels
Draggable split panels — horizontal and vertical resize, with min/max constraints, collapse/expand, and a multi-panel layout.
Open in Lab
MCP
css vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #050910;
--card: #0d1117;
--card2: #0a0f1a;
--border: rgba(255, 255, 255, 0.08);
--text: #f2f6ff;
--muted: #475569;
--accent: #38bdf8;
--success: #4ade80;
--radius: 12px;
}
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
min-height: 100vh;
padding: 2.5rem 1.5rem;
}
.page {
max-width: 900px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 2.5rem;
}
.demo-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.demo-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--muted);
}
/* ── Panels container ── */
.panels {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
display: flex;
height: 260px;
background: var(--card);
user-select: none;
}
.panels--v {
flex-direction: column;
}
/* ── Panel ── */
.panel {
flex: 1;
min-width: 80px;
min-height: 60px;
overflow: hidden;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.panel-sidebar {
flex-shrink: 0;
}
/* ── Panel header ── */
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.875rem;
border-bottom: 1px solid var(--border);
background: var(--card2);
flex-shrink: 0;
min-height: 36px;
}
.panel-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.04em;
}
.panel-tabs {
display: flex;
gap: 0.5rem;
}
.panel-tab {
font-size: 0.72rem;
color: var(--muted);
padding: 0.2rem 0.5rem;
border-radius: 4px;
cursor: pointer;
}
.panel-tab.active {
background: rgba(56, 189, 248, 0.1);
color: var(--accent);
}
/* ── Panel body ── */
.panel-body {
flex: 1;
overflow: auto;
padding: 0.625rem;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
}
.panel-body::-webkit-scrollbar {
width: 4px;
}
.panel-body::-webkit-scrollbar-track {
background: transparent;
}
.panel-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
/* ── Resize handle ── */
.resize-handle {
flex-shrink: 0;
background: var(--border);
position: relative;
z-index: 1;
transition: background 0.15s;
}
.resize-handle--h {
width: 4px;
cursor: col-resize;
}
.resize-handle--v {
height: 4px;
cursor: row-resize;
}
.resize-handle:hover,
.resize-handle:focus-visible,
.resize-handle.dragging {
background: var(--accent);
outline: none;
}
/* Widen hit area without changing visual width */
.resize-handle--h::before {
content: "";
position: absolute;
top: 0;
left: -4px;
right: -4px;
bottom: 0;
}
.resize-handle--v::before {
content: "";
position: absolute;
left: 0;
top: -4px;
right: 0;
bottom: -4px;
}
/* ── File tree ── */
.file-tree {
list-style: none;
font-size: 0.8rem;
line-height: 2.2;
}
.ft-dir > span,
.ft-file {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0 0.25rem;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
color: var(--muted);
transition: background 0.15s, color 0.15s;
}
.ft-dir > span:hover,
.ft-file:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.ft-file.active {
background: rgba(56, 189, 248, 0.08);
color: var(--accent);
}
.ft-dir ul {
list-style: none;
padding-left: 1rem;
}
/* ── Code block ── */
.panel-body--code {
padding: 0.875rem;
}
.code-block {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 0.78rem;
line-height: 1.8;
color: var(--text);
white-space: pre;
}
.c-tag {
color: #7dd3fc;
}
.c-attr {
color: #86efac;
}
.c-str {
color: #fde68a;
}
.c-cmt {
color: var(--muted);
font-style: italic;
}
/* ── Preview ── */
.panel-body--preview {
display: flex;
align-items: center;
justify-content: center;
}
.preview-box {
width: 80%;
display: flex;
flex-direction: column;
gap: 8px;
}
.preview-bar {
height: 10px;
background: rgba(56, 189, 248, 0.15);
border-radius: 5px;
}
.preview-bar--short {
width: 45%;
background: rgba(56, 189, 248, 0.08);
}
.preview-bar--medium {
width: 70%;
background: rgba(56, 189, 248, 0.08);
}
/* ── Terminal ── */
.terminal-dots {
display: flex;
gap: 5px;
}
.tdot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.tdot--red {
background: #f87171;
}
.tdot--yellow {
background: #fbbf24;
}
.tdot--green {
background: #4ade80;
}
.panel-body--terminal {
padding: 0.625rem 0.875rem;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 0.78rem;
}
.term-line {
line-height: 2;
color: var(--text);
}
.term-prompt {
color: var(--accent);
margin-right: 0.5rem;
}
.term-line--ok {
color: #4ade80;
}
.term-line--dim {
color: var(--muted);
}
/* ── Three-panel ── */
.placeholder-list {
display: flex;
flex-direction: column;
}
.pl-item {
padding: 0.4rem 0.5rem;
font-size: 0.82rem;
color: var(--muted);
border-radius: 5px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.pl-item:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.pl-item.active {
background: rgba(56, 189, 248, 0.08);
color: var(--accent);
}
.panel-body--center-placeholder {
display: flex;
align-items: center;
justify-content: center;
}
.center-msg {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--muted);
font-size: 0.85rem;
}
.prop-row {
display: flex;
justify-content: space-between;
padding: 0.35rem 0.25rem;
font-size: 0.8rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.prop-key {
color: var(--muted);
}
.prop-val {
color: var(--accent);
font-variant-numeric: tabular-nums;
}(function () {
"use strict";
const MIN = 80; // px minimum per panel
// ── Init all resize handles ──────────────────────────────────────────────────
document.querySelectorAll(".panels").forEach(function (container) {
const dir = container.dataset.direction || "horizontal";
const handles = Array.from(container.querySelectorAll(".resize-handle"));
handles.forEach(function (handle) {
initHandle(handle, container, dir);
});
});
// ── Per-handle setup ─────────────────────────────────────────────────────────
function initHandle(handle, container, dir) {
const isH = dir === "horizontal";
// Find the panels on each side of this handle
function getPanels() {
const children = Array.from(container.children).filter(function (c) {
return !c.classList.contains("resize-handle");
});
const handles = Array.from(container.querySelectorAll(".resize-handle"));
const hIdx = handles.indexOf(handle);
return { before: children[hIdx], after: children[hIdx + 1] };
}
let startPos = 0;
let startBefore = 0;
let startAfter = 0;
let collapsed = false;
let savedBefore = 0;
let dragging = false;
// ── Drag ──────────────────────────────────────────────────────────────────
function onDown(e) {
e.preventDefault();
dragging = true;
handle.classList.add("dragging");
const { before, after } = getPanels();
startPos = isH ? getClientX(e) : getClientY(e);
startBefore = isH ? before.offsetWidth : before.offsetHeight;
startAfter = isH ? after.offsetWidth : after.offsetHeight;
document.addEventListener("mousemove", onMove);
document.addEventListener("touchmove", onMove, { passive: false });
document.addEventListener("mouseup", onUp);
document.addEventListener("touchend", onUp);
}
function onMove(e) {
if (!dragging) return;
e.preventDefault();
const { before, after } = getPanels();
const pos = isH ? getClientX(e) : getClientY(e);
const delta = pos - startPos;
const total = startBefore + startAfter;
let newBefore = Math.max(MIN, Math.min(total - MIN, startBefore + delta));
let newAfter = total - newBefore;
if (isH) {
before.style.flexBasis = newBefore + "px";
before.style.flex = "0 0 " + newBefore + "px";
after.style.flex = "1";
} else {
before.style.flexBasis = newBefore + "px";
before.style.flex = "0 0 " + newBefore + "px";
after.style.flex = "1";
}
// Update ARIA
handle.setAttribute("aria-valuenow", Math.round(newBefore));
}
function onUp() {
dragging = false;
handle.classList.remove("dragging");
document.removeEventListener("mousemove", onMove);
document.removeEventListener("touchmove", onMove);
document.removeEventListener("mouseup", onUp);
document.removeEventListener("touchend", onUp);
}
handle.addEventListener("mousedown", onDown);
handle.addEventListener("touchstart", onDown, { passive: false });
// ── Double-click to collapse/restore ──────────────────────────────────────
handle.addEventListener("dblclick", function () {
const { before, after } = getPanels();
if (collapsed) {
// Restore
before.style.flex = "0 0 " + savedBefore + "px";
after.style.flex = "1";
collapsed = false;
} else {
// Collapse
savedBefore = isH ? before.offsetWidth : before.offsetHeight;
before.style.flex = "0 0 0px";
after.style.flex = "1";
collapsed = true;
}
});
// ── Keyboard resize ───────────────────────────────────────────────────────
handle.addEventListener("keydown", function (e) {
const { before, after } = getPanels();
const step = 20;
const current = isH ? before.offsetWidth : before.offsetHeight;
let next = current;
if (isH) {
if (e.key === "ArrowLeft") next = Math.max(MIN, current - step);
if (e.key === "ArrowRight")
next = Math.min(before.offsetWidth + after.offsetWidth - MIN, current + step);
} else {
if (e.key === "ArrowUp") next = Math.max(MIN, current - step);
if (e.key === "ArrowDown")
next = Math.min(before.offsetHeight + after.offsetHeight - MIN, current + step);
}
if (next !== current) {
e.preventDefault();
before.style.flex = "0 0 " + next + "px";
after.style.flex = "1";
handle.setAttribute("aria-valuenow", next);
}
});
}
// ── Pointer helpers ──────────────────────────────────────────────────────────
function getClientX(e) {
return e.touches ? e.touches[0].clientX : e.clientX;
}
function getClientY(e) {
return e.touches ? e.touches[0].clientY : e.clientY;
}
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Resizable Panels</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<!-- ── Demo 1: Horizontal two-panel ── -->
<div class="demo-section">
<h2 class="demo-label">Horizontal — left sidebar | main content</h2>
<div class="panels panels--h" id="panels-h" data-direction="horizontal">
<div class="panel panel-sidebar" id="panel-h-left" style="flex-basis: 220px;">
<div class="panel-header">
<span class="panel-title">File Tree</span>
</div>
<div class="panel-body">
<ul class="file-tree">
<li class="ft-dir open">
<span>📁 src</span>
<ul>
<li class="ft-file active">📄 index.html</li>
<li class="ft-file">📄 style.css</li>
<li class="ft-file">📄 script.js</li>
</ul>
</li>
<li class="ft-dir">
<span>📁 components</span>
</li>
<li class="ft-file">📄 package.json</li>
<li class="ft-file">📄 README.md</li>
</ul>
</div>
</div>
<div class="resize-handle resize-handle--h" id="rh-h-1" tabindex="0" role="separator" aria-orientation="vertical" aria-label="Resize sidebar" aria-valuenow="220" aria-valuemin="80" aria-valuemax="500" title="Drag to resize · Double-click to collapse"></div>
<div class="panel" id="panel-h-right">
<div class="panel-header">
<span class="panel-title">Editor — index.html</span>
<div class="panel-tabs">
<span class="panel-tab active">index.html</span>
<span class="panel-tab">style.css</span>
</div>
</div>
<div class="panel-body panel-body--code">
<pre class="code-block"><code><span class="c-tag"><!DOCTYPE html></span>
<span class="c-tag"><html</span> <span class="c-attr">lang</span>=<span class="c-str">"en"</span><span class="c-tag">></span>
<span class="c-tag"><head></span>
<span class="c-tag"><meta</span> <span class="c-attr">charset</span>=<span class="c-str">"UTF-8"</span> <span class="c-tag">/></span>
<span class="c-tag"><title></span>My Page<span class="c-tag"></title></span>
<span class="c-tag"></head></span>
<span class="c-tag"><body></span>
<span class="c-cmt"><!-- content --></span>
<span class="c-tag"></body></span>
<span class="c-tag"></html></span></code></pre>
</div>
</div>
</div>
</div>
<!-- ── Demo 2: Vertical two-panel ── -->
<div class="demo-section">
<h2 class="demo-label">Vertical — top | bottom</h2>
<div class="panels panels--v" id="panels-v" data-direction="vertical" style="height: 280px;">
<div class="panel" id="panel-v-top" style="flex-basis: 130px;">
<div class="panel-header">
<span class="panel-title">Preview</span>
</div>
<div class="panel-body panel-body--preview">
<div class="preview-box">
<div class="preview-bar"></div>
<div class="preview-bar preview-bar--short"></div>
<div class="preview-bar preview-bar--medium"></div>
</div>
</div>
</div>
<div class="resize-handle resize-handle--v" id="rh-v-1" tabindex="0" role="separator" aria-orientation="horizontal" aria-label="Resize panels" aria-valuenow="130" aria-valuemin="60" aria-valuemax="220" title="Drag to resize · Double-click to collapse"></div>
<div class="panel" id="panel-v-bottom">
<div class="panel-header">
<span class="panel-title">Terminal</span>
<div class="terminal-dots">
<span class="tdot tdot--red"></span>
<span class="tdot tdot--yellow"></span>
<span class="tdot tdot--green"></span>
</div>
</div>
<div class="panel-body panel-body--terminal">
<div class="term-line"><span class="term-prompt">$</span> bun run dev</div>
<div class="term-line term-line--ok">✓ Server listening on http://localhost:4321</div>
<div class="term-line term-line--dim">watching for changes…</div>
</div>
</div>
</div>
</div>
<!-- ── Demo 3: Three-panel ── -->
<div class="demo-section">
<h2 class="demo-label">Three-panel — left | center | right</h2>
<div class="panels panels--h" id="panels-3" data-direction="horizontal">
<div class="panel" id="panel-3-left" style="flex-basis: 180px;">
<div class="panel-header">
<span class="panel-title">Explorer</span>
</div>
<div class="panel-body">
<div class="placeholder-list">
<div class="pl-item active">Components</div>
<div class="pl-item">Pages</div>
<div class="pl-item">Layouts</div>
<div class="pl-item">Styles</div>
</div>
</div>
</div>
<div class="resize-handle resize-handle--h" tabindex="0" role="separator" aria-orientation="vertical" aria-label="Resize left panel" title="Drag to resize · Double-click to collapse"></div>
<div class="panel" id="panel-3-center">
<div class="panel-header">
<span class="panel-title">Editor</span>
</div>
<div class="panel-body panel-body--center-placeholder">
<div class="center-msg">
<span>📝</span>
<span>Select a file to edit</span>
</div>
</div>
</div>
<div class="resize-handle resize-handle--h" tabindex="0" role="separator" aria-orientation="vertical" aria-label="Resize right panel" title="Drag to resize · Double-click to collapse"></div>
<div class="panel" id="panel-3-right" style="flex-basis: 200px;">
<div class="panel-header">
<span class="panel-title">Properties</span>
</div>
<div class="panel-body">
<div class="prop-row">
<span class="prop-key">Width</span>
<span class="prop-val">auto</span>
</div>
<div class="prop-row">
<span class="prop-key">Height</span>
<span class="prop-val">100%</span>
</div>
<div class="prop-row">
<span class="prop-key">Display</span>
<span class="prop-val">flex</span>
</div>
<div class="prop-row">
<span class="prop-key">Gap</span>
<span class="prop-val">0px</span>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Resizable Panels
Draggable split-panel layouts with horizontal and vertical orientations, min/max size constraints, and double-click collapse. Three demos show common IDE-style and dashboard layouts.
Variants
- Horizontal two-panel — left sidebar | main content
- Vertical two-panel — top pane | bottom pane
- Three-panel — left | center | right
How it works
- Panels use
display: flexwithflex-basisas the sizing unit mousedown/touchstarton a resize handle begins tracking pointer positionmousemove/touchmoveupdates the adjacent panels’flex-basisvalues in real time- A minimum size of 80px is enforced on each panel; the max is the container width minus the opposing minimum
mouseup/touchendends the drag and fires aresizeevent on the container
Collapse / expand
Double-clicking a handle collapses the left (or top) panel to 0px and saves the previous size. A second double-click restores it. Collapsed panels show a thin strip that remains draggable.