CSS Split Panel
Two side by side panels where the left one can be drag resized using CSS resize: horizontal, with no JavaScript.
A CSS split panel gets its drag handle from resize: horizontal, a property the browser implements natively. There is no pointer event handling and no width calculation to write. Three declarations do the work: resize to ask for the handle, overflow to make the request legal, and flex-shrink: 0 to stop the flex row undoing the reader's drag.
The layout below is the file tree and preview pair every editor has. Two more follow: a vertical split for an editor stacked over a console, and a before and after comparison slider that uses the same resize handle to wipe one layer across another.
Drag the corner to resize
HTML
<div class="split-frame">
<div class="split-left">
<span class="split-label">// Files</span>
<div class="split-item">index.njk</div>
<div class="split-item">global.css</div>
<div class="split-item">eleventy.config.js</div>
<div class="split-item">allExamples.js</div>
<div class="split-item">base.njk</div>
<span class="split-hint">drag corner ↘ to resize</span>
</div>
<div class="split-right">
<span class="split-label">// Preview</span>
<div class="split-item">← select a file to preview</div>
</div>
</div>
CSS
.split-frame {
width: 100%;
height: 260px;
display: flex;
border: 1px solid #2a2a2d;
border-radius: 12px;
overflow: hidden;
}
.split-left {
resize: horizontal;
overflow: auto; /* required for resize to work */
min-width: 80px;
max-width: 70%;
width: 45%;
flex-shrink: 0; /* without this the panel gets squashed by the flex row */
position: relative;
border-right: 1px solid #2a2a2d;
background: #141415;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
/* Purely decorative marker sitting over the native resize corner. */
.split-left::after {
content: "⠿";
position: absolute;
bottom: .4rem;
right: .35rem;
font-size: .7rem;
color: #88888f;
opacity: .5;
pointer-events: none;
}
.split-right {
flex: 1; /* fill the rest */
overflow: auto;
background: #1c1c1e;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
.split-label {
font-family: "DM Mono", "Fira Code", Consolas, monospace;
font-size: .6rem;
letter-spacing: .1em;
text-transform: uppercase;
color: #88888f;
margin-bottom: .25rem;
}
.split-item {
background: #1c1c1e;
border: 1px solid #2a2a2d;
border-radius: 8px;
padding: .5rem .75rem;
font-size: .8rem;
color: #f0f0f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
.split-right .split-item {
background: #141415;
}
.split-hint {
font-family: "DM Mono", "Fira Code", Consolas, monospace;
font-size: .65rem;
color: #b8ff57;
letter-spacing: .04em;
margin-top: auto;
}
Other ways to build it
Vertical split
The same three declarations on the other axis. The wrapper becomes a column, resize changes to vertical, and the bounds move from width to height. Everything else is identical, including the requirement that overflow be set and the flex-shrink: 0 that stops the column reclaiming the height the reader dragged to.
HTML
<div class="split-vframe">
<div class="split-vtop">
<span class="split-label">// Editor</span>
<div class="split-item">.panel { resize: vertical }</div>
<div class="split-item">.panel { overflow: auto }</div>
<span class="split-hint">drag corner ↘ to resize</span>
</div>
<div class="split-vbottom">
<span class="split-label">// Console</span>
<div class="split-item">build finished in 240ms</div>
<div class="split-item">0 errors, 0 warnings</div>
</div>
</div>
CSS
.split-vframe {
display: flex;
flex-direction: column;
height: 300px;
overflow: hidden;
}
.split-vtop {
resize: vertical;
overflow: auto; /* still required, on either axis */
height: 62%;
min-height: 70px;
max-height: 78%;
flex-shrink: 0; /* or the column takes the height straight back */
}
.split-vbottom {
flex: 1;
overflow: auto;
}
Before and after comparison
The resize grip does not have to move a layout pane. Here two layers hold the same artwork, the lower one in full color and the upper one through filter: grayscale(1), and dragging the upper layer narrower wipes it away to reveal what is underneath. Both layers paint the gradient at a fixed background-size, which is what keeps the two halves in register as the boundary moves. The divider is white rather than the site accent because a filter applies to everything the element paints, borders included, so a green line would come out gray.
HTML
<div class="split-compare">
<div class="split-compare-layer split-compare-after">After</div>
<div class="split-compare-layer split-compare-before">Before</div>
</div>
CSS
.split-compare {
position: relative;
width: 320px;
height: 200px;
overflow: hidden;
border-radius: 12px;
}
.split-compare-layer {
position: absolute;
top: 0;
left: 0;
height: 100%;
/* a fixed paint size, so the two halves stay in register */
background-image: linear-gradient(135deg,
#b8ff57, #38bdf8 45%, #c084fc 100%);
background-size: 320px 200px;
background-repeat: no-repeat;
}
.split-compare-after {
width: 100%;
}
.split-compare-before {
width: 55%;
min-width: 44px;
max-width: 100%;
resize: horizontal;
overflow: hidden; /* required, as always */
filter: grayscale(1) contrast(.85);
/* white, not the accent: the filter grays the border along with
everything else the element paints */
border-right: 2px solid #ffffff;
}
How it works
resize: horizontal asks the browser to draw a native drag grip on the element. It only takes effect when overflow is something other than visible, and overflow: auto is the usual choice because it also handles content that outgrows the pane. min-width and max-width bound the drag so the panel can be neither collapsed to nothing nor pulled across the whole container. The right panel is flex: 1, so it takes whatever is left.
resize does nothing at all on an element whose overflow is visible, which is the default, so a resize: horizontal that appears to be ignored is almost always missing its overflow value. The reason is in the specification: resizing is defined for elements that establish a scroll container, since the browser needs somewhere for the content to go when the box gets smaller than it. auto is the practical choice, hidden and scroll work too.
The grip is drawn by the browser at the bottom right corner, or the bottom left in a right to left writing mode, and it cannot be styled. There is no pseudo-element for it in the standard and no way to move it to the middle of the edge where a real splitter would sit. The braille pattern character in this demo is a decoration positioned over the native grip with pointer-events: none, so it looks deliberate without intercepting the drag.
flex-shrink: 0 is the declaration that makes the pattern hold. Resizing sets a used width on the panel, but a flex item is shrinkable by default, so the flex algorithm immediately reclaims that width to fit the row and the panel springs back. Setting shrink to zero takes the panel out of that negotiation, and flex: 1 on the sibling absorbs the change instead.
Giving a pane overflow: auto has a second consequence worth knowing. It turns the pane into a scroll container, which is what animation-timeline: scroll() and view() look for when they walk up the tree. That is useful when you want a reveal tied to a pane rather than the page, and it is a trap when you did not, as covered in CSS scroll reveal.
The resized width is user state, not document state. It lives in the browser's box for that element and it is gone on reload, because CSS has nowhere to store it. There is also no event for it in CSS and no selector that matches a resized element, so anything that has to remember or react to the split needs script. Where the pane sizes matter more than the dragging, a grid with minmax() tracks gives a predictable layout with no handle at all, in the same way the columns work in CSS calendar.
CSS properties used
resizehorizontal,verticalorbothasks the browser for a native drag grip.noneis the default on most elements, and it is what a<textarea>overrides.overflow- Anything other than
visibleis required beforeresizehas any effect.autoalso gives the pane a scrollbar when its content no longer fits. flex-shrink0removes the pane from the flex row's shrink negotiation, so the width the reader dragged to survives instead of being reclaimed.flex1on the other pane lets it absorb whatever the resized pane gives up or takes, with no width of its own to maintain.min-width- Stops the pane being dragged down to nothing, which would leave the grip almost impossible to grab again.
max-width- Caps the drag so the other pane cannot be squeezed out of the layout entirely.
filtergrayscale(1)on the top layer of the comparison variant, so the wipe reveals a processed and an unprocessed version of the same artwork.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
resize | 4 | 4 | 4 | 79 |
flexbox | 21 | 28 | 6.1 | 12 |
min-width | 4 | 2 | 3.1 | 12 |
filter | 18 | 35 | 6 | 79 |
Figures come from Can I Use for the CSS resize property, Flexible Box Layout, min and max width, and CSS Filter Effects. The desktop numbers hide the important gap: Can I Use records resize as unsupported in every version of iOS Safari, in Opera Mini, and in the old Android browser. On those the handle never appears and the panel simply keeps its authored width, so the layout has to be usable without dragging. Treat resizing as an enhancement for pointer users and make sure the default split is the one most readers should get.
Accessibility notes
The native resize grip is not reachable from a keyboard in any browser, and it is not exposed as a control to assistive technology. Nobody can adjust the split without a mouse or a trackpad. That is acceptable while the default layout is fully usable and both panes are readable at their starting sizes, and it is not acceptable if content is only reachable after a drag.
The same gap applies on touch. Since iOS Safari has no support at all and touch dragging of a small corner grip is awkward everywhere else, phone readers get a fixed split. Design the mobile layout as a stack rather than a split, with a media query that removes the resize and lets the panes run full width one above the other.
Give each pane a heading and a landmark role rather than relying on the visual divider to explain the structure. A screen reader user moving through the page gets two regions in a row with no indication that they sit side by side, so the headings are what carry the relationship. The braille grip character should stay out of the accessibility tree, which it does here because it is generated content on a pseudo-element with pointer-events: none.
What you can build with it
- Editor and preview. Source on one side and rendered output on the other, the layout every online playground uses.
- File tree and detail. A navigation column the reader can widen when file names are long and narrow when they are not.
- Before and after comparison. The third demo on this page: one layer wiped across another to compare a processed image with the original.
- Log console. The vertical version, with output below the main view and a drag to give it more room when something goes wrong.
- Documentation with a sidebar. A contents column that the reader can pull wider on a large screen without the site remembering anything.
Mistakes worth avoiding
- Leaving
overflowat its default.resizeis silently ignored, no grip appears, and nothing in the console says why. - Forgetting
flex-shrink: 0. The drag appears to work and then the panel snaps back, because the flex row reclaims the width as soon as it needs it. - Omitting
min-width. The pane can be dragged to zero, at which point the grip is a two pixel target in the corner and the reader has no way to recover the layout. - Styling the grip. There is no standard pseudo-element for it, so any visible handle is a separate decoration that has to sit behind it with
pointer-events: noneor it will block the drag. - Building a layout that only works after a resize. iOS Safari, Opera Mini and keyboard users never get the handle, so the authored split is the only split they will ever see.
Frequently asked questions
Why is CSS resize not working?
overflow is still visible. resize only applies to elements that establish a scroll container, so add overflow: auto and the grip appears. The second most common cause is a flex parent reclaiming the width, which flex-shrink: 0 fixes.Does CSS resize work on mobile?
resize property as unsupported across every version of iOS Safari, along with Opera Mini and the legacy Android browser. Android Chrome supports it but a corner grip is a poor touch target. Stack the panes on small screens instead.Can I style the resize handle?
pointer-events: none, which is what the braille character in these demos is.How do I make a vertical split panel?
resize: vertical on the top pane, give the wrapper flex-direction: column, and swap the width constraints for min-height and max-height. The vertical variant above is the same three declarations on the other axis.