CSS Split Panel

Two side by side panels where the left one can be drag resized using CSS resize: horizontal, with no JavaScript.

Published August 26, 2026 Beginner 8 min read

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

// Files
index.njk
global.css
eleventy.config.js
allExamples.js
base.njk
drag corner ↘ to resize
// Preview
← select a file to preview

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.

// Editor
.panel { resize: vertical }
.panel { overflow: auto }
drag corner ↘ to resize
// Console
build finished in 240ms
0 errors, 0 warnings

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.

After
Before

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

resize
horizontal, vertical or both asks the browser for a native drag grip. none is the default on most elements, and it is what a <textarea> overrides.
overflow
Anything other than visible is required before resize has any effect. auto also gives the pane a scrollbar when its content no longer fits.
flex-shrink
0 removes the pane from the flex row's shrink negotiation, so the width the reader dragged to survives instead of being reclaimed.
flex
1 on 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.
filter
grayscale(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

FeatureChromeFirefoxSafariEdge
resize44479
flexbox21286.112
min-width423.112
filter1835679

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 overflow at its default. resize is 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: none or 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?
The element's 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?
Not on iOS. Can I Use records the 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?
No. The grip is rendered by the browser and there is no standard selector for it. What you can do is draw your own marker underneath it with a pseudo-element and pointer-events: none, which is what the braille character in these demos is.
How do I make a vertical split panel?
Set 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.
Does the resized width persist across page loads?
No. It is user interface state held by the browser for that element and it resets on navigation. CSS cannot read or store it, and there is no selector that matches an element the reader has resized, so persistence needs script.